[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
{
"name": "@netcatty/plugin-cli",
"version": "0.1.0-internal",
"private": true,
"type": "module",
"license": "GPL-3.0-or-later",
"files": ["dist"],
"bin": {
"netcatty-plugin": "./dist/cli.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.build.json"
},
"dependencies": {
"@netcatty/plugin-contract": "0.1.0-internal",
"ajv": "8.18.0",
"ajv-formats": "3.0.1",
"semver": "^7.7.3",
"yauzl": "^3.2.0"
},
"devDependencies": {
"@types/semver": "^7.7.1",
"@types/yauzl": "^2.10.3"
}
}

View File

@@ -0,0 +1,796 @@
import { createHash, randomUUID } from "node:crypto";
import { constants, createReadStream, createWriteStream } from "node:fs";
import {
chmod,
lstat,
mkdir,
open,
opendir,
realpath,
rename,
rm,
stat,
} from "node:fs/promises";
import path from "node:path";
import { once } from "node:events";
import type { IconReference, PluginManifest } from "@netcatty/plugin-contract";
import yauzl, { type Entry, type ZipFile } from "yauzl";
import { IGNORED_ROOT_ENTRIES, PACKAGE_LIMITS } from "./constants.js";
import {
parseAndValidateManifestContents,
readValidatedManifestSource,
type ValidatedManifestSource,
} from "./manifest.js";
import { assertSafePackagePath, PackagePathRegistry } from "./packagePath.js";
const CRC32_TABLE = new Uint32Array(256);
const EXECUTABLE_EXTENSIONS = new Set([".bat", ".cmd", ".com", ".exe", ".ps1"]);
for (let index = 0; index < CRC32_TABLE.length; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) === 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
CRC32_TABLE[index] = value >>> 0;
}
interface ScannedFile {
readonly absolutePath: string;
readonly packagePath: string;
readonly size: number;
readonly crc32: number;
readonly sha256: string;
readonly executable: boolean;
}
interface ScannedManifestIdentity {
readonly size: number;
readonly sha256: string;
}
export function assertManifestSnapshotMatches(
source: Pick<ValidatedManifestSource, "size" | "sha256">,
scanned: ScannedManifestIdentity | undefined,
): void {
if (!scanned || scanned.size !== source.size || scanned.sha256 !== source.sha256) {
throw new Error("Plugin manifest changed after validation");
}
}
export interface PackageBuildResult {
readonly outputPath: string;
readonly fileCount: number;
readonly uncompressedBytes: number;
readonly archiveBytes: number;
readonly sha256: string;
readonly contentSha256: string;
}
export interface PackageValidationResult {
readonly manifest: PluginManifest;
readonly fileCount: number;
readonly uncompressedBytes: number;
readonly contentSha256: string;
}
export type PluginDirectoryValidationResult = PackageValidationResult;
function updateCrc32(current: number, chunk: Buffer): number {
let crc = current;
for (const byte of chunk) crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return crc >>> 0;
}
async function readRegularFile(
filePath: string,
maxBytes: number,
onChunk: (chunk: Buffer) => void,
): Promise<{ size: number; mode: number }> {
const noFollow = "O_NOFOLLOW" in constants
? constants.O_NOFOLLOW
: 0;
const handle = await open(filePath, constants.O_RDONLY | noFollow);
try {
const fileStats = await handle.stat();
if (!fileStats.isFile()) throw new Error(`Package source is not a regular file: ${filePath}`);
let size = 0;
const stream = createReadStream(filePath, { fd: handle.fd, autoClose: false });
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.byteLength;
if (size > maxBytes) {
throw new Error(`Package source exceeds ${maxBytes} bytes while reading: ${filePath}`);
}
onChunk(buffer);
}
return { size, mode: fileStats.mode };
} finally {
await handle.close();
}
}
export async function hashFile(
filePath: string,
maxBytes: number,
): Promise<{ crc32: number; sha256: string; size: number; mode: number }> {
const sha256 = createHash("sha256");
let crc = 0xffffffff;
const file = await readRegularFile(filePath, maxBytes, (buffer) => {
sha256.update(buffer);
crc = updateCrc32(crc, buffer);
});
return {
crc32: (crc ^ 0xffffffff) >>> 0,
sha256: sha256.digest("hex"),
size: file.size,
mode: file.mode,
};
}
function sortPackagePaths(left: ScannedFile, right: ScannedFile): number {
return Buffer.compare(Buffer.from(left.packagePath), Buffer.from(right.packagePath));
}
interface PackageContentIdentity {
readonly packagePath: string;
readonly size: number;
readonly sha256: string;
readonly executable: boolean;
}
/**
* Hash the logical package contents instead of the ZIP representation. This
* keeps integrity checks stable across valid ZIP encoders and compression
* choices while binding every path, byte length, executable bit, and file
* digest into one versioned identity.
*/
export function computePackageContentSha256(
entries: readonly PackageContentIdentity[],
): string {
const hash = createHash("sha256");
hash.update("netcatty-plugin-content-v1\0", "utf8");
const ordered = [...entries].sort((left, right) => (
Buffer.compare(Buffer.from(left.packagePath), Buffer.from(right.packagePath))
));
for (const entry of ordered) {
const packagePath = Buffer.from(entry.packagePath, "utf8");
const header = Buffer.allocUnsafe(13);
header.writeUInt32BE(packagePath.byteLength, 0);
header.writeBigUInt64BE(BigInt(entry.size), 4);
header.writeUInt8(entry.executable ? 1 : 0, 12);
hash.update(header);
hash.update(packagePath);
hash.update(Buffer.from(entry.sha256, "hex"));
}
return hash.digest("hex");
}
function isExecutablePackageFile(packagePath: string, mode: number): boolean {
return (mode & 0o111) !== 0
|| EXECUTABLE_EXTENSIONS.has(path.posix.extname(packagePath).toLowerCase());
}
function packageIconPaths(icon: IconReference | undefined): string[] {
if (icon?.kind !== "package") return [];
return icon.dark
? [icon.light, icon.dark]
: [icon.light];
}
function isSameOrDescendantPath(parentPath: string, candidatePath: string): boolean {
const relativePath = path.relative(parentPath, candidatePath);
return relativePath === ""
|| (!path.isAbsolute(relativePath)
&& relativePath !== ".."
&& !relativePath.startsWith(`..${path.sep}`));
}
async function resolveThroughExistingAncestor(targetPath: string): Promise<string> {
let currentPath = targetPath;
const missingSegments: string[] = [];
while (true) {
try {
const resolvedAncestor = await realpath(currentPath);
return path.join(resolvedAncestor, ...missingSegments.reverse());
} catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) throw error;
missingSegments.push(path.basename(currentPath));
currentPath = parentPath;
}
}
}
async function scanPackageDirectory(
pluginDirectory: string,
manifestSource: ValidatedManifestSource,
options: { allowIgnoredRootEntries?: boolean } = {},
): Promise<ScannedFile[]> {
const { manifest } = manifestSource;
const registry = new PackagePathRegistry();
const companionPaths = new Map(
(manifest.companionExecutables ?? []).flatMap((companion) => (
companion.variants.map((variant) => [variant.path, variant] as const)
)),
);
const files: ScannedFile[] = [];
let totalBytes = 0;
async function visit(directory: string, relativeDirectory: string): Promise<void> {
const entries = [];
for await (const entry of await opendir(directory)) entries.push(entry);
entries.sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name)));
for (const entry of entries) {
if (
relativeDirectory === ""
&& (IGNORED_ROOT_ENTRIES.has(entry.name) || entry.name.endsWith(".ncpkg"))
) {
if (options.allowIgnoredRootEntries === false) {
throw new Error(`Installed plugin contains an unpackaged root entry: ${entry.name}`);
}
continue;
}
const relativePath = relativeDirectory
? `${relativeDirectory}/${entry.name}`
: entry.name;
const packagePath = assertSafePackagePath(relativePath);
const absolutePath = path.join(directory, entry.name);
const fileStats = await lstat(absolutePath);
if (fileStats.isSymbolicLink()) {
throw new Error(`Symbolic links are not allowed in plugin packages: ${packagePath}`);
}
if (fileStats.isDirectory()) {
await visit(absolutePath, packagePath);
continue;
}
if (!fileStats.isFile()) {
throw new Error(`Only regular files are allowed in plugin packages: ${packagePath}`);
}
registry.add(packagePath);
if (files.length + 1 > PACKAGE_LIMITS.fileCount) {
throw new Error(`Plugin package exceeds ${PACKAGE_LIMITS.fileCount} files`);
}
if (fileStats.size > PACKAGE_LIMITS.singleFileBytes) {
throw new Error(`Plugin file exceeds ${PACKAGE_LIMITS.singleFileBytes} bytes: ${packagePath}`);
}
totalBytes += fileStats.size;
if (totalBytes > PACKAGE_LIMITS.uncompressedBytes) {
throw new Error(
`Plugin package exceeds ${PACKAGE_LIMITS.uncompressedBytes} uncompressed bytes`,
);
}
const hashes = await hashFile(absolutePath, PACKAGE_LIMITS.singleFileBytes);
if (hashes.size !== fileStats.size || hashes.mode !== fileStats.mode) {
throw new Error(`Package source changed while it was being scanned: ${packagePath}`);
}
const isExecutable = isExecutablePackageFile(packagePath, hashes.mode);
if (isExecutable && !companionPaths.has(packagePath)) {
throw new Error(`Executable file is not declared as a companion: ${packagePath}`);
}
const companion = companionPaths.get(packagePath);
if (companion && companion.sha256 !== hashes.sha256) {
throw new Error(`Companion SHA-256 mismatch: ${packagePath}`);
}
files.push({
absolutePath,
packagePath,
size: fileStats.size,
executable: Boolean(companion),
crc32: hashes.crc32,
sha256: hashes.sha256,
});
}
}
await visit(pluginDirectory, "");
assertManifestSnapshotMatches(
manifestSource,
files.find(({ packagePath }) => packagePath === "netcatty.plugin.json"),
);
const packagedPaths = new Set(files.map(({ packagePath }) => packagePath));
const requiredPaths = [
"netcatty.plugin.json",
manifest.main.browser,
manifest.main.node,
...(manifest.contributes?.views ?? []).map(({ entry }) => entry),
...(manifest.contributes?.commands ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
...(manifest.contributes?.menus ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
...(manifest.contributes?.views ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
...companionPaths.keys(),
].filter((entryPath): entryPath is string => Boolean(entryPath));
for (const requiredPath of requiredPaths) {
if (!packagedPaths.has(requiredPath)) {
throw new Error(`Manifest references a missing package file: ${requiredPath}`);
}
}
return files.sort(sortPackagePaths);
}
function makeLocalHeader(file: ScannedFile): Buffer {
const name = Buffer.from(file.packagePath, "utf8");
const header = Buffer.alloc(30 + name.byteLength);
header.writeUInt32LE(0x04034b50, 0);
header.writeUInt16LE(20, 4);
header.writeUInt16LE(0x0800, 6);
header.writeUInt16LE(0, 8);
header.writeUInt16LE(0, 10);
header.writeUInt16LE(33, 12);
header.writeUInt32LE(file.crc32, 14);
header.writeUInt32LE(file.size, 18);
header.writeUInt32LE(file.size, 22);
header.writeUInt16LE(name.byteLength, 26);
header.writeUInt16LE(0, 28);
name.copy(header, 30);
return header;
}
function makeCentralHeader(file: ScannedFile, localOffset: number): Buffer {
const name = Buffer.from(file.packagePath, "utf8");
const header = Buffer.alloc(46 + name.byteLength);
header.writeUInt32LE(0x02014b50, 0);
header.writeUInt16LE(0x0314, 4);
header.writeUInt16LE(20, 6);
header.writeUInt16LE(0x0800, 8);
header.writeUInt16LE(0, 10);
header.writeUInt16LE(0, 12);
header.writeUInt16LE(33, 14);
header.writeUInt32LE(file.crc32, 16);
header.writeUInt32LE(file.size, 20);
header.writeUInt32LE(file.size, 24);
header.writeUInt16LE(name.byteLength, 28);
header.writeUInt16LE(0, 30);
header.writeUInt16LE(0, 32);
header.writeUInt16LE(0, 34);
header.writeUInt16LE(0, 36);
const mode = file.executable ? 0o100755 : 0o100644;
header.writeUInt32LE((mode << 16) >>> 0, 38);
header.writeUInt32LE(localOffset, 42);
name.copy(header, 46);
return header;
}
function makeEndOfCentralDirectory(
entryCount: number,
centralSize: number,
centralOffset: number,
): Buffer {
const footer = Buffer.alloc(22);
footer.writeUInt32LE(0x06054b50, 0);
footer.writeUInt16LE(0, 4);
footer.writeUInt16LE(0, 6);
footer.writeUInt16LE(entryCount, 8);
footer.writeUInt16LE(entryCount, 10);
footer.writeUInt32LE(centralSize, 12);
footer.writeUInt32LE(centralOffset, 16);
footer.writeUInt16LE(0, 20);
return footer;
}
async function writeBuffer(output: ReturnType<typeof createWriteStream>, buffer: Buffer) {
if (!output.write(buffer)) await once(output, "drain");
}
async function writeArchive(files: readonly ScannedFile[], outputPath: string): Promise<void> {
await mkdir(path.dirname(outputPath), { recursive: true });
const temporaryPath = `${outputPath}.tmp-${process.pid}-${randomUUID()}`;
const output = createWriteStream(temporaryPath, { flags: "wx", mode: 0o600 });
const centralHeaders: Buffer[] = [];
let offset = 0;
try {
await once(output, "open");
for (const file of files) {
const localHeader = makeLocalHeader(file);
centralHeaders.push(makeCentralHeader(file, offset));
await writeBuffer(output, localHeader);
offset += localHeader.byteLength;
const sha256 = createHash("sha256");
let crc = 0xffffffff;
let writtenBytes = 0;
const input = await open(
file.absolutePath,
constants.O_RDONLY | ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0),
);
try {
const inputStats = await input.stat();
if (!inputStats.isFile() || inputStats.size !== file.size) {
throw new Error(`Package source changed before archive write: ${file.packagePath}`);
}
const stream = createReadStream(file.absolutePath, { fd: input.fd, autoClose: false });
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
writtenBytes += buffer.byteLength;
if (writtenBytes > file.size) {
throw new Error(`Package source changed while it was being written: ${file.packagePath}`);
}
sha256.update(buffer);
crc = updateCrc32(crc, buffer);
await writeBuffer(output, buffer);
offset += buffer.byteLength;
}
} finally {
await input.close();
}
if (
writtenBytes !== file.size
|| ((crc ^ 0xffffffff) >>> 0) !== file.crc32
|| sha256.digest("hex") !== file.sha256
) {
throw new Error(`Package source changed while it was being written: ${file.packagePath}`);
}
}
const centralOffset = offset;
for (const centralHeader of centralHeaders) {
await writeBuffer(output, centralHeader);
offset += centralHeader.byteLength;
}
await writeBuffer(
output,
makeEndOfCentralDirectory(files.length, offset - centralOffset, centralOffset),
);
output.end();
await once(output, "close");
const archiveStats = await stat(temporaryPath);
if (archiveStats.size > PACKAGE_LIMITS.archiveBytes) {
throw new Error(`Plugin archive exceeds ${PACKAGE_LIMITS.archiveBytes} bytes`);
}
await rename(temporaryPath, outputPath);
} catch (error) {
output.destroy();
await rm(temporaryPath, { force: true });
throw error;
}
}
export async function buildPluginPackage(
pluginDirectory: string,
outputPath: string,
): Promise<PackageBuildResult> {
const sourceDirectory = path.resolve(pluginDirectory);
const resolvedOutputPath = path.resolve(outputPath);
if (!resolvedOutputPath.endsWith(".ncpkg")) {
throw new Error("Plugin package output must use the .ncpkg extension");
}
const canonicalSourceDirectory = await realpath(sourceDirectory);
const canonicalOutputPath = await resolveThroughExistingAncestor(resolvedOutputPath);
if (
isSameOrDescendantPath(sourceDirectory, resolvedOutputPath)
|| isSameOrDescendantPath(canonicalSourceDirectory, canonicalOutputPath)
) {
throw new Error("Plugin package output must be outside the plugin source directory");
}
const manifestSource = await readValidatedManifestSource(sourceDirectory);
const files = await scanPackageDirectory(sourceDirectory, manifestSource);
await writeArchive(files, resolvedOutputPath);
const outputStats = await stat(resolvedOutputPath);
const archiveHash = await hashFile(resolvedOutputPath, PACKAGE_LIMITS.archiveBytes);
return {
outputPath: resolvedOutputPath,
fileCount: files.length,
uncompressedBytes: files.reduce((sum, file) => sum + file.size, 0),
archiveBytes: outputStats.size,
sha256: archiveHash.sha256,
contentSha256: computePackageContentSha256(files),
};
}
export async function validatePluginDirectory(
pluginDirectory: string,
options: { allowIgnoredRootEntries?: boolean } = {},
): Promise<PluginDirectoryValidationResult> {
const sourceDirectory = path.resolve(pluginDirectory);
const manifestSource = await readValidatedManifestSource(sourceDirectory);
const files = await scanPackageDirectory(sourceDirectory, manifestSource, options);
return {
manifest: manifestSource.manifest,
fileCount: files.length,
uncompressedBytes: files.reduce((sum, file) => sum + file.size, 0),
contentSha256: computePackageContentSha256(files),
};
}
function openZip(filePath: string): Promise<ZipFile> {
return new Promise((resolve, reject) => {
yauzl.open(
filePath,
// Security invariant: for deflated entries, validateEntrySizes inserts an
// AssertByteCountStream that rejects an overflowing chunk before it is
// forwarded to readEntry(). Combined with the metadata limits below,
// captured manifest contents cannot exceed PACKAGE_LIMITS.manifestBytes.
{ lazyEntries: true, decodeStrings: true, strictFileNames: true, validateEntrySizes: true },
(error, zipFile) => {
if (error || !zipFile) reject(error ?? new Error("Unable to open plugin archive"));
else resolve(zipFile);
},
);
});
}
interface ReadArchiveEntryResult {
readonly bytes: number;
readonly crc32: number;
readonly sha256: string;
readonly contents?: Buffer;
}
interface LocalFileHeader {
readonly generalPurposeBitFlag: number;
readonly compressionMethod: number;
readonly crc32: number;
readonly compressedSize: number;
readonly uncompressedSize: number;
readonly fileName: Buffer;
}
interface ZipEntryWithRawName extends Entry {
readonly fileNameRaw?: Buffer;
}
interface ZipFileWithLocalHeader extends ZipFile {
readLocalFileHeader(
entry: Entry,
callback: (error: Error | null, header?: LocalFileHeader) => void,
): void;
}
function readLocalFileHeader(zipFile: ZipFile, entry: Entry): Promise<LocalFileHeader> {
return new Promise((resolve, reject) => {
(zipFile as ZipFileWithLocalHeader).readLocalFileHeader(entry, (error, header) => {
if (error || !header) {
reject(error ?? new Error(`Unable to read local ZIP header: ${entry.fileName}`));
} else {
resolve(header);
}
});
});
}
async function validateLocalFileHeader(
zipFile: ZipFile,
entry: Entry,
packagePath: string,
): Promise<void> {
if ((entry.generalPurposeBitFlag & 0x0800) === 0) {
throw new Error(`ZIP entry name must use UTF-8 encoding: ${packagePath}`);
}
if ((entry.generalPurposeBitFlag & 0x0008) !== 0) {
throw new Error(`ZIP data descriptors are not allowed: ${packagePath}`);
}
const centralName = (entry as ZipEntryWithRawName).fileNameRaw;
if (!centralName || !centralName.equals(Buffer.from(entry.fileName, "utf8"))) {
throw new Error(`ZIP central entry name is not canonical UTF-8: ${packagePath}`);
}
const local = await readLocalFileHeader(zipFile, entry);
if (!local.fileName.equals(centralName)) {
throw new Error(`ZIP local and central entry names differ: ${packagePath}`);
}
if (local.generalPurposeBitFlag !== entry.generalPurposeBitFlag) {
throw new Error(`ZIP local and central entry flags differ: ${packagePath}`);
}
if (local.compressionMethod !== entry.compressionMethod) {
throw new Error(`ZIP local and central compression methods differ: ${packagePath}`);
}
if (
local.crc32 !== entry.crc32
|| local.compressedSize !== entry.compressedSize
|| local.uncompressedSize !== entry.uncompressedSize
) {
throw new Error(`ZIP local and central integrity metadata differ: ${packagePath}`);
}
}
async function readEntry(
zipFile: ZipFile,
entry: Entry,
captureContents: boolean,
outputPath?: string,
): Promise<ReadArchiveEntryResult> {
const stream = await new Promise<NodeJS.ReadableStream>((resolve, reject) => {
zipFile.openReadStream(entry, (error, stream) => {
if (error || !stream) {
reject(error ?? new Error(`Unable to read archive entry: ${entry.fileName}`));
return;
}
resolve(stream);
});
});
const chunks: Buffer[] = [];
const sha256 = createHash("sha256");
let crc = 0xffffffff;
let bytes = 0;
let outputHandle;
try {
if (outputPath) {
await mkdir(path.dirname(outputPath), { recursive: true, mode: 0o700 });
outputHandle = await open(outputPath, "wx", 0o600);
}
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
bytes += buffer.byteLength;
if (bytes > PACKAGE_LIMITS.singleFileBytes) {
throw new Error(`Plugin file exceeds size limit: ${entry.fileName}`);
}
sha256.update(buffer);
crc = updateCrc32(crc, buffer);
if (captureContents) chunks.push(buffer);
if (outputHandle) {
let written = 0;
while (written < buffer.length) {
const result = await outputHandle.write(buffer, written, buffer.length - written);
if (result.bytesWritten === 0) {
throw new Error(`Unable to extract the complete archive entry: ${entry.fileName}`);
}
written += result.bytesWritten;
}
}
}
if (outputHandle) await outputHandle.sync();
return {
bytes,
crc32: (crc ^ 0xffffffff) >>> 0,
sha256: sha256.digest("hex"),
contents: captureContents ? Buffer.concat(chunks) : undefined,
};
} finally {
await outputHandle?.close();
}
}
async function inspectPluginPackage(
archivePath: string,
extractionDirectory?: string,
): Promise<PackageValidationResult> {
const archiveStats = await stat(archivePath);
if (archiveStats.size > PACKAGE_LIMITS.archiveBytes) {
throw new Error(`Plugin archive exceeds ${PACKAGE_LIMITS.archiveBytes} bytes`);
}
const zipFile = await openZip(archivePath);
const registry = new PackagePathRegistry();
const entries = new Map<string, {
contents?: Buffer;
mode: number;
sha256: string;
size: number;
}>();
let totalBytes = 0;
await new Promise<void>((resolve, reject) => {
const fail = (error: unknown) => {
zipFile.close();
reject(error);
};
zipFile.once("error", fail);
zipFile.once("end", resolve);
zipFile.on("entry", (entry: Entry) => {
void (async () => {
try {
const packagePath = registry.add(entry.fileName);
if (entries.size + 1 > PACKAGE_LIMITS.fileCount) {
throw new Error(`Plugin package exceeds ${PACKAGE_LIMITS.fileCount} files`);
}
if ((entry.generalPurposeBitFlag & 1) !== 0) {
throw new Error(`Encrypted ZIP entries are not allowed: ${packagePath}`);
}
if (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) {
throw new Error(`Unsupported ZIP compression method: ${packagePath}`);
}
await validateLocalFileHeader(zipFile, entry, packagePath);
const mode = (entry.externalFileAttributes >>> 16) & 0xffff;
const fileType = mode & 0o170000;
if (fileType !== 0 && fileType !== 0o100000) {
throw new Error(`Only regular files are allowed: ${packagePath}`);
}
if (entry.uncompressedSize > PACKAGE_LIMITS.singleFileBytes) {
throw new Error(`Plugin file exceeds size limit: ${packagePath}`);
}
if (
packagePath === "netcatty.plugin.json"
&& entry.uncompressedSize > PACKAGE_LIMITS.manifestBytes
) {
throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`);
}
totalBytes += entry.uncompressedSize;
if (totalBytes > PACKAGE_LIMITS.uncompressedBytes) {
throw new Error("Plugin package exceeds the uncompressed size limit");
}
const result = await readEntry(
zipFile,
entry,
packagePath === "netcatty.plugin.json",
extractionDirectory ? path.join(extractionDirectory, ...packagePath.split("/")) : undefined,
);
if (result.bytes !== entry.uncompressedSize || result.crc32 !== entry.crc32) {
throw new Error(`ZIP entry integrity check failed: ${packagePath}`);
}
entries.set(packagePath, {
contents: result.contents,
mode,
sha256: result.sha256,
size: result.bytes,
});
zipFile.readEntry();
} catch (error) {
fail(error);
}
})();
});
zipFile.readEntry();
});
const manifestEntry = entries.get("netcatty.plugin.json");
if (!manifestEntry?.contents) throw new Error("Plugin package is missing netcatty.plugin.json");
const manifest = parseAndValidateManifestContents(manifestEntry.contents);
const declaredCompanions = new Map(
(manifest.companionExecutables ?? []).flatMap((companion) => (
companion.variants.map((variant) => [variant.path, variant] as const)
)),
);
for (const [packagePath, entry] of entries) {
const isExecutable = isExecutablePackageFile(packagePath, entry.mode);
if (isExecutable && !declaredCompanions.has(packagePath)) {
throw new Error(`Executable file is not declared as a companion: ${packagePath}`);
}
}
const requiredPaths = [
manifest.main.browser,
manifest.main.node,
...(manifest.contributes?.views ?? []).map(({ entry }) => entry),
...(manifest.contributes?.commands ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
...(manifest.contributes?.menus ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
...(manifest.contributes?.views ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
].filter((entryPath): entryPath is string => Boolean(entryPath));
for (const requiredPath of requiredPaths) {
if (!entries.has(requiredPath)) {
throw new Error(`Manifest references a missing package file: ${requiredPath}`);
}
}
for (const [companionPath, companion] of declaredCompanions) {
const entry = entries.get(companionPath);
if (!entry) throw new Error(`Manifest references a missing companion: ${companionPath}`);
if (entry.sha256 !== companion.sha256) {
throw new Error(`Companion SHA-256 mismatch: ${companionPath}`);
}
}
if (extractionDirectory) {
for (const [packagePath] of entries) {
await chmod(
path.join(extractionDirectory, ...packagePath.split("/")),
declaredCompanions.has(packagePath) ? 0o700 : 0o600,
);
}
}
const contentSha256 = computePackageContentSha256(
[...entries].map(([packagePath, entry]) => ({
packagePath,
size: entry.size,
sha256: entry.sha256,
executable: declaredCompanions.has(packagePath),
})),
);
return { manifest, fileCount: entries.size, uncompressedBytes: totalBytes, contentSha256 };
}
export async function validatePluginPackage(
archivePath: string,
): Promise<PackageValidationResult> {
return inspectPluginPackage(archivePath);
}
/**
* Validates and extracts an immutable package snapshot into a new directory.
* Failed or incomplete destinations are always removed so only callers that
* receive a successful result can atomically publish the staged directory.
*/
export async function extractPluginPackage(
archivePath: string,
destinationDirectory: string,
): Promise<PackageValidationResult> {
await mkdir(destinationDirectory, { recursive: false, mode: 0o700 });
try {
return await inspectPluginPackage(archivePath, destinationDirectory);
} catch (error) {
await rm(destinationDirectory, { recursive: true, force: true });
throw error;
}
}

View File

@@ -0,0 +1,943 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
mkdir,
mkdtemp,
readFile,
rm,
symlink,
truncate,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { deflateRawSync } from "node:zlib";
import test from "node:test";
import {
assertManifestSnapshotMatches,
buildPluginPackage,
extractPluginPackage,
hashFile,
validatePluginDirectory,
validatePluginPackage,
} from "./archive.ts";
import { checkPluginCompatibility } from "./compatibility.ts";
import { PACKAGE_LIMITS } from "./constants.ts";
import { initPlugin } from "./commands.ts";
import {
parseAndValidateManifestContents,
readAndValidateManifest,
validateManifestValue,
} from "./manifest.ts";
import { assertSafePackagePath, PackagePathRegistry } from "./packagePath.ts";
const execFileAsync = promisify(execFile);
const cliPath = fileURLToPath(new URL("./cli.ts", import.meta.url));
const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url));
function crc32(contents: Buffer): number {
let crc = 0xffffffff;
for (const byte of contents) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc & 1) === 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function createZipEntry(
entryName: string,
contents: Buffer,
options: {
readonly compressionMethod?: 0 | 8;
readonly declaredUncompressedSize?: number;
} = {},
): Buffer {
const compressionMethod = options.compressionMethod ?? 0;
const encodedContents = compressionMethod === 8 ? deflateRawSync(contents) : contents;
const declaredUncompressedSize = options.declaredUncompressedSize ?? contents.byteLength;
const encodedName = Buffer.from(entryName);
const checksum = crc32(contents);
const localHeader = Buffer.alloc(30 + encodedName.byteLength);
localHeader.writeUInt32LE(0x04034b50, 0);
localHeader.writeUInt16LE(20, 4);
localHeader.writeUInt16LE(0x0800, 6);
localHeader.writeUInt16LE(compressionMethod, 8);
localHeader.writeUInt16LE(0, 10);
localHeader.writeUInt16LE(33, 12);
localHeader.writeUInt32LE(checksum, 14);
localHeader.writeUInt32LE(encodedContents.byteLength, 18);
localHeader.writeUInt32LE(declaredUncompressedSize, 22);
localHeader.writeUInt16LE(encodedName.byteLength, 26);
encodedName.copy(localHeader, 30);
const centralHeader = Buffer.alloc(46 + encodedName.byteLength);
centralHeader.writeUInt32LE(0x02014b50, 0);
centralHeader.writeUInt16LE(20, 4);
centralHeader.writeUInt16LE(20, 6);
centralHeader.writeUInt16LE(0x0800, 8);
centralHeader.writeUInt16LE(compressionMethod, 10);
centralHeader.writeUInt16LE(0, 12);
centralHeader.writeUInt16LE(33, 14);
centralHeader.writeUInt32LE(checksum, 16);
centralHeader.writeUInt32LE(encodedContents.byteLength, 20);
centralHeader.writeUInt32LE(declaredUncompressedSize, 24);
centralHeader.writeUInt16LE(encodedName.byteLength, 28);
centralHeader.writeUInt32LE((0o100644 << 16) >>> 0, 38);
centralHeader.writeUInt32LE(0, 42);
encodedName.copy(centralHeader, 46);
const centralOffset = localHeader.byteLength + encodedContents.byteLength;
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(1, 8);
end.writeUInt16LE(1, 10);
end.writeUInt32LE(centralHeader.byteLength, 12);
end.writeUInt32LE(centralOffset, 16);
return Buffer.concat([localHeader, encodedContents, centralHeader, end]);
}
function replaceCentralEntryMode(archive: Buffer, entryName: string, mode: number): Buffer {
const result = Buffer.from(archive);
const endOffset = result.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
if (endOffset < 0) throw new Error("Missing ZIP end record");
const entryCount = result.readUInt16LE(endOffset + 10);
let offset = result.readUInt32LE(endOffset + 16);
for (let entry = 0; entry < entryCount; entry += 1) {
if (result.readUInt32LE(offset) !== 0x02014b50) throw new Error("Invalid central ZIP entry");
const nameLength = result.readUInt16LE(offset + 28);
const extraLength = result.readUInt16LE(offset + 30);
const commentLength = result.readUInt16LE(offset + 32);
const name = result.subarray(offset + 46, offset + 46 + nameLength).toString("utf8");
if (name === entryName) {
result.writeUInt32LE((mode << 16) >>> 0, offset + 38);
return result;
}
offset += 46 + nameLength + extraLength + commentLength;
}
throw new Error(`Missing central ZIP entry: ${entryName}`);
}
function manifest(overrides: Record<string, unknown> = {}) {
return {
manifestVersion: 1,
id: "com.example.package-test",
name: "package-test",
version: "1.0.0",
publisher: "example",
engines: { netcatty: ">=1.0.0 <2.0.0", api: ">=0.1.0-internal <0.2.0" },
main: { browser: "dist/index.js" },
...overrides,
};
}
async function createPlugin(root: string): Promise<string> {
const directory = path.join(root, "plugin");
await mkdir(path.join(directory, "dist"), { recursive: true });
await Promise.all([
writeFile(
path.join(directory, "netcatty.plugin.json"),
`${JSON.stringify(manifest(), null, 2)}\n`,
),
writeFile(path.join(directory, "dist/index.js"), "export default {};\n"),
writeFile(path.join(directory, "README.md"), "# Package test\n"),
]);
return directory;
}
test("path validation rejects traversal, platform aliases, and duplicates", () => {
for (const unsafe of [
"../escape",
"/absolute",
"C:/drive",
"a\\b",
"a/../b",
"CON",
"assets/PRN.txt",
"file.",
"folder/file ",
"folder/file?.js",
"a//b",
"ab",
"ab",
"/x",
"",
"/x",
"assets/.txt",
"file",
"😀".repeat(129),
]) {
assert.throws(() => assertSafePackagePath(unsafe));
}
assert.equal(assertSafePackagePath("😀".repeat(128)), "😀".repeat(128));
assert.equal(assertSafePackagePath("assets/fullwidth-.txt"), "assets/fullwidth-.txt");
const registry = new PackagePathRegistry();
registry.add("dist/Plugin.js");
assert.throws(() => registry.add("dist/plugin.js"), /case-colliding/);
for (const [first, second] of [
["assets/Straße.txt", "assets/STRASSE.txt"],
["assets/fullwidth-.txt", "assets/fullwidth-S.txt"],
]) {
const unicodeRegistry = new PackagePathRegistry();
unicodeRegistry.add(first);
assert.throws(() => unicodeRegistry.add(second), /case-colliding/);
}
for (const paths of [
["dist", "dist/index.js"],
["dist/index.js", "dist"],
["DIST", "dist/index.js"],
["dist/index.js", "DIST"],
]) {
const collisionRegistry = new PackagePathRegistry();
collisionRegistry.add(paths[0]);
assert.throws(
() => collisionRegistry.add(paths[1]),
/File\/directory package path collision/,
);
}
});
test("manifest validation reports permission and contribution mistakes", () => {
const result = validateManifestValue(manifest({
permissions: {
required: [{ permission: "network", resources: ["https://example.com"] }],
optional: ["network"],
},
contributes: {
commands: [{ id: "com.example.package-test.run", title: "Run" }],
menus: [{ command: "com.example.package-test.missing", location: "commandPalette" }],
},
}));
assert.equal(result.valid, false);
assert.match(result.errors.join("\n"), /both required and optional/);
assert.match(result.errors.join("\n"), /undeclared command/);
});
test("text setting defaults must satisfy their declared pattern", () => {
const result = validateManifestValue(manifest({
permissions: { required: ["settings.read", "settings.write"] },
contributes: {
settings: [{
id: "com.example.package-test.channel",
label: "Channel",
control: "text",
scope: "application",
pattern: "^(stable|beta)$",
default: "nightly",
}],
},
}));
assert.equal(result.valid, false);
assert.match(result.errors.join("\n"), /default does not match its pattern/u);
});
test("resource-scoped required permissions must declare activation-time bounds", () => {
for (const [permission, resource] of [
["network", "https://example.com"],
["filesystem.read", "/tmp/plugin-read"],
["filesystem.write", "/tmp/plugin-write"],
["companion.execute", "com.example.package-test.helper"],
] as const) {
const unbounded = validateManifestValue(manifest({
permissions: { required: [permission] },
}));
assert.equal(unbounded.valid, false, permission);
assert.match(unbounded.errors.join("\n"), /must declare explicit resources/u);
const bounded = validateManifestValue(manifest({
permissions: { required: [{ permission, resources: [resource] }] },
}));
assert.equal(bounded.valid, true, bounded.errors.join("\n"));
const wildcard = validateManifestValue(manifest({
permissions: { required: [{ permission, resources: ["*"] }] },
}));
assert.equal(wildcard.valid, false, permission);
assert.match(wildcard.errors.join("\n"), /must not use the wildcard resource/u);
const optional = validateManifestValue(manifest({
permissions: { optional: [permission] },
}));
assert.equal(optional.valid, true, optional.errors.join("\n"));
}
});
test("manifest validation applies the runtime resource limit for each permission", () => {
for (const [permission, length, expectedLimit] of [
["network", 2_049, 2_048],
["storage", 2_049, 2_048],
["companion.execute", 193, 192],
] as const) {
const result = validateManifestValue(manifest({
permissions: permission === "storage"
? { optional: [{ permission, resources: ["x".repeat(length)] }] }
: { required: [{ permission, resources: ["x".repeat(length)] }] },
}));
assert.equal(result.valid, false);
assert.match(
result.errors.join("\n"),
new RegExp(`Permission resource for ${permission.replace(".", "\\.")} exceeds ${expectedLimit}`),
);
}
const filesystem = validateManifestValue(manifest({
permissions: {
required: [{ permission: "filesystem.read", resources: [`/${"x".repeat(4_096)}`] }],
},
}));
assert.equal(filesystem.valid, true, filesystem.errors.join("\n"));
});
test("Node utility entrypoints require the explicit advanced-runtime permission", () => {
const missing = validateManifestValue(manifest({
main: { node: "dist/index.js" },
}));
assert.equal(missing.valid, false);
assert.match(missing.errors.join("\n"), /requires declared permission: runtime\.advanced/);
const declared = validateManifestValue(manifest({
main: { node: "dist/index.js" },
permissions: { required: ["runtime.advanced"] },
}));
assert.equal(declared.valid, true, declared.errors.join("\n"));
});
test("manifest byte parsing rejects invalid UTF-8 before JSON validation", () => {
const validContents = new TextEncoder().encode(JSON.stringify(manifest()));
assert.equal(parseAndValidateManifestContents(validContents).id, "com.example.package-test");
assert.throws(
() => parseAndValidateManifestContents(Uint8Array.from([0x7b, 0x22, 0xff, 0x22, 0x7d])),
/not valid UTF-8 JSON/,
);
});
test("manifest validation safely rejects excessive JSON nesting", () => {
let nested: unknown = null;
for (let depth = 0; depth < 5_000; depth += 1) {
nested = [nested];
}
const result = validateManifestValue(manifest({
permissions: { required: ["commands"] },
contributes: {
commands: [{ id: "com.example.package-test.run", title: "Run" }],
keybindings: [{
command: "com.example.package-test.run",
key: "ctrl+x",
args: nested,
}],
},
}));
assert.equal(result.valid, false);
assert.match(result.errors.join("\n"), /must not exceed .* levels/);
const wideResult = validateManifestValue(manifest({
permissions: { required: ["commands"] },
contributes: {
commands: [{ id: "com.example.package-test.run", title: "Run" }],
keybindings: [{
command: "com.example.package-test.run",
key: "ctrl+x",
args: Array.from({ length: 100_000 }, () => null),
}],
},
}));
assert.equal(wideResult.valid, false);
assert.match(wideResult.errors.join("\n"), /must not contain more than .* nodes/);
});
test("manifest validation rejects duplicate companion executable paths", () => {
const result = validateManifestValue(manifest({
companionExecutables: [
{
id: "com.example.package-test.helper-one",
variants: [{
path: "bin/helper",
platforms: ["linux-x64"],
sha256: "0".repeat(64),
}],
},
{
id: "com.example.package-test.helper-two",
variants: [{
path: "bin/helper",
platforms: ["darwin-arm64"],
sha256: "1".repeat(64),
}],
},
],
}));
assert.equal(result.valid, false);
assert.match(result.errors.join("\n"), /Duplicate companion executable path: bin\/helper/);
});
test("manifest validation supports platform-specific companion variants", () => {
const result = validateManifestValue(manifest({
main: { browser: "dist/index.js", node: "dist/index.js" },
permissions: {
required: [
"runtime.advanced",
{
permission: "companion.execute",
resources: ["com.example.package-test.helper"],
},
],
},
companionExecutables: [{
id: "com.example.package-test.helper",
variants: [
{
path: "bin/helper-darwin",
platforms: ["darwin-arm64", "darwin-x64"],
sha256: "0".repeat(64),
},
{
path: "bin/helper-linux",
platforms: ["linux-arm64", "linux-x64"],
sha256: "1".repeat(64),
},
],
}],
}));
assert.equal(result.valid, true, result.errors.join("\n"));
const duplicatePlatform = validateManifestValue(manifest({
main: { browser: "dist/index.js", node: "dist/index.js" },
permissions: {
required: [
"runtime.advanced",
{
permission: "companion.execute",
resources: ["com.example.package-test.helper"],
},
],
},
companionExecutables: [{
id: "com.example.package-test.helper",
variants: [
{
path: "bin/helper-one",
platforms: ["linux-x64"],
sha256: "0".repeat(64),
},
{
path: "bin/helper-two",
platforms: ["linux-x64"],
sha256: "1".repeat(64),
},
],
}],
}));
assert.equal(duplicatePlatform.valid, false);
assert.match(duplicatePlatform.errors.join("\n"), /Duplicate companion platform/);
});
test("companion executables require an advanced utility placement", () => {
const companionExecutables = [{
id: "com.example.package-test.helper",
variants: [{
path: "bin/helper",
platforms: ["linux-x64"],
sha256: "0".repeat(64),
}],
}];
const companionPermission = {
permission: "companion.execute" as const,
resources: ["com.example.package-test.helper"],
};
const browserOnly = validateManifestValue(manifest({
permissions: { required: [companionPermission] },
companionExecutables,
}));
assert.equal(browserOnly.valid, false);
assert.match(browserOnly.errors.join("\n"), /require a Node utility entrypoint/u);
const missingAdvanced = validateManifestValue(manifest({
main: { browser: "dist/index.js", node: "dist/index.js" },
permissions: { required: [companionPermission] },
companionExecutables,
}));
assert.equal(missingAdvanced.valid, false);
assert.match(missingAdvanced.errors.join("\n"), /requires declared permission: runtime\.advanced/u);
});
test("packaging treats contributed package icons as required safe files", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-icons-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
const manifestPath = path.join(directory, "netcatty.plugin.json");
await writeFile(manifestPath, `${JSON.stringify(manifest({
permissions: { required: ["commands"] },
contributes: {
commands: [{
id: "com.example.package-test.run",
title: "Run",
icon: {
kind: "package",
light: "assets/run-light.svg",
dark: "assets/run-dark.svg",
},
}],
},
}), null, 2)}\n`);
await assert.rejects(
buildPluginPackage(directory, path.join(root, "missing-icons.ncpkg")),
/missing package file: assets\/run-light\.svg/,
);
await mkdir(path.join(directory, "assets"));
await Promise.all([
writeFile(path.join(directory, "assets/run-light.svg"), "<svg></svg>"),
writeFile(path.join(directory, "assets/run-dark.svg"), "<svg></svg>"),
]);
const output = path.join(root, "with-icons.ncpkg");
await buildPluginPackage(directory, output);
const result = await validatePluginPackage(output);
assert.equal(result.manifest.id, "com.example.package-test");
});
test("compatibility checks engine ranges and negotiates declared features", () => {
const pluginManifest = manifest({
features: {
required: ["netcatty.rpc.progress"],
optional: ["netcatty.stream.binary", "netcatty.view.theme"],
},
});
const compatible = checkPluginCompatibility(pluginManifest, {
netcattyVersion: "1.4.0",
features: ["netcatty.rpc.progress", "netcatty.stream.binary"],
});
assert.equal(compatible.compatible, true);
assert.deepEqual(compatible.enabledFeatures, [
"netcatty.rpc.progress",
"netcatty.stream.binary",
]);
const incompatible = checkPluginCompatibility(pluginManifest, {
netcattyVersion: "2.0.0",
apiVersion: "0.2.0",
features: [],
});
assert.equal(incompatible.compatible, false);
assert.deepEqual(incompatible.missingRequiredFeatures, ["netcatty.rpc.progress"]);
assert.match(incompatible.errors.join("\n"), /does not satisfy/);
assert.match(incompatible.errors.join("\n"), /Missing required features/);
const nextApiPrerelease = checkPluginCompatibility(pluginManifest, {
netcattyVersion: "1.4.0",
apiVersion: "0.2.0-alpha.1",
features: ["netcatty.rpc.progress"],
});
assert.equal(nextApiPrerelease.compatible, false);
assert.match(nextApiPrerelease.errors.join("\n"), /plugin API version .* does not satisfy/);
});
test("compatibility CLI checks a validated plugin target", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-compatibility-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
const compatible = await execFileAsync(process.execPath, [
"--import",
"tsx",
cliPath,
"compatibility",
directory,
"--netcatty",
"1.5.0",
]);
assert.match(compatible.stdout, /Compatible: com\.example\.package-test@1\.0\.0/);
await assert.rejects(
execFileAsync(process.execPath, [
"--import",
"tsx",
cliPath,
"compatibility",
directory,
"--netcatty",
"2.0.0",
]),
/Plugin is incompatible/,
);
});
test("example README commands use repository-root CLI paths", async () => {
const readme = await readFile(
path.join(repositoryRoot, "examples/plugins/hello-netcatty/README.md"),
"utf8",
);
assert.match(readme, /npm run build:plugin-packages/);
assert.match(
readme,
/npm exec -- netcatty-plugin validate examples\/plugins\/hello-netcatty/,
);
assert.match(
readme,
/npm exec -- netcatty-plugin compatibility examples\/plugins\/hello-netcatty --netcatty 0\.0\.0/,
);
assert.doesNotMatch(readme, /npm exec --workspace @netcatty\/plugin-cli/);
});
test("init creates a valid TypeScript plugin skeleton", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-init-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = path.join(root, "created");
await initPlugin(directory, { id: "com.example.created", name: "Created" });
const createdManifest = await readAndValidateManifest(directory);
assert.equal(createdManifest.id, "com.example.created");
assert.match(await readFile(path.join(directory, "src/index.ts"), "utf8"), /definePlugin/);
});
test("init safely serializes the display name in generated TypeScript", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-init-escape-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = path.join(root, "created");
const displayName = 'A "quoted" \\ plugin\nnext line';
await initPlugin(directory, { id: "com.example.escaped", name: displayName });
const source = await readFile(path.join(directory, "src/index.ts"), "utf8");
assert.ok(
source.includes(`context.logger.info(${JSON.stringify(`${displayName} activated`)});`),
);
});
test("packing is deterministic and the archive validates", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-pack-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
const first = path.join(root, "first.ncpkg");
const second = path.join(root, "second.ncpkg");
const firstResult = await buildPluginPackage(directory, first);
await buildPluginPackage(directory, second);
const firstBytes = await readFile(first);
const secondBytes = await readFile(second);
assert.deepEqual(firstBytes, secondBytes);
assert.equal(
firstResult.sha256,
createHash("sha256").update(firstBytes).digest("hex"),
);
const validation = await validatePluginPackage(first);
const directoryValidation = await validatePluginDirectory(directory);
assert.equal(validation.manifest.id, "com.example.package-test");
assert.equal(validation.fileCount, 3);
assert.equal(firstResult.contentSha256, validation.contentSha256);
assert.equal(validation.contentSha256, directoryValidation.contentSha256);
assert.match(validation.contentSha256, /^[a-f0-9]{64}$/u);
});
test("logical content identity follows companion declarations across ZIP mode encoders", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-companion-mode-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
const companionContents = Buffer.from("portable companion\n");
const companionPath = path.join(directory, "bin/helper");
await mkdir(path.dirname(companionPath), { recursive: true });
await writeFile(companionPath, companionContents);
await writeFile(
path.join(directory, "netcatty.plugin.json"),
`${JSON.stringify(manifest({
main: { browser: "dist/index.js", node: "dist/index.js" },
permissions: {
required: [
"runtime.advanced",
{
permission: "companion.execute",
resources: ["com.example.package-test.helper"],
},
],
},
companionExecutables: [{
id: "com.example.package-test.helper",
variants: [{
path: "bin/helper",
platforms: ["linux-x64"],
sha256: createHash("sha256").update(companionContents).digest("hex"),
}],
}],
}), null, 2)}\n`,
);
const builtPath = path.join(root, "built.ncpkg");
const portablePath = path.join(root, "portable.ncpkg");
const extracted = path.join(root, "extracted");
await buildPluginPackage(directory, builtPath);
await writeFile(
portablePath,
replaceCentralEntryMode(await readFile(builtPath), "bin/helper", 0o100644),
);
const archiveValidation = await extractPluginPackage(portablePath, extracted);
const directoryValidation = await validatePluginDirectory(extracted, {
allowIgnoredRootEntries: false,
});
assert.equal(archiveValidation.contentSha256, directoryValidation.contentSha256);
});
test("validated extraction creates an isolated tree and removes partial output on failure", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-extract-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
const archive = path.join(root, "plugin.ncpkg");
const extracted = path.join(root, "extracted");
await buildPluginPackage(directory, archive);
const result = await extractPluginPackage(archive, extracted);
assert.equal(result.manifest.id, "com.example.package-test");
assert.equal(
await readFile(path.join(extracted, "dist/index.js"), "utf8"),
"export default {};\n",
);
await assert.rejects(extractPluginPackage(archive, extracted));
assert.equal(
await readFile(path.join(extracted, "dist/index.js"), "utf8"),
"export default {};\n",
);
const invalidArchive = path.join(root, "invalid.ncpkg");
const failedDestination = path.join(root, "failed-extraction");
await writeFile(invalidArchive, "not a zip");
await assert.rejects(extractPluginPackage(invalidArchive, failedDestination));
await assert.rejects(readFile(failedDestination), /ENOENT/);
});
test("archive validation rejects oversized manifests before buffering", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-archive-manifest-limit-"));
context.after(() => rm(root, { recursive: true, force: true }));
const oversizedPath = path.join(root, "oversized-manifest.ncpkg");
const oversizedBytes = createZipEntry(
"netcatty.plugin.json",
Buffer.alloc(PACKAGE_LIMITS.manifestBytes + 1, 0x20),
);
await writeFile(oversizedPath, oversizedBytes);
await assert.rejects(
validatePluginPackage(oversizedPath),
new RegExp(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`),
);
const forgedSizePath = path.join(root, "forged-size-manifest.ncpkg");
const forgedSizeBytes = createZipEntry(
"netcatty.plugin.json",
Buffer.alloc(PACKAGE_LIMITS.manifestBytes + 1, 0x20),
{
compressionMethod: 8,
declaredUncompressedSize: PACKAGE_LIMITS.manifestBytes,
},
);
await writeFile(forgedSizePath, forgedSizeBytes);
await assert.rejects(
validatePluginPackage(forgedSizePath),
new RegExp(
`too many bytes in the stream\\. expected ${PACKAGE_LIMITS.manifestBytes}\\. got at least ${PACKAGE_LIMITS.manifestBytes + 1}`,
),
);
});
test("archive validation rejects duplicate names and CRC corruption", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-archive-safety-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
await Promise.all([
writeFile(path.join(directory, "a.txt"), "first\n"),
writeFile(path.join(directory, "b.txt"), "second\n"),
]);
await mkdir(path.join(directory, "bbbbb"));
await Promise.all([
writeFile(path.join(directory, "aaaaa"), "parent-file\n"),
writeFile(path.join(directory, "bbbbb/file"), "child-file\n"),
]);
const validPath = path.join(root, "valid.ncpkg");
await buildPluginPackage(directory, validPath);
const validBytes = await readFile(validPath);
const duplicateBytes = Buffer.from(validBytes);
const originalName = Buffer.from("b.txt");
const duplicateName = Buffer.from("a.txt");
let replacements = 0;
for (let offset = duplicateBytes.indexOf(originalName); offset !== -1;) {
duplicateName.copy(duplicateBytes, offset);
replacements += 1;
offset = duplicateBytes.indexOf(originalName, offset + originalName.byteLength);
}
assert.equal(replacements, 2, "ZIP should contain the local and central entry names");
const duplicatePath = path.join(root, "duplicate.ncpkg");
await writeFile(duplicatePath, duplicateBytes);
await assert.rejects(validatePluginPackage(duplicatePath), /Duplicate or case-colliding/);
const prefixCollisionBytes = Buffer.from(validBytes);
let prefixReplacements = 0;
for (const [source, target] of [
[Buffer.from("aaaaa"), Buffer.from("distx")],
[Buffer.from("bbbbb/file"), Buffer.from("distx/file")],
]) {
for (let offset = prefixCollisionBytes.indexOf(source); offset !== -1;) {
target.copy(prefixCollisionBytes, offset);
prefixReplacements += 1;
offset = prefixCollisionBytes.indexOf(source, offset + source.byteLength);
}
}
assert.equal(prefixReplacements, 4, "ZIP should contain both local and central names");
const prefixCollisionPath = path.join(root, "prefix-collision.ncpkg");
await writeFile(prefixCollisionPath, prefixCollisionBytes);
await assert.rejects(
validatePluginPackage(prefixCollisionPath),
/File\/directory package path collision/,
);
const corruptedBytes = Buffer.from(validBytes);
const content = Buffer.from("# Package test\n");
const contentOffset = corruptedBytes.indexOf(content);
assert.notEqual(contentOffset, -1);
corruptedBytes[contentOffset] ^= 0x01;
const corruptedPath = path.join(root, "corrupted.ncpkg");
await writeFile(corruptedPath, corruptedBytes);
await assert.rejects(validatePluginPackage(corruptedPath), /integrity check failed/);
const splitNameBytes = Buffer.from(validBytes);
const localName = Buffer.from("README.md");
const conflictingLocalName = Buffer.from("renamed.x");
assert.equal(localName.byteLength, conflictingLocalName.byteLength);
const localNameOffset = splitNameBytes.indexOf(localName);
assert.notEqual(localNameOffset, -1);
conflictingLocalName.copy(splitNameBytes, localNameOffset);
const splitNamePath = path.join(root, "split-name.ncpkg");
await writeFile(splitNamePath, splitNameBytes);
await assert.rejects(
validatePluginPackage(splitNamePath),
/local and central entry names differ/,
);
});
test("packer rejects symbolic links and undeclared executables", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-safety-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
await assert.rejects(
buildPluginPackage(directory, path.join(root, "wrong-extension.zip")),
/\.ncpkg extension/,
);
if (process.platform !== "win32") {
await symlink("README.md", path.join(directory, "linked-readme"));
await assert.rejects(
buildPluginPackage(directory, path.join(root, "symlink.ncpkg")),
/Symbolic links/,
);
await rm(path.join(directory, "linked-readme"));
}
await mkdir(path.join(directory, "bin"));
const executablePath = path.join(directory, "bin/tool.exe");
await writeFile(executablePath, "not-a-real-executable\n");
await assert.rejects(
buildPluginPackage(directory, path.join(root, "executable.ncpkg")),
/not declared as a companion/,
);
});
test("packer ignores root dev artifacts without dropping nested runtime dependencies", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-runtime-deps-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
await Promise.all([
mkdir(path.join(directory, "node_modules/dev-only"), { recursive: true }),
mkdir(path.join(directory, "dist/node_modules/runtime-dependency"), { recursive: true }),
]);
await Promise.all([
writeFile(path.join(directory, "node_modules/dev-only/index.js"), "dev only\n"),
writeFile(
path.join(directory, "dist/node_modules/runtime-dependency/index.js"),
"export const runtime = true;\n",
),
]);
const packagePath = path.join(root, "runtime-deps.ncpkg");
const build = await buildPluginPackage(directory, packagePath);
const validation = await validatePluginPackage(packagePath);
assert.equal(build.fileCount, 4);
assert.equal(validation.fileCount, 4);
});
test("packer rejects outputs inside the plugin source tree", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-output-containment-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
const nestedOutput = path.join(directory, "dist/plugin.ncpkg");
await writeFile(nestedOutput, "previous package output\n");
await assert.rejects(
buildPluginPackage(directory, nestedOutput),
/output must be outside the plugin source directory/,
);
if (process.platform !== "win32") {
const outputAlias = path.join(root, "output-alias");
await symlink(path.join(directory, "dist"), outputAlias);
await assert.rejects(
buildPluginPackage(directory, path.join(outputAlias, "plugin.ncpkg")),
/output must be outside the plugin source directory/,
);
}
});
test("manifest byte limit is enforced before JSON parsing", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-limit-"));
context.after(() => rm(root, { recursive: true, force: true }));
const manifestPath = path.join(root, "netcatty.plugin.json");
await writeFile(manifestPath, "{}");
await truncate(manifestPath, PACKAGE_LIMITS.manifestBytes * 4);
await assert.rejects(readAndValidateManifest(root), /manifest exceeds/);
});
test("manifest validation refuses symlinked source manifests", async (context) => {
if (process.platform === "win32") return;
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-manifest-link-"));
context.after(() => rm(root, { recursive: true, force: true }));
const target = path.join(root, "target.json");
await writeFile(target, JSON.stringify(manifest()));
await symlink(target, path.join(root, "netcatty.plugin.json"));
await assert.rejects(readAndValidateManifest(root), /must be a regular file/);
});
test("validated manifest snapshots reject changed package bytes", () => {
const snapshot = { size: 128, sha256: "a".repeat(64) };
assert.doesNotThrow(() => assertManifestSnapshotMatches(snapshot, snapshot));
assert.throws(
() => assertManifestSnapshotMatches(snapshot, { ...snapshot, size: 129 }),
/manifest changed after validation/,
);
assert.throws(
() => assertManifestSnapshotMatches(snapshot, { ...snapshot, sha256: "b".repeat(64) }),
/manifest changed after validation/,
);
assert.throws(
() => assertManifestSnapshotMatches(snapshot, undefined),
/manifest changed after validation/,
);
});
test("source hashing enforces its byte budget while reading", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-source-limit-"));
context.after(() => rm(root, { recursive: true, force: true }));
const filePath = path.join(root, "growing.bin");
await writeFile(filePath, "1234");
await assert.rejects(hashFile(filePath, 3), /source exceeds 3 bytes while reading/);
assert.equal((await hashFile(filePath, 4)).size, 4);
});

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env node
import process from "node:process";
import { checkPluginCompatibility } from "./compatibility.js";
import { buildPlugin, initPlugin, packPlugin, validateTarget } from "./commands.js";
const USAGE = `Netcatty plugin CLI (API 0.1.0-internal)
Usage:
netcatty-plugin init <directory> --id <reverse.dns.id> [--name <display name>]
netcatty-plugin validate <directory|package.ncpkg>
netcatty-plugin compatibility <directory|package.ncpkg> --netcatty <version> [--api <version>] [--features <id,id,...>]
netcatty-plugin build <directory>
netcatty-plugin pack <directory> [--out <package.ncpkg>]
`;
function optionValue(args: readonly string[], name: string): string | undefined {
const index = args.indexOf(name);
if (index === -1) return undefined;
const value = args[index + 1];
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${name}`);
return value;
}
async function main(args: readonly string[]): Promise<void> {
const [command, target] = args;
if (!command || command === "help" || command === "--help" || command === "-h") {
process.stdout.write(USAGE);
return;
}
if (!target) throw new Error(`Missing target for ${command}`);
if (command === "init") {
const id = optionValue(args, "--id");
if (!id) throw new Error("init requires --id <reverse.dns.id>");
const directory = await initPlugin(target, { id, name: optionValue(args, "--name") });
process.stdout.write(`Initialized plugin in ${directory}\n`);
return;
}
if (command === "validate") {
const result = await validateTarget(target);
process.stdout.write(
`Valid ${result.kind}: ${result.manifest.id}@${result.manifest.version}\n`,
);
return;
}
if (command === "compatibility") {
const netcattyVersion = optionValue(args, "--netcatty");
if (!netcattyVersion) {
throw new Error("compatibility requires --netcatty <version>");
}
const targetResult = await validateTarget(target);
const features = optionValue(args, "--features")
?.split(",")
.map((feature) => feature.trim())
.filter(Boolean);
const result = checkPluginCompatibility(targetResult.manifest, {
netcattyVersion,
apiVersion: optionValue(args, "--api"),
features,
});
if (!result.compatible) {
throw new Error(`Plugin is incompatible:\n- ${result.errors.join("\n- ")}`);
}
const featureSummary = result.enabledFeatures.length > 0
? result.enabledFeatures.join(", ")
: "none";
process.stdout.write(
`Compatible: ${targetResult.manifest.id}@${targetResult.manifest.version}\nEnabled features: ${featureSummary}\n`,
);
return;
}
if (command === "build") {
await buildPlugin(target);
process.stdout.write("Plugin build completed.\n");
return;
}
if (command === "pack") {
const result = await packPlugin(target, optionValue(args, "--out"));
process.stdout.write(
`Packed ${result.fileCount} files to ${result.outputPath}\nSHA-256 ${result.sha256}\n`,
);
return;
}
throw new Error(`Unknown command: ${command}`);
}
main(process.argv.slice(2)).catch((error: unknown) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});

View File

@@ -0,0 +1,137 @@
import { spawn } from "node:child_process";
import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import type { PluginManifest } from "@netcatty/plugin-contract";
import {
buildPluginPackage,
validatePluginDirectory,
validatePluginPackage,
} from "./archive.js";
import { readAndValidateManifest } from "./manifest.js";
export interface InitPluginOptions {
readonly id: string;
readonly name?: string;
}
export async function initPlugin(
targetDirectory: string,
options: InitPluginOptions,
): Promise<string> {
const directory = path.resolve(targetDirectory);
await mkdir(directory, { recursive: true });
const existingEntries = await readdir(directory);
if (existingEntries.length > 0) {
throw new Error(`Target directory is not empty: ${directory}`);
}
const displayName = options.name?.trim() || options.id.split(".").at(-1) || options.id;
const activationMessage = JSON.stringify(`${displayName} activated`);
const packageName = options.id.replaceAll(".", "-");
const manifest: PluginManifest = {
$schema: "https://netcatty.com/schemas/plugins/0.1.0-internal/plugin-contract.schema.json",
manifestVersion: 1,
id: options.id,
name: displayName,
displayName,
description: "A Netcatty plugin",
version: "0.1.0",
publisher: options.id.split(".")[0] || "local",
engines: {
netcatty: ">=0.0.0",
api: ">=0.1.0-internal <0.2.0",
},
main: { browser: "dist/index.js" },
activationEvents: ["onStartupFinished"],
permissions: { required: [], optional: [] },
};
await mkdir(path.join(directory, "src"), { recursive: true });
await Promise.all([
writeFile(
path.join(directory, "netcatty.plugin.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
),
writeFile(
path.join(directory, "package.json"),
`${JSON.stringify({
name: packageName,
version: "0.1.0",
private: true,
type: "module",
scripts: { build: "tsc -p tsconfig.json" },
dependencies: { "@netcatty/plugin-sdk": "0.1.0-internal" },
devDependencies: { typescript: "^5.9.0" },
}, null, 2)}\n`,
"utf8",
),
writeFile(
path.join(directory, "tsconfig.json"),
`${JSON.stringify({
compilerOptions: {
target: "ES2022",
module: "ESNext",
moduleResolution: "Bundler",
strict: true,
declaration: true,
rootDir: "src",
outDir: "dist",
},
include: ["src/**/*.ts"],
}, null, 2)}\n`,
"utf8",
),
writeFile(
path.join(directory, "src/index.ts"),
`import { definePlugin } from "@netcatty/plugin-sdk";\n\nexport default definePlugin({\n activate(context) {\n context.logger.info(${activationMessage});\n },\n});\n`,
"utf8",
),
]);
await readAndValidateManifest(directory);
return directory;
}
export async function validateTarget(target: string) {
const resolved = path.resolve(target);
const targetStats = await stat(resolved);
if (targetStats.isDirectory()) {
const result = await validatePluginDirectory(resolved);
return { kind: "directory" as const, ...result };
}
if (targetStats.isFile() && resolved.endsWith(".ncpkg")) {
const result = await validatePluginPackage(resolved);
return { kind: "package" as const, ...result };
}
throw new Error("Validation target must be a plugin directory or .ncpkg file");
}
export async function buildPlugin(pluginDirectory: string): Promise<void> {
const directory = path.resolve(pluginDirectory);
await readAndValidateManifest(directory);
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
await new Promise<void>((resolve, reject) => {
const child = spawn(npmCommand, ["run", "build", "--if-present"], {
cwd: directory,
env: process.env,
shell: false,
stdio: "inherit",
windowsHide: true,
});
child.once("error", reject);
child.once("exit", (code, signal) => {
if (code === 0) resolve();
else reject(new Error(`Plugin build failed (${signal ?? `exit ${String(code)}`})`));
});
});
await validatePluginDirectory(directory);
}
export async function packPlugin(pluginDirectory: string, outputPath?: string) {
const directory = path.resolve(pluginDirectory);
const manifest = await readAndValidateManifest(directory);
const resolvedOutput = outputPath
? path.resolve(outputPath)
: path.join(path.dirname(directory), `${manifest.id}-${manifest.version}.ncpkg`);
return buildPluginPackage(directory, resolvedOutput);
}

View File

@@ -0,0 +1,72 @@
import {
type PluginManifest,
} from "@netcatty/plugin-contract";
import { satisfies, valid, validRange } from "semver";
const DEFAULT_PLUGIN_API_VERSION = "0.1.0-internal";
export interface PluginCompatibilityTarget {
readonly netcattyVersion: string;
readonly apiVersion?: string;
readonly features?: readonly string[];
}
export interface PluginCompatibilityResult {
readonly compatible: boolean;
readonly apiVersion: string;
readonly enabledFeatures: readonly string[];
readonly missingRequiredFeatures: readonly string[];
readonly errors: readonly string[];
}
function checkEngineVersion(
label: string,
version: string,
range: string,
errors: string[],
): void {
if (valid(version) === null) {
errors.push(`Host ${label} version is not valid semver: ${version}`);
return;
}
const normalizedRange = validRange(range);
if (normalizedRange === null) {
errors.push(`Plugin ${label} range is not valid semver: ${range}`);
return;
}
if (!satisfies(version, normalizedRange)) {
errors.push(`Host ${label} version ${version} does not satisfy ${range}`);
}
}
export function checkPluginCompatibility(
manifest: PluginManifest,
target: PluginCompatibilityTarget,
): PluginCompatibilityResult {
const apiVersion = target.apiVersion ?? DEFAULT_PLUGIN_API_VERSION;
const errors: string[] = [];
checkEngineVersion("Netcatty", target.netcattyVersion, manifest.engines.netcatty, errors);
checkEngineVersion("plugin API", apiVersion, manifest.engines.api, errors);
const supportedFeatures = new Set(target.features ?? []);
const requiredFeatures = manifest.features?.required ?? [];
const optionalFeatures = manifest.features?.optional ?? [];
const missingRequiredFeatures = requiredFeatures
.filter((feature) => !supportedFeatures.has(feature))
.sort((left, right) => left.localeCompare(right, "en"));
if (missingRequiredFeatures.length > 0) {
errors.push(`Missing required features: ${missingRequiredFeatures.join(", ")}`);
}
const enabledFeatures = [...new Set([...requiredFeatures, ...optionalFeatures])]
.filter((feature) => supportedFeatures.has(feature))
.sort((left, right) => left.localeCompare(right, "en"));
return {
compatible: errors.length === 0,
apiVersion,
enabledFeatures,
missingRequiredFeatures,
errors,
};
}

View File

@@ -0,0 +1,15 @@
export const PACKAGE_LIMITS = Object.freeze({
archiveBytes: 100 * 1024 * 1024,
uncompressedBytes: 250 * 1024 * 1024,
singleFileBytes: 50 * 1024 * 1024,
manifestBytes: 1024 * 1024,
fileCount: 5_000,
pathCharacters: 128,
pathBytes: 512,
});
export const IGNORED_ROOT_ENTRIES = new Set([
".DS_Store",
".git",
"node_modules",
]);

View File

@@ -0,0 +1,28 @@
export { PACKAGE_LIMITS } from "./constants.js";
export {
buildPluginPackage,
extractPluginPackage,
validatePluginDirectory,
validatePluginPackage,
type PackageBuildResult,
type PackageValidationResult,
type PluginDirectoryValidationResult,
} from "./archive.js";
export {
buildPlugin,
initPlugin,
packPlugin,
validateTarget,
type InitPluginOptions,
} from "./commands.js";
export {
checkPluginCompatibility,
type PluginCompatibilityResult,
type PluginCompatibilityTarget,
} from "./compatibility.js";
export {
readAndValidateManifest,
validateManifestValue,
type ManifestValidationResult,
} from "./manifest.js";
export { assertSafePackagePath, PackagePathRegistry } from "./packagePath.js";

View File

@@ -0,0 +1,804 @@
import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import {
lstat,
open,
readFile,
} from "node:fs/promises";
import path from "node:path";
import type {
IconReference,
PermissionDeclaration,
PluginManifest,
PluginPermission,
} from "@netcatty/plugin-contract";
import Ajv2020, { type ErrorObject } from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { valid, validRange } from "semver";
import { PACKAGE_LIMITS } from "./constants.js";
import { assertSafePackagePath } from "./packagePath.js";
const require = createRequire(import.meta.url);
const schemaPath = require.resolve(
"@netcatty/plugin-contract/schema/plugin-contract.schema.json",
);
interface PluginContractSchema extends Record<string, unknown> {
readonly $defs: {
readonly JsonValueLimits: {
readonly const: {
readonly maxDepth: number;
readonly maxNodes: number;
};
};
readonly ResourceScopedPermission: {
readonly enum: readonly PluginPermission[];
};
};
}
const contractSchema = JSON.parse(
await readFile(schemaPath, "utf8"),
) as PluginContractSchema;
const manifestJsonLimits = contractSchema.$defs.JsonValueLimits.const;
if (!Number.isSafeInteger(manifestJsonLimits.maxDepth) || manifestJsonLimits.maxDepth < 1
|| !Number.isSafeInteger(manifestJsonLimits.maxNodes) || manifestJsonLimits.maxNodes < 1) {
throw new Error("Plugin contract JSON value limits are invalid");
}
const ajv = new Ajv2020({ allErrors: true, strict: true });
addFormats(ajv);
const validateSchema = ajv.compile<PluginManifest>(contractSchema);
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
const MAX_SETTING_PATTERN_LENGTH = 512;
const MAX_RESTRICTED_SCHEMA_DEPTH = 8;
const MAX_RESTRICTED_SCHEMA_NODES = 256;
const RESTRICTED_SCHEMA_TYPES = new Set(["array", "boolean", "integer", "null", "number", "object", "string"]);
const RESTRICTED_SCHEMA_KEYWORDS = new Set([
"additionalProperties", "const", "enum", "items", "maxItems", "maxLength", "maximum",
"minItems", "minLength", "minimum", "properties", "required", "type",
]);
const FORBIDDEN_SCHEMA_PROPERTY_NAMES = new Set(["__proto__", "constructor", "prototype"]);
export interface ManifestValidationResult {
readonly valid: boolean;
readonly manifest?: PluginManifest;
readonly errors: readonly string[];
}
export interface ValidatedManifestSource {
readonly manifest: PluginManifest;
readonly size: number;
readonly sha256: string;
}
function formatAjvError(error: ErrorObject): string {
const location = error.instancePath || "/";
return `${location} ${error.message ?? "is invalid"}`;
}
function plainRecord(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function settingPatternError(source: string): string | null {
if (source.length < 1 || source.length > MAX_SETTING_PATTERN_LENGTH
|| /\(\?/u.test(source) || /\\(?:[1-9]|k<)/u.test(source)
|| /\)(?:[*+?]|\{\d+(?:,\d*)?\})/u.test(source)) {
return "pattern uses an unsafe regular-expression feature";
}
try { new RegExp(source, "u"); }
catch { return "pattern is invalid"; }
return null;
}
function restrictedSettingSchemaErrors(root: unknown, rootType: "array" | null = "array"): string[] {
const errors: string[] = [];
const stack: Array<{ schema: unknown; depth: number }> = [{ schema: root, depth: 0 }];
let nodes = 0;
while (stack.length > 0) {
const current = stack.pop();
if (!current || !plainRecord(current.schema)) {
errors.push("valueSchema must contain plain schema objects");
continue;
}
nodes += 1;
if (nodes > MAX_RESTRICTED_SCHEMA_NODES || current.depth > MAX_RESTRICTED_SCHEMA_DEPTH) {
errors.push("valueSchema is too complex");
break;
}
const schema = current.schema;
for (const keyword of Object.keys(schema)) {
if (!RESTRICTED_SCHEMA_KEYWORDS.has(keyword)) errors.push(`valueSchema keyword is not allowed: ${keyword}`);
}
if (!RESTRICTED_SCHEMA_TYPES.has(schema.type as string)) {
errors.push("every valueSchema node must declare one supported type");
}
if (current.depth === 0 && rootType && schema.type !== rootType) {
errors.push(`valueSchema root must use type ${rootType}`);
}
if (schema.type === "array") {
if (!plainRecord(schema.items)) errors.push("array valueSchema nodes require one items schema");
else stack.push({ schema: schema.items, depth: current.depth + 1 });
} else if (schema.items !== undefined || schema.minItems !== undefined || schema.maxItems !== undefined) {
errors.push("valueSchema array keywords require type array");
}
if (schema.type === "object") {
if (!plainRecord(schema.properties)) errors.push("object valueSchema nodes require properties");
else {
for (const [name, child] of Object.entries(schema.properties)) {
if (name.length < 1 || name.length > 128 || name.includes("\0")
|| FORBIDDEN_SCHEMA_PROPERTY_NAMES.has(name)) {
errors.push(`valueSchema property name is invalid: ${name}`);
}
stack.push({ schema: child, depth: current.depth + 1 });
}
if (schema.required !== undefined && (!Array.isArray(schema.required)
|| new Set(schema.required).size !== schema.required.length
|| schema.required.some((name) => typeof name !== "string" || !Object.hasOwn(schema.properties as object, name)))) {
errors.push("valueSchema required fields are invalid");
}
}
if (schema.additionalProperties !== false) errors.push("object valueSchema nodes must deny additionalProperties");
} else if (schema.properties !== undefined || schema.required !== undefined || schema.additionalProperties !== undefined) {
errors.push("valueSchema object keywords require type object");
}
for (const name of ["minItems", "maxItems", "minLength", "maxLength"] as const) {
if (schema[name] !== undefined && (!Number.isSafeInteger(schema[name]) || (schema[name] as number) < 0)) {
errors.push(`valueSchema ${name} must be a non-negative safe integer`);
}
}
for (const name of ["minimum", "maximum"] as const) {
if (schema[name] !== undefined && (typeof schema[name] !== "number" || !Number.isFinite(schema[name]))) {
errors.push(`valueSchema ${name} must be finite`);
}
}
if (typeof schema.minItems === "number" && typeof schema.maxItems === "number" && schema.minItems > schema.maxItems) {
errors.push("valueSchema minItems must not exceed maxItems");
}
if (typeof schema.minLength === "number" && typeof schema.maxLength === "number" && schema.minLength > schema.maxLength) {
errors.push("valueSchema minLength must not exceed maxLength");
}
if (typeof schema.minimum === "number" && typeof schema.maximum === "number" && schema.minimum > schema.maximum) {
errors.push("valueSchema minimum must not exceed maximum");
}
if (schema.type === "integer" && typeof schema.minimum === "number" && typeof schema.maximum === "number"
&& Math.ceil(schema.minimum) > Math.floor(schema.maximum)) {
errors.push("valueSchema integer range must contain a valid integer");
}
if (schema.type !== "string" && (schema.minLength !== undefined || schema.maxLength !== undefined)) {
errors.push("valueSchema string keywords require type string");
}
if (!["integer", "number"].includes(String(schema.type))
&& (schema.minimum !== undefined || schema.maximum !== undefined)) {
errors.push("valueSchema numeric keywords require type number or integer");
}
if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length < 1 || schema.enum.length > 256)) {
errors.push("valueSchema enum is invalid");
}
}
return [...new Set(errors)];
}
function assertManifestJsonStructure(root: unknown): void {
const stack: Array<{ value: unknown; depth: number }> = [{ value: root, depth: 0 }];
const seen = new WeakSet<object>();
let nodes = 0;
while (stack.length > 0) {
const current = stack.pop();
if (!current) break;
if (current.depth > manifestJsonLimits.maxDepth) {
throw new RangeError(
`JSON values must not exceed ${manifestJsonLimits.maxDepth} levels of nesting`,
);
}
nodes += 1;
if (nodes > manifestJsonLimits.maxNodes) {
throw new RangeError(
`JSON values must not contain more than ${manifestJsonLimits.maxNodes} nodes`,
);
}
const { value } = current;
if (value === null || typeof value === "string" || typeof value === "boolean") continue;
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new TypeError("JSON numbers must be finite");
continue;
}
if (typeof value !== "object") {
throw new TypeError(`Unsupported JSON value type: ${typeof value}`);
}
if (seen.has(value)) throw new TypeError("JSON values must not contain shared references");
seen.add(value);
const nextDepth = current.depth + 1;
if (Array.isArray(value)) {
const keys = Object.keys(value);
const ownKeys = Reflect.ownKeys(value);
if (keys.length !== value.length || ownKeys.length !== value.length + 1) {
throw new TypeError("JSON arrays must be dense and contain no named properties");
}
for (let index = value.length - 1; index >= 0; index -= 1) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
throw new TypeError("JSON arrays must contain enumerable data properties only");
}
stack.push({ value: descriptor.value, depth: nextDepth });
}
continue;
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError("JSON objects must be plain records");
}
const stringKeys = Object.keys(value);
const ownKeys = Reflect.ownKeys(value);
if (ownKeys.length !== stringKeys.length) {
throw new TypeError("JSON objects must not contain symbols or non-enumerable properties");
}
for (let index = stringKeys.length - 1; index >= 0; index -= 1) {
const key = stringKeys[index];
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor || !("value" in descriptor)) {
throw new TypeError("JSON objects must not contain accessor properties");
}
stack.push({ value: descriptor.value, depth: nextDepth });
}
}
}
function permissionName(declaration: PermissionDeclaration): PluginPermission {
return typeof declaration === "string" ? declaration : declaration.permission;
}
function permissionResourceLimit(permission: PluginPermission): number {
if (permission === "filesystem.read" || permission === "filesystem.write") return 8_192;
if (permission === "companion.execute") return 192;
return 2_048;
}
const resourceScopedPermissions = new Set<PluginPermission>(
contractSchema.$defs.ResourceScopedPermission.enum,
);
function validateRequiredPermissionBounds(value: unknown): string[] {
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
const permissions = (value as { permissions?: unknown }).permissions;
if (!permissions || typeof permissions !== "object" || Array.isArray(permissions)) return [];
const required = (permissions as { required?: unknown }).required;
if (!Array.isArray(required)) return [];
return required.flatMap((declaration) => {
if (typeof declaration === "string") {
return resourceScopedPermissions.has(declaration as PluginPermission)
? [`Required permission ${declaration} must declare explicit resources`]
: [];
}
if (!declaration || typeof declaration !== "object" || Array.isArray(declaration)) return [];
const permission = (declaration as { permission?: unknown }).permission;
const resources = (declaration as { resources?: unknown }).resources;
return typeof permission === "string"
&& resourceScopedPermissions.has(permission as PluginPermission)
&& Array.isArray(resources)
&& resources.includes("*")
? [`Required permission ${permission} must not use the wildcard resource`]
: [];
});
}
function validatePermissionResourceLimits(manifest: PluginManifest): string[] {
const errors: string[] = [];
for (const declaration of [
...(manifest.permissions?.required ?? []),
...(manifest.permissions?.optional ?? []),
]) {
if (typeof declaration === "string") continue;
const maximum = permissionResourceLimit(declaration.permission);
for (const resource of declaration.resources) {
if (resource.length > maximum) {
errors.push(
`Permission resource for ${declaration.permission} exceeds ${maximum} characters`,
);
}
}
}
return errors;
}
function findDuplicateIds(manifest: PluginManifest): string[] {
const errors: string[] = [];
const groups = [
["settings", manifest.contributes?.settings],
["commands", manifest.contributes?.commands],
["views", manifest.contributes?.views],
["providers", manifest.contributes?.providers],
["companionExecutables", manifest.companionExecutables],
] as const;
const ids = new Map<string, string>();
for (const [groupName, contributions] of groups) {
for (const contribution of contributions ?? []) {
const previousGroup = ids.get(contribution.id);
if (previousGroup) {
errors.push(
`Duplicate contribution id across ${previousGroup} and ${groupName}: ${contribution.id}`,
);
} else {
ids.set(contribution.id, groupName);
}
}
}
return errors;
}
function validateOwnedContributionIds(manifest: PluginManifest): string[] {
const errors: string[] = [];
const expectedPrefix = `${manifest.id}.`;
const groups = [
["setting", manifest.contributes?.settings],
["command", manifest.contributes?.commands],
["view", manifest.contributes?.views],
["provider", manifest.contributes?.providers],
["companion executable", manifest.companionExecutables],
] as const;
for (const [kind, contributions] of groups) {
for (const contribution of contributions ?? []) {
if (!contribution.id.startsWith(expectedPrefix)) {
errors.push(
`${kind} id must start with the owning plugin id '${expectedPrefix}': ${contribution.id}`,
);
}
}
}
return errors;
}
function validateSemantics(manifest: PluginManifest): string[] {
const errors = [
...findDuplicateIds(manifest),
...validateOwnedContributionIds(manifest),
...validatePermissionResourceLimits(manifest),
];
for (const [engine, range] of Object.entries(manifest.engines)) {
if (validRange(range) === null) {
errors.push(`Invalid ${engine} engine semver range: ${range}`);
}
}
if (valid(manifest.version) === null) {
errors.push(`Invalid plugin semantic version: ${manifest.version}`);
}
const requiredFeatures = new Set(manifest.features?.required ?? []);
for (const feature of manifest.features?.optional ?? []) {
if (requiredFeatures.has(feature)) {
errors.push(`Feature cannot be both required and optional: ${feature}`);
}
}
const companionPaths = new Set<string>();
for (const companion of manifest.companionExecutables ?? []) {
const companionPlatforms = new Set<string>();
for (const variant of companion.variants) {
if (companionPaths.has(variant.path)) {
errors.push(`Duplicate companion executable path: ${variant.path}`);
}
companionPaths.add(variant.path);
for (const platform of variant.platforms) {
if (companionPlatforms.has(platform)) {
errors.push(`Duplicate companion platform for ${companion.id}: ${platform}`);
}
companionPlatforms.add(platform);
}
}
}
const requiredPermissions = new Set(
(manifest.permissions?.required ?? []).map(permissionName),
);
if (requiredPermissions.size !== (manifest.permissions?.required ?? []).length) {
errors.push("Required permissions must not contain duplicate permission names");
}
const optionalPermissions = new Set(
(manifest.permissions?.optional ?? []).map(permissionName),
);
if (optionalPermissions.size !== (manifest.permissions?.optional ?? []).length) {
errors.push("Optional permissions must not contain duplicate permission names");
}
const declaredPermissions = new Set([
...requiredPermissions,
...optionalPermissions,
]);
for (const declaration of manifest.permissions?.optional ?? []) {
const permission = permissionName(declaration);
if (requiredPermissions.has(permission)) {
errors.push(`Permission cannot be both required and optional: ${permission}`);
}
}
const requirePermission = (condition: boolean, permission: PluginPermission, reason: string) => {
if (condition && !declaredPermissions.has(permission)) {
errors.push(`${reason} requires declared permission: ${permission}`);
}
};
requirePermission(
Boolean(manifest.contributes?.settings?.length),
"settings.read",
"Setting contributions",
);
requirePermission(
Boolean(manifest.main.node),
"runtime.advanced",
"Node utility entrypoints",
);
requirePermission(
Boolean(manifest.contributes?.commands?.length),
"commands",
"Command contributions",
);
requirePermission(Boolean(manifest.contributes?.menus?.length), "menus", "Menu contributions");
requirePermission(Boolean(manifest.contributes?.views?.length), "views", "View contributions");
requirePermission(
Boolean(manifest.companionExecutables?.length),
"companion.execute",
"Companion executables",
);
requirePermission(
Boolean(manifest.companionExecutables?.length),
"runtime.advanced",
"Companion executables",
);
if (manifest.companionExecutables?.length && !manifest.main.node) {
errors.push("Companion executables require a Node utility entrypoint");
}
const providerPermissions = new Map<string, readonly PluginPermission[]>([
["terminal.completion", ["provider.terminal", "terminal.complete"]],
["terminal.decoration", ["provider.terminal", "terminal.output", "terminal.decorate"]],
["terminal.link", ["provider.terminal", "terminal.output", "terminal.decorate"]],
["terminal.hover", ["provider.terminal", "terminal.output", "terminal.decorate"]],
["terminal.matcher", ["provider.terminal", "terminal.output", "terminal.decorate"]],
["terminal.semantic", ["provider.terminal", "terminal.input", "terminal.decorate"]],
["terminal.prompt", ["provider.terminal", "terminal.output", "terminal.decorate"]],
["terminal.background", ["provider.terminal", "terminal.decorate"]],
["terminal.theme", ["provider.terminal", "terminal.decorate"]],
["terminal.interceptor.input", ["provider.terminal", "terminal.intercept.input"]],
["terminal.interceptor.output", ["provider.terminal", "terminal.intercept.output"]],
["connection", ["provider.connection"]],
["authentication", ["provider.authentication"]],
["sync", ["provider.sync"]],
["importer", ["provider.importer"]],
]);
for (const provider of manifest.contributes?.providers ?? []) {
for (const permission of providerPermissions.get(provider.kind) ?? ["provider.terminal"]) {
requirePermission(true, permission, `Provider ${provider.id}`);
}
if (provider.kind === "terminal.interceptor.input"
|| provider.kind === "terminal.interceptor.output") {
requirePermission(true, "runtime.advanced", `Provider ${provider.id}`);
if (!manifest.main.node) {
errors.push(`Provider ${provider.id} requires a Node utility entrypoint`);
}
}
if (provider.configurationSchema !== undefined) {
const schemaErrors = restrictedSettingSchemaErrors(provider.configurationSchema, null);
for (const error of schemaErrors) {
errors.push(`Provider ${provider.id} configurationSchema ${error}`);
}
}
}
for (const companion of manifest.companionExecutables ?? []) {
for (const permission of companion.permissions ?? []) {
requirePermission(
true,
permission,
`Companion executable ${companion.id}`,
);
}
}
const commandIds = new Set((manifest.contributes?.commands ?? []).map(({ id }) => id));
const viewIds = new Set((manifest.contributes?.views ?? []).map(({ id }) => id));
const providerIds = new Set((manifest.contributes?.providers ?? []).map(({ id }) => id));
for (const menu of manifest.contributes?.menus ?? []) {
if (!commandIds.has(menu.command)) {
errors.push(`Menu references an undeclared command: ${menu.command}`);
}
if (menu.alt && !commandIds.has(menu.alt)) {
errors.push(`Menu references an undeclared alternate command: ${menu.alt}`);
}
}
for (const keybinding of manifest.contributes?.keybindings ?? []) {
if (!commandIds.has(keybinding.command)) {
errors.push(`Keybinding references an undeclared command: ${keybinding.command}`);
}
}
for (const activationEvent of manifest.activationEvents ?? []) {
const targets = [
["onCommand:", "command", commandIds],
["onView:", "view", viewIds],
["onProvider:", "provider", providerIds],
] as const;
for (const [prefix, kind, ids] of targets) {
if (activationEvent.startsWith(prefix)) {
const id = activationEvent.slice(prefix.length);
if (!ids.has(id)) errors.push(`Activation event references an undeclared ${kind}: ${id}`);
}
}
}
for (const setting of manifest.contributes?.settings ?? []) {
if (setting.secret && setting.default !== undefined) {
errors.push(`Secret setting must not declare a default value: ${setting.id}`);
}
if (["radio", "select", "multiselect"].includes(setting.control)
&& !setting.options?.length) {
errors.push(`${setting.control} setting requires options: ${setting.id}`);
}
const optionValues = new Set<string>();
for (const option of setting.options ?? []) {
if (optionValues.has(option.value)) {
errors.push(`${setting.control} setting has duplicate option value '${option.value}': ${setting.id}`);
}
optionValues.add(option.value);
}
if (!["radio", "select", "multiselect"].includes(setting.control)
&& setting.options !== undefined) {
errors.push(`${setting.control} setting must not declare options: ${setting.id}`);
}
if (["number", "slider"].includes(setting.control) && setting.minimum !== undefined
&& setting.maximum !== undefined && setting.minimum > setting.maximum) {
errors.push(`${setting.control} setting minimum exceeds maximum: ${setting.id}`);
}
if (setting.control === "slider"
&& (setting.minimum === undefined || setting.maximum === undefined)) {
errors.push(`slider setting requires minimum and maximum: ${setting.id}`);
}
if (!["number", "slider"].includes(setting.control)
&& (setting.minimum !== undefined
|| setting.maximum !== undefined
|| setting.step !== undefined)) {
errors.push(`${setting.control} setting must not declare numeric bounds: ${setting.id}`);
}
if (setting.pattern !== undefined
&& !["text", "textarea", "password"].includes(setting.control)) {
errors.push(`${setting.control} setting must not declare a text pattern: ${setting.id}`);
}
if (setting.pattern !== undefined) {
const patternError = settingPatternError(setting.pattern);
if (patternError) errors.push(`${setting.control} setting ${patternError}: ${setting.id}`);
else if (typeof setting.default === "string"
&& !new RegExp(setting.pattern, "u").test(setting.default)) {
errors.push(`${setting.control} setting default does not match its pattern: ${setting.id}`);
}
}
if (setting.control === "password" && !setting.secret) {
errors.push(`password setting must be marked secret: ${setting.id}`);
}
if (setting.secret && setting.control !== "password") {
errors.push(`Secret setting must use the password control: ${setting.id}`);
}
if (setting.secret && setting.sync) {
errors.push(`Secret setting must not be cloud-synced: ${setting.id}`);
}
if (setting.sync && ["device", "session"].includes(setting.scope)) {
errors.push(`${setting.scope}-scoped setting must not be cloud-synced: ${setting.id}`);
}
if (setting.sync && ["file", "directory"].includes(setting.control)) {
errors.push(`${setting.control} setting paths must not be cloud-synced: ${setting.id}`);
}
if (["list", "table"].includes(setting.control) && setting.valueSchema === undefined) {
errors.push(`${setting.control} setting requires valueSchema: ${setting.id}`);
}
if (["list", "table"].includes(setting.control) && setting.valueSchema !== undefined) {
const schemaErrors = restrictedSettingSchemaErrors(setting.valueSchema);
for (const error of schemaErrors) errors.push(`${setting.control} setting ${error}: ${setting.id}`);
if (schemaErrors.length === 0) {
try {
const validateDefault = ajv.compile(setting.valueSchema as object);
if (setting.default !== undefined && !validateDefault(setting.default)) {
errors.push(`${setting.control} setting default does not match valueSchema: ${setting.id}`);
}
} catch (error) {
errors.push(`${setting.control} setting valueSchema is invalid: ${setting.id}: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
if (!["list", "table"].includes(setting.control) && setting.valueSchema !== undefined) {
errors.push(`${setting.control} setting must not declare valueSchema: ${setting.id}`);
}
if (!["list", "table"].includes(setting.control) && setting.sortable) {
errors.push(`${setting.control} setting must not be sortable: ${setting.id}`);
}
if (setting.default !== undefined) {
const stringControls = [
"radio",
"select",
"text",
"textarea",
"password",
"color",
"font",
"file",
"directory",
"keybinding",
];
if (setting.control === "switch" && typeof setting.default !== "boolean") {
errors.push(`switch setting default must be boolean: ${setting.id}`);
}
if (stringControls.includes(setting.control) && typeof setting.default !== "string") {
errors.push(`${setting.control} setting default must be a string: ${setting.id}`);
}
if (["number", "slider"].includes(setting.control)
&& (typeof setting.default !== "number" || !Number.isFinite(setting.default))) {
errors.push(`${setting.control} setting default must be a finite number: ${setting.id}`);
}
if (["list", "table"].includes(setting.control) && !Array.isArray(setting.default)) {
errors.push(`${setting.control} setting default must be an array: ${setting.id}`);
}
if (setting.control === "multiselect") {
if (!Array.isArray(setting.default)
|| setting.default.some((value) => typeof value !== "string")) {
errors.push(`multiselect setting default must be a string array: ${setting.id}`);
} else {
const selected = new Set<string>();
for (const value of setting.default as string[]) {
if (!optionValues.has(value)) {
errors.push(`multiselect setting default uses an undeclared option '${value}': ${setting.id}`);
}
if (selected.has(value)) {
errors.push(`multiselect setting default repeats option '${value}': ${setting.id}`);
}
selected.add(value);
}
}
}
if (["radio", "select"].includes(setting.control)
&& typeof setting.default === "string"
&& !optionValues.has(setting.default)) {
errors.push(`${setting.control} setting default uses an undeclared option '${setting.default}': ${setting.id}`);
}
if (typeof setting.default === "number" && Number.isFinite(setting.default)) {
if (setting.minimum !== undefined && setting.default < setting.minimum) {
errors.push(`${setting.control} setting default is below minimum: ${setting.id}`);
}
if (setting.maximum !== undefined && setting.default > setting.maximum) {
errors.push(`${setting.control} setting default is above maximum: ${setting.id}`);
}
if (setting.step !== undefined) {
const offset = setting.default - (setting.minimum ?? 0);
const steps = offset / setting.step;
if (Math.abs(steps - Math.round(steps)) > 1e-9) {
errors.push(`${setting.control} setting default does not align to step: ${setting.id}`);
}
}
}
}
}
for (const entryPath of [
manifest.main.browser,
manifest.main.node,
...(manifest.contributes?.views ?? []).map(({ entry }) => entry),
...(manifest.contributes?.commands ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
...(manifest.contributes?.menus ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
...(manifest.contributes?.views ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
...(manifest.companionExecutables ?? []).flatMap(({ variants }) => (
variants.map(({ path: companionPath }) => companionPath)
)),
]) {
if (entryPath) {
try {
assertSafePackagePath(entryPath);
} catch (error) {
errors.push(error instanceof Error ? error.message : String(error));
}
}
}
return errors;
}
function packageIconPaths(icon: IconReference | undefined): string[] {
if (icon?.kind !== "package") return [];
return icon.dark
? [icon.light, icon.dark]
: [icon.light];
}
export function validateManifestValue(value: unknown): ManifestValidationResult {
try {
assertManifestJsonStructure(value);
} catch (error) {
return {
valid: false,
errors: [error instanceof Error ? error.message : String(error)],
};
}
const requiredPermissionErrors = validateRequiredPermissionBounds(value);
if (requiredPermissionErrors.length > 0) {
return { valid: false, errors: requiredPermissionErrors };
}
if (!validateSchema(value)) {
return {
valid: false,
errors: (validateSchema.errors ?? []).map(formatAjvError),
};
}
const semanticErrors = validateSemantics(value);
return semanticErrors.length === 0
? { valid: true, manifest: value, errors: [] }
: { valid: false, errors: semanticErrors };
}
export function parseAndValidateManifestContents(contents: Uint8Array): PluginManifest {
if (contents.byteLength > PACKAGE_LIMITS.manifestBytes) {
throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`);
}
let value: unknown;
try {
value = JSON.parse(utf8Decoder.decode(contents));
} catch (error) {
throw new Error(
`Plugin manifest is not valid UTF-8 JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
const result = validateManifestValue(value);
if (!result.valid || !result.manifest) {
throw new Error(`Plugin manifest is invalid:\n- ${result.errors.join("\n- ")}`);
}
return result.manifest;
}
export async function readValidatedManifestSource(
pluginDirectory: string,
): Promise<ValidatedManifestSource> {
const manifestPath = path.join(pluginDirectory, "netcatty.plugin.json");
const initialStats = await lstat(manifestPath);
if (!initialStats.isFile()) {
throw new Error("Plugin manifest must be a regular file");
}
if (initialStats.size > PACKAGE_LIMITS.manifestBytes) {
throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`);
}
const handle = await open(manifestPath, "r");
let contents: Uint8Array;
try {
const [openedStats, currentStats] = await Promise.all([
handle.stat(),
lstat(manifestPath),
]);
if (!openedStats.isFile() || !currentStats.isFile()) {
throw new Error("Plugin manifest must be a regular file");
}
if (openedStats.dev !== currentStats.dev || openedStats.ino !== currentStats.ino) {
throw new Error("Plugin manifest changed while being opened");
}
if (openedStats.size > PACKAGE_LIMITS.manifestBytes) {
throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`);
}
const buffer = new Uint8Array(PACKAGE_LIMITS.manifestBytes + 1);
let bytesRead = 0;
while (bytesRead < buffer.byteLength) {
const result = await handle.read(
buffer,
bytesRead,
buffer.byteLength - bytesRead,
null,
);
if (result.bytesRead === 0) break;
bytesRead += result.bytesRead;
}
if (bytesRead > PACKAGE_LIMITS.manifestBytes) {
throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`);
}
contents = buffer.subarray(0, bytesRead);
} finally {
await handle.close();
}
return {
manifest: parseAndValidateManifestContents(contents),
size: contents.byteLength,
sha256: createHash("sha256").update(contents).digest("hex"),
};
}
export async function readAndValidateManifest(
pluginDirectory: string,
): Promise<PluginManifest> {
return (await readValidatedManifestSource(pluginDirectory)).manifest;
}

View File

@@ -0,0 +1,102 @@
import { PACKAGE_LIMITS } from "./constants.js";
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
const WINDOWS_SPECIAL = /[<>:"|?*]/;
function containsControlCharacter(value: string): boolean {
return [...value].some((character) => character.charCodeAt(0) <= 0x1f);
}
function portablePathKey(value: string): string {
return value
.normalize("NFKC")
.toUpperCase()
.toLowerCase()
.normalize("NFKC");
}
function assertPortablePathSyntax(value: string, originalInput: string): string[] {
if (value.startsWith("/") || /^[A-Za-z]:/.test(value) || value.includes("\\")) {
throw new Error(`Package path must be relative POSIX syntax: ${originalInput}`);
}
const segments = value.split("/");
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
throw new Error(`Package path contains an unsafe segment: ${originalInput}`);
}
for (const segment of segments) {
if (
segment.endsWith(".")
|| segment.endsWith(" ")
|| WINDOWS_RESERVED_NAME.test(segment)
|| WINDOWS_SPECIAL.test(segment)
|| containsControlCharacter(segment)
) {
throw new Error(`Package path is not portable across supported platforms: ${originalInput}`);
}
}
return segments;
}
export function assertSafePackagePath(input: string): string {
if (!input || input !== input.normalize("NFC")) {
throw new Error(`Package path must be non-empty NFC text: ${JSON.stringify(input)}`);
}
if ([...input].length > PACKAGE_LIMITS.pathCharacters) {
throw new Error(
`Package path exceeds ${PACKAGE_LIMITS.pathCharacters} Unicode characters: ${input}`,
);
}
if (Buffer.byteLength(input, "utf8") > PACKAGE_LIMITS.pathBytes) {
throw new Error(`Package path exceeds ${PACKAGE_LIMITS.pathBytes} UTF-8 bytes: ${input}`);
}
const rawSegments = assertPortablePathSyntax(input, input);
const compatibilitySegments = assertPortablePathSyntax(input.normalize("NFKC"), input);
if (compatibilitySegments.length !== rawSegments.length) {
throw new Error(
`Package path changes directory structure after Unicode compatibility normalization: ${input}`,
);
}
return input;
}
export class PackagePathRegistry {
readonly #filesExact = new Set<string>();
readonly #filesPortable = new Set<string>();
readonly #directoriesExact = new Set<string>();
readonly #directoriesPortable = new Set<string>();
add(input: string): string {
const safePath = assertSafePackagePath(input);
const portableKey = portablePathKey(safePath);
if (this.#filesExact.has(safePath) || this.#filesPortable.has(portableKey)) {
throw new Error(`Duplicate or case-colliding package path: ${safePath}`);
}
if (
this.#directoriesExact.has(safePath)
|| this.#directoriesPortable.has(portableKey)
) {
throw new Error(`File/directory package path collision: ${safePath}`);
}
const segments = safePath.split("/");
const ancestors: string[] = [];
for (let index = 1; index < segments.length; index += 1) {
const ancestor = segments.slice(0, index).join("/");
if (
this.#filesExact.has(ancestor)
|| this.#filesPortable.has(portablePathKey(ancestor))
) {
throw new Error(`File/directory package path collision: ${safePath}`);
}
ancestors.push(ancestor);
}
this.#filesExact.add(safePath);
this.#filesPortable.add(portableKey);
for (const ancestor of ancestors) {
this.#directoriesExact.add(ancestor);
this.#directoriesPortable.add(portablePathKey(ancestor));
}
return safePath;
}
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "dist",
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}

View File

@@ -0,0 +1,25 @@
{
"name": "@netcatty/plugin-contract",
"version": "0.1.0-internal",
"private": true,
"type": "module",
"license": "GPL-3.0-or-later",
"files": [
"dist",
"schema"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./schema/plugin-contract.schema.json": "./schema/plugin-contract.schema.json"
},
"scripts": {
"build": "tsc -p tsconfig.build.json"
},
"devDependencies": {
"ajv": "8.18.0",
"ajv-formats": "3.0.1"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
// This file is generated from schema/plugin-contract.schema.json.
// Run `npm run generate:plugin-contract` after changing the contract.
// Do not edit this file directly.
export const PLUGIN_JSON_MAX_DEPTH = 128 as const;
export const PLUGIN_JSON_MAX_NODES = 100000 as const;
export const PLUGIN_WIRE_MAX_SAFE_INTEGER = 9007199254740991 as const;
export const PLUGIN_RPC_MAX_JSON_BYTES = 1048576 as const;
export const PLUGIN_RPC_ERROR_CODES = [-32700,-32600,-32601,-32602,-32603,-32001,-32002,-32003,-32004,-32005,-32006,-32007,-32008,-32009,-32010,-32011,-32012,-32013,-32014,-32015,-32016] as const;
export const PLUGIN_STREAM_MAX_ID_LENGTH = 128 as const;
export const PLUGIN_STREAM_MAX_CHUNK_BYTES = 16777216 as const;
export const PLUGIN_STREAM_MAX_FRAME_JSON_BYTES = 25165824 as const;
export const PLUGIN_STREAM_MIN_WINDOW_BYTES = 1024 as const;
export const PLUGIN_STREAM_MAX_WINDOW_BYTES = 16777216 as const;
export const PLUGIN_STREAM_MAX_CREDIT_BYTES = 16777216 as const;
export const PLUGIN_TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES = 65536 as const;
export const PLUGIN_TERMINAL_INTERCEPTOR_MAX_WINDOW_BYTES = 262144 as const;
export const PLUGIN_IMPORTER_MAX_INPUT_BYTES = 67108864 as const;
export const PLUGIN_IMPORTER_MAX_OUTPUT_BYTES = 67108864 as const;
export const PLUGIN_IMPORTER_MAX_RECORD_BYTES = 67108864 as const;
export const PLUGIN_IMPORTER_MAX_RECORDS = 10000 as const;
export const PLUGIN_SYNC_MAX_OBJECT_BYTES = 67108864 as const;
export const PLUGIN_SYNC_MAX_OBJECT_KEY_LENGTH = 1024 as const;
export const PLUGIN_SYNC_MAX_REVISION_LENGTH = 256 as const;
export const PLUGIN_SYNC_INLINE_OBJECT_BYTES = 92160 as const;

View File

@@ -0,0 +1,951 @@
// This file is generated from schema/plugin-contract.schema.json.
// Run `npm run generate:plugin-contract` after changing the contract.
// Do not edit this file directly.
export type ActivationEvent = "onStartupFinished" | `onCommand:${ContributionId}` | `onView:${ContributionId}` | `onProvider:${ContributionId}`;
export type AuthenticationBeginPayload = {
operationId: string;
connectionProviderId: ContributionId;
configuration: JsonValue;
credential?: (SecretRef) | (CredentialRef) | (SecretLeaseRef);
};
export type AuthenticationChallenge = ({
id: string;
kind: "text" | "password" | "otp";
title: string;
message?: string;
placeholder?: string;
}) | ({
id: string;
kind: "choice";
title: string;
message?: string;
choices: Array<{
id: string;
label: string;
description?: string;
}>;
multiple?: boolean;
}) | ({
id: string;
kind: "confirmation";
title: string;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
}) | ({
id: string;
kind: "browser";
title: string;
url: string;
callbackUri?: string;
}) | ({
id: string;
kind: "deviceCode";
title: string;
verificationUri: string;
userCode: string;
expiresAt: number;
intervalMs?: number;
});
export type AuthenticationResponsePayload = {
operationId: string;
challengeId: string;
response: (string) | (boolean) | (Array<string>) | (SecretLeaseRef);
};
export type AuthenticationResult = ({
status: "challenge";
challenge: AuthenticationChallenge;
}) | ({
status: "authenticated";
credential?: (SecretRef) | (CredentialRef);
}) | ({
status: "cancelled";
message?: string;
}) | ({
status: "failed";
message?: string;
});
export type BoundedPermissionResource = string;
export type CommandContribution = {
id: ContributionId;
title: LocalizedText;
category?: LocalizedText;
description?: LocalizedText;
icon?: IconReference;
enablement?: ContextKeyExpression;
};
export type CompanionExecutable = {
id: ContributionId;
variants: Array<CompanionExecutableVariant>;
permissions?: Array<PluginPermission>;
};
export type CompanionExecutableVariant = {
path: RelativePackagePath;
platforms: Array<CompanionPlatform>;
sha256: string;
};
export type CompanionPlatform = "darwin-arm64" | "darwin-x64" | "linux-arm64" | "linux-x64" | "win32-arm64" | "win32-x64";
export type ConnectionConfigurationPayload = {
configuration: JsonValue;
};
export type ConnectionControlPayload = {
connectionId: string;
operationId: string;
};
export type ConnectionControlResult = null;
export type ConnectionOpenPayload = {
configuration: JsonValue;
operationId: string;
columns: number;
rows: number;
inputStreamId: string;
outputStreamId: string;
windowBytes: number;
credential?: (SecretRef) | (CredentialRef) | (SecretLeaseRef);
authenticationProviderId?: ContributionId;
};
export type ConnectionOpenResult = {
connectionId: string;
status: "connecting" | "connected";
diagnostics?: Array<ProviderValidationIssue>;
};
export type ConnectionProbeResult = {
available: boolean;
message?: string;
capabilities?: { [key: string]: JsonValue };
};
export type ConnectionResizePayload = {
connectionId: string;
operationId: string;
columns: number;
rows: number;
};
export type ConnectionSignalPayload = {
connectionId: string;
operationId: string;
signal: "interrupt" | "terminate" | "kill" | "eof" | "break";
};
export type ConnectionStatusResult = {
status: "connecting" | "connected" | "reconnecting" | "closed" | "error";
message?: string;
retryable?: boolean;
diagnostics?: Array<ProviderValidationIssue>;
};
export type ConnectionValidateResult = {
valid: boolean;
issues: Array<ProviderValidationIssue>;
};
export type ContextKeyExpression = string;
export type ContributionId = string;
export type CredentialRef = {
kind: "credential";
id: string;
};
export type FeatureId = string;
export type IconReference = (ThemeIcon) | (PackageIcon);
export type ImporterDetectPayload = {
fileName?: string;
mediaType?: string;
sample: {
encoding: "base64";
data: string;
};
};
export type ImporterDetectResult = {
confidence: number;
format?: string;
reason?: string;
};
export type ImporterGroupDraft = string | { path: string; label?: string } | { path?: string; label: string };
export type ImporterHostDraft = ({
id?: string;
label?: string;
username?: string;
group?: string;
tags?: Array<string>;
os?: "linux" | "windows" | "macos";
deviceType?: "general" | "network";
identityId?: string;
identityFileId?: string;
telnetIdentityId?: string;
notes?: string;
theme?: string;
sftpEncoding?: string;
sftpFileProtocol?: "auto" | "sftp" | "scp";
moshEnabled?: boolean;
etEnabled?: boolean;
telnetEnabled?: boolean;
sftpSudo?: boolean;
requiresMfa?: boolean;
useSshAgent?: boolean;
identitiesOnly?: boolean;
agentForwarding?: boolean;
x11Forwarding?: boolean;
showLineTimestamps?: boolean;
disableDynamicTabTitle?: boolean;
pinned?: boolean;
autoOpenSftpPanel?: boolean;
sftpFollowTerminalCwd?: boolean;
port?: number;
telnetPort?: number;
etPort?: number;
keepaliveInterval?: number;
keepaliveCountMax?: number;
} & ({
hostname: string;
protocol?: "ssh" | "telnet" | "mosh" | "et" | "local" | "serial";
pluginConnection?: never;
} | {
hostname?: string;
protocol: PluginHostProtocol;
pluginConnection: ImporterPluginConnectionDraft;
}));
export type ImporterIdentityDraft = {
id?: string;
label: string;
username: string;
authMethod: "password" | "key" | "certificate";
password?: string;
keyId?: string;
};
export type ImporterKeyDraft = ({
id?: string;
label: string;
type: "RSA" | "ECDSA" | "ED25519";
publicKey?: string;
certificate?: string;
passphrase?: string;
category?: "key" | "certificate" | "identity";
} & ({
privateKey: string;
filePath?: never;
} | {
privateKey?: never;
filePath: string;
}));
export type ImporterLimits = {"maxInputBytes":67108864,"maxOutputBytes":67108864,"maxRecordBytes":67108864,"maxRecords":10000};
export type ImporterParsePayload = {
operationId: string;
fileName?: string;
mediaType?: string;
inputStreamId: string;
outputStreamId: string;
windowBytes: number;
options?: JsonValue;
};
export type ImporterParseResult = {
parsed: number;
warnings: number;
errors: number;
};
export type ImporterPluginConnectionDraft = {
providerId: ContributionId;
configuration: JsonValue;
authenticationProviderId?: ContributionId;
credentialId?: string;
};
export type ImporterRecord = ({
type: "draft";
draft: ({
kind: "host";
value: ImporterHostDraft;
}) | ({
kind: "identity";
value: ImporterIdentityDraft;
}) | ({
kind: "key";
value: ImporterKeyDraft;
}) | ({
kind: "snippet";
value: ImporterSnippetDraft;
}) | ({
kind: "group";
value: ImporterGroupDraft;
});
}) | ({
type: "warning" | "error";
code?: string;
message: string;
path?: string;
}) | ({
type: "progress";
completed: number;
total?: number;
message?: string;
});
export type ImporterSnippetDraft = {
id?: string;
label: string;
command: string;
tags?: Array<string>;
kind?: "snippet" | "script";
description?: string;
};
export type JsonPrimitive = (string) | (number) | (boolean) | (null);
export type JsonRpcStandardErrorCode = -32700 | -32600 | -32601 | -32602 | -32603;
export type JsonValue = (JsonPrimitive) | (Array<JsonValue>) | ({ [key: string]: JsonValue });
export type JsonValueLimits = {"maxDepth":128,"maxNodes":100000};
export type KeybindingContribution = {
command: ContributionId;
key: string;
mac?: string;
linux?: string;
windows?: string;
when?: ContextKeyExpression;
args?: JsonValue;
};
export type LocalizedText = (string) | ({ [key: string]: string });
export type MenuContribution = {
command: ContributionId;
alt?: ContributionId;
location: MenuLocation;
title?: LocalizedText;
icon?: IconReference;
group?: string;
order?: number;
when?: ContextKeyExpression;
enablement?: ContextKeyExpression;
checked?: ContextKeyExpression;
showKeybinding?: boolean;
};
export type MenuLocation = "commandPalette" | "application" | "host/context" | "terminal/context" | "terminal/toolbar" | "statusBar";
export type NonResourceScopedPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
export type NullableRpcId = (RpcId) | (null);
export type PackageIcon = {
kind: "package";
light: RelativePackagePath;
dark?: RelativePackagePath;
};
export type PermissionDecision = ({
requestId: string;
decision: "allow";
scope: PermissionGrantScope;
resources?: Array<PermissionResource>;
}) | ({
requestId: string;
decision: "deny" | "cancel";
});
export type PermissionDeclaration = (PluginPermission) | (PermissionResourceDeclaration);
export type PermissionGrantScope = "once" | "session" | "application" | "always";
export type PermissionRequest = {
requestId: string;
pluginId: PluginId;
pluginVersion?: SemanticVersion;
pluginName?: string;
publisher?: string;
runtimeId?: string | null;
runtimeKind?: "browser" | "utility" | null;
permission: PluginPermission;
resources?: Array<PermissionResource>;
resourceKinds?: Array<PermissionResourceKind>;
reason: string;
operationId?: string;
sessionId?: string;
allowedScopes?: Array<PermissionGrantScope>;
};
export type PermissionResource = string;
export type PermissionResourceDeclaration = {
permission: PluginPermission;
resources: Array<PermissionResource>;
reason?: string;
};
export type PermissionResourceKind = "exact" | "directory";
export type PermissionSet = {
required?: Array<RequiredPermissionDeclaration>;
optional?: Array<PermissionDeclaration>;
};
export type PluginContributions = {
settings?: Array<SettingContribution>;
commands?: Array<CommandContribution>;
keybindings?: Array<KeybindingContribution>;
menus?: Array<MenuContribution>;
views?: Array<ViewContribution>;
providers?: Array<ProviderContribution>;
};
export type PluginEngineHeader = ({
netcatty: SemverRange;
api: SemverRange;
} & Record<string, unknown>);
export type PluginEngines = {
netcatty: SemverRange;
api: SemverRange;
};
export type PluginEntrypoints = {
browser?: RelativePackagePath;
node?: RelativePackagePath;
};
export type PluginErrorData = {
pluginCode: PluginErrorName;
details?: JsonValue;
};
export type PluginErrorName = "cancelled" | "unknown" | "deadline_exceeded" | "invalid_argument" | "not_found" | "already_exists" | "permission_denied" | "resource_exhausted" | "failed_precondition" | "aborted" | "out_of_range" | "unavailable" | "unsupported" | "internal" | "data_loss" | "unauthenticated";
export type PluginFeatures = {
required?: Array<FeatureId>;
optional?: Array<FeatureId>;
};
export type PluginHostProtocol = `plugin:${ContributionId}`;
export type PluginId = string;
export type PluginManifest = {
$schema?: string;
manifestVersion: 1;
id: PluginId;
name: string;
displayName?: LocalizedText;
description?: LocalizedText;
version: SemanticVersion;
publisher: string;
license?: string;
homepage?: string;
repository?: string;
engines: PluginEngines;
features?: PluginFeatures;
main: PluginEntrypoints;
activationEvents?: Array<ActivationEvent>;
permissions?: PermissionSet;
contributes?: PluginContributions;
companionExecutables?: Array<CompanionExecutable>;
};
export type PluginManifestHeader = ({
$schema?: string;
manifestVersion: number;
id: PluginId;
version: SemanticVersion;
engines: PluginEngineHeader;
} & Record<string, unknown>);
export type PluginPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
export type PluginWireErrorCode = -32001 | -32002 | -32003 | -32004 | -32005 | -32006 | -32007 | -32008 | -32009 | -32010 | -32011 | -32012 | -32013 | -32014 | -32015 | -32016;
export type ProgressBegin = {
kind: "begin";
title: string;
message?: string;
percentage?: number;
cancellable?: boolean;
};
export type ProgressEnd = {
kind: "end";
message?: string;
};
export type ProgressReport = {
kind: "report";
message?: string;
percentage?: number;
increment?: number;
};
export type ProgressToken = RpcId;
export type ProgressValue = (ProgressBegin) | (ProgressReport) | (ProgressEnd);
export type ProviderContribution = {
id: ContributionId;
label: LocalizedText;
description?: LocalizedText;
kind: ProviderKind;
capabilities?: Array<FeatureId>;
configurationSchema?: JsonValue;
};
export type ProviderKind = "terminal.completion" | "terminal.decoration" | "terminal.link" | "terminal.hover" | "terminal.matcher" | "terminal.semantic" | "terminal.prompt" | "terminal.background" | "terminal.theme" | "terminal.interceptor.input" | "terminal.interceptor.output" | "connection" | "authentication" | "sync" | "importer";
export type ProviderRequest = {
providerId: ContributionId;
operation: string;
requestId: string;
payload?: JsonValue;
deadlineMs?: number;
cancellationId?: string;
};
export type ProviderResult = ({
requestId: string;
status: "ok";
result: JsonValue;
}) | ({
requestId: string;
status: "cancelled";
}) | ({
requestId: string;
status: "failed";
error: RpcErrorObject;
});
export type ProviderValidationIssue = {
path?: string;
severity: "warning" | "error";
message: string;
};
export type RelativePackagePath = string;
export type RequiredPermissionDeclaration = (NonResourceScopedPermission) | (RequiredPermissionResourceDeclaration);
export type RequiredPermissionResourceDeclaration = {
permission: ResourceScopedPermission;
resources: Array<BoundedPermissionResource>;
reason?: string;
};
export type ResourceScopedPermission = "network" | "filesystem.read" | "filesystem.write" | "companion.execute";
export type RpcCancel = {
jsonrpc: "2.0";
method: "$/cancelRequest";
params: {
cancellationId: string;
};
};
export type RpcErrorCode = (JsonRpcStandardErrorCode) | (PluginWireErrorCode);
export type RpcErrorObject = {
code: RpcErrorCode;
message: string;
data?: JsonValue;
};
export type RpcFailure = {
jsonrpc: "2.0";
id: NullableRpcId;
error: RpcErrorObject;
};
export type RpcId = (string) | (SafeUnsignedInteger);
export type RpcLimits = {"maxJsonBytes":1048576};
export type RpcMessage = (RpcRequest) | (RpcNotification) | (RpcSuccess) | (RpcFailure) | (RpcCancel) | (RpcProgressNotification) | (RuntimeInitializeRequest) | (TerminalInterceptorAttachmentRequest);
export type RpcNotification = {
jsonrpc: "2.0";
method: string;
params?: JsonValue;
};
export type RpcProgressNotification = {
jsonrpc: "2.0";
method: "$/progress";
params: {
token: ProgressToken;
value: ProgressValue;
};
};
export type RpcRequest = {
jsonrpc: "2.0";
id: RpcId;
method: string;
params?: JsonValue;
deadlineMs?: number;
cancellationId?: string;
};
export type RpcSuccess = {
jsonrpc: "2.0";
id: RpcId;
result: JsonValue;
};
export type RuntimeInitializeParams = {
netcattyVersion: SemanticVersion;
apiVersion: SemanticVersion;
supportedFeatures: Array<FeatureId>;
};
export type RuntimeInitializeRequest = {
jsonrpc: "2.0";
id: RpcId;
method: "plugin.initialize";
params: RuntimeInitializeParams;
deadlineMs?: number;
cancellationId?: string;
};
export type RuntimeInitializeResult = {
pluginId: PluginId;
pluginVersion: SemanticVersion;
apiVersion: SemanticVersion;
enabledFeatures: Array<FeatureId>;
};
export type RuntimeInitializeSuccess = {
jsonrpc: "2.0";
id: RpcId;
result: RuntimeInitializeResult;
};
export type SafePositiveInteger = number;
export type SafeUnsignedInteger = number;
export type SecretLeaseRef = {
kind: "secret-lease";
id: string;
operationId: string;
expiresAt: SafePositiveInteger;
};
export type SecretRef = {
kind: "secret";
id: string;
key: string;
};
export type SemanticVersion = string;
export type SemverRange = string;
export type SettingContribution = {
id: ContributionId;
label: LocalizedText;
description?: LocalizedText;
control: SettingControl;
scope: SettingScope;
default?: JsonValue;
secret?: boolean;
required?: boolean;
options?: Array<SettingOption>;
minimum?: number;
maximum?: number;
step?: number;
pattern?: string;
placeholder?: LocalizedText;
when?: ContextKeyExpression;
restartRequired?: boolean;
sync?: boolean;
sortable?: boolean;
valueSchema?: JsonValue;
};
export type SettingControl = "switch" | "radio" | "select" | "multiselect" | "text" | "textarea" | "number" | "slider" | "password" | "color" | "font" | "file" | "directory" | "keybinding" | "list" | "table";
export type SettingOption = {
value: string;
label: LocalizedText;
description?: LocalizedText;
};
export type SettingScope = "application" | "workspace" | "host" | "session" | "device";
export type StreamChunkByteLength = number;
export type StreamChunkData = ({
encoding: "json";
value: JsonValue;
byteLength: StreamChunkByteLength;
}) | ({
encoding: "base64";
value: string;
byteLength: StreamChunkByteLength;
}) | ({
encoding: "transfer";
byteLength: StreamChunkByteLength;
});
export type StreamCreditBytes = number;
export type StreamFrame = ({
streamId: StreamId;
sequence: 0;
kind: "open";
windowBytes: StreamWindowBytes;
}) | ({
streamId: StreamId;
sequence: SafePositiveInteger;
kind: "chunk";
data: StreamChunkData;
}) | ({
streamId: StreamId;
sequence: SafePositiveInteger;
kind: "end" | "cancel";
}) | ({
streamId: StreamId;
sequence: SafePositiveInteger;
kind: "error";
error: RpcErrorObject;
}) | ({
streamId: StreamId;
sequence: SafeUnsignedInteger;
kind: "windowUpdate";
creditBytes: StreamCreditBytes;
});
export type StreamId = string;
export type StreamLimits = {"maxStreamIdLength":128,"maxChunkBytes":16777216,"maxFrameJsonBytes":25165824,"minWindowBytes":1024,"maxWindowBytes":16777216,"maxCreditBytes":16777216};
export type StreamWindowBytes = number;
export type SyncAccount = {
id: string;
email?: string;
name?: string;
avatarUrl?: string;
};
export type SyncCapabilitiesResult = {
revisions: boolean;
conditionalWrites: boolean;
atomicReplacement: boolean;
maxObjectBytes?: number;
maxObjects?: number;
};
export type SyncConnectPayload = {
configuration: JsonValue;
operationId: string;
credential?: (SecretRef) | (CredentialRef) | (SecretLeaseRef);
};
export type SyncConnectResult = {
account: SyncAccount;
};
export type SyncDeleteObjectPayload = {
key: SyncObjectKey;
operationId: string;
expectedRevision?: SyncObjectRevision;
};
export type SyncDeleteObjectResult = {
deleted: boolean;
};
export type SyncDisconnectPayload = {
operationId?: string;
};
export type SyncDisconnectResult = null;
export type SyncGetAccountPayload = {
operationId?: string;
};
export type SyncGetAccountResult = {
account: (SyncAccount) | (null);
};
export type SyncGetCapabilitiesPayload = {
operationId?: string;
};
export type SyncLimits = {"maxObjectBytes":67108864,"maxObjectKeyLength":1024,"maxRevisionLength":256,"inlineObjectBytes":92160};
export type SyncObjectKey = string;
export type SyncObjectRevision = string;
export type SyncReadObjectPayload = {
key: SyncObjectKey;
operationId: string;
outputStreamId?: string;
windowBytes?: number;
};
export type SyncReadObjectResult = ({
found: false;
}) | ({
found: true;
byteLength: number;
encoding: "base64";
data: string;
revision?: SyncObjectRevision;
contentType?: string;
}) | ({
found: true;
byteLength: number;
streamed: true;
revision?: SyncObjectRevision;
contentType?: string;
});
export type SyncWriteObjectPayload = {
key: SyncObjectKey;
operationId: string;
byteLength: number;
expectedRevision?: (SyncObjectRevision) | (null);
encoding?: "base64";
data?: string;
inputStreamId?: string;
windowBytes?: number;
};
export type SyncWriteObjectResult = {
created: boolean;
revision?: SyncObjectRevision;
};
export type TerminalInterceptorAttachmentDescriptor = {
providerId: ContributionId;
direction: "input" | "output";
session: TerminalSessionSnapshot;
};
export type TerminalInterceptorAttachmentParams = {
descriptor: TerminalInterceptorAttachmentDescriptor;
};
export type TerminalInterceptorAttachmentRequest = {
jsonrpc: "2.0";
id: RpcId;
method: "plugin.terminal.interceptor.attach";
params: TerminalInterceptorAttachmentParams;
deadlineMs?: number;
cancellationId?: string;
};
export type TerminalInterceptorAttachmentResult = {
accepted: true;
};
export type TerminalInterceptorAttachmentSuccess = {
jsonrpc: "2.0";
id: RpcId;
result: TerminalInterceptorAttachmentResult;
};
export type TerminalInterceptorChunkByteLength = number;
export type TerminalInterceptorChunkFrame = {
type: "netcatty:terminal-interceptor:chunk";
sequence: SafePositiveInteger;
direction: TerminalInterceptorDirection;
creditBytes: TerminalInterceptorCreditBytes;
byteLength: TerminalInterceptorChunkByteLength;
};
export type TerminalInterceptorCreditBytes = number;
export type TerminalInterceptorDirection = "input" | "output";
export type TerminalInterceptorFailedResultFrame = {
type: "netcatty:terminal-interceptor:result";
sequence: SafePositiveInteger;
status: "failed";
};
export type TerminalInterceptorFrame = (TerminalInterceptorReadyFrame) | (TerminalInterceptorChunkFrame) | (TerminalInterceptorOkResultFrame) | (TerminalInterceptorFailedResultFrame);
export type TerminalInterceptorLimits = {"maxChunkBytes":65536,"maxWindowBytes":262144};
export type TerminalInterceptorOkResultFrame = {
type: "netcatty:terminal-interceptor:result";
sequence: SafePositiveInteger;
status: "ok";
creditBytes: TerminalInterceptorChunkByteLength;
byteLength: TerminalInterceptorChunkByteLength;
};
export type TerminalInterceptorReadyFrame = {
type: "netcatty:terminal-interceptor:ready";
sessionId: string;
direction: TerminalInterceptorDirection;
windowBytes: TerminalInterceptorWindowBytes;
};
export type TerminalInterceptorWindowBytes = number;
export type TerminalSessionSnapshot = {
sessionId: string;
hostId?: string;
workspaceId?: string;
protocol: string;
status: "connecting" | "connected" | "disconnected";
cwd?: string;
title?: string;
shellType?: "posix" | "fish" | "powershell" | "cmd" | "unknown";
cols?: number;
rows?: number;
alternateScreen?: boolean;
};
export type ThemeIcon = {
kind: "theme";
name: string;
};
export type ViewContribution = {
id: ContributionId;
title: LocalizedText;
location: ViewLocation;
entry: RelativePackagePath;
icon?: IconReference;
order?: number;
when?: ContextKeyExpression;
retainContextWhenHidden?: boolean;
};
export type ViewLocation = "aside" | "panel" | "tab" | "modal" | "settings";
export type WireIntegerLimits = {"maxSafeInteger":9007199254740991};

View File

@@ -0,0 +1,49 @@
export const PLUGIN_API_VERSION = "0.1.0-internal" as const;
export const PLUGIN_MANIFEST_FILE = "netcatty.plugin.json" as const;
export const PLUGIN_PACKAGE_EXTENSION = ".ncpkg" as const;
export type * from "./generated/plugin-contract.js";
export {
PLUGIN_JSON_MAX_DEPTH,
PLUGIN_JSON_MAX_NODES,
assertJsonValue,
serializeJsonValue,
} from "./jsonValue.js";
export {
PLUGIN_IMPORTER_MAX_INPUT_BYTES,
PLUGIN_IMPORTER_MAX_OUTPUT_BYTES,
PLUGIN_IMPORTER_MAX_RECORD_BYTES,
PLUGIN_IMPORTER_MAX_RECORDS,
PLUGIN_RPC_ERROR_CODES,
PLUGIN_RPC_MAX_JSON_BYTES,
PLUGIN_SYNC_INLINE_OBJECT_BYTES,
PLUGIN_SYNC_MAX_OBJECT_BYTES,
PLUGIN_SYNC_MAX_OBJECT_KEY_LENGTH,
PLUGIN_SYNC_MAX_REVISION_LENGTH,
PLUGIN_TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES,
PLUGIN_TERMINAL_INTERCEPTOR_MAX_WINDOW_BYTES,
PLUGIN_WIRE_MAX_SAFE_INTEGER,
} from "./generated/plugin-contract-limits.js";
export {
COMPANION_STDIO_MAX_CONTENT_BYTES,
COMPANION_STDIO_MAX_HEADER_BYTES,
ContentLengthFrameDecoder,
encodeContentLengthFrame,
type ContentLengthFrameDecoderOptions,
} from "./stdioFraming.js";
export {
PLUGIN_STREAM_MAX_CHUNK_BYTES,
PLUGIN_STREAM_MAX_CREDIT_BYTES,
PLUGIN_STREAM_MAX_FRAME_JSON_BYTES,
PLUGIN_STREAM_MAX_ID_LENGTH,
PLUGIN_STREAM_MAX_WINDOW_BYTES,
PLUGIN_STREAM_MIN_WINDOW_BYTES,
assertStreamChunkData,
assertStreamFrame,
createBase64StreamChunk,
createJsonStreamChunk,
createMessagePortStreamEnvelope,
materializeStreamChunk,
type MaterializedStreamChunk,
type MessagePortStreamEnvelope,
} from "./streamTransport.js";

View File

@@ -0,0 +1,149 @@
import type { JsonValue } from "./generated/plugin-contract.js";
import {
PLUGIN_JSON_MAX_DEPTH,
PLUGIN_JSON_MAX_NODES,
} from "./generated/plugin-contract-limits.js";
export {
PLUGIN_JSON_MAX_DEPTH,
PLUGIN_JSON_MAX_NODES,
} from "./generated/plugin-contract-limits.js";
interface JsonValidationBudget {
nodes: number;
}
function assertJsonValueInternal(
value: unknown,
ancestors: WeakSet<object>,
depth: number,
budget: JsonValidationBudget,
): void {
if (depth > PLUGIN_JSON_MAX_DEPTH) {
throw new RangeError(
`JSON values must not exceed ${PLUGIN_JSON_MAX_DEPTH} levels of nesting`,
);
}
budget.nodes += 1;
if (budget.nodes > PLUGIN_JSON_MAX_NODES) {
throw new RangeError(
`JSON values must not contain more than ${PLUGIN_JSON_MAX_NODES} nodes`,
);
}
if (value === null || typeof value === "string" || typeof value === "boolean") return;
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new TypeError("JSON numbers must be finite");
return;
}
if (typeof value !== "object") {
throw new TypeError(`Unsupported JSON value type: ${typeof value}`);
}
if (ancestors.has(value)) throw new TypeError("JSON values must not contain cycles");
ancestors.add(value);
try {
if (Array.isArray(value)) {
const keys = Object.keys(value);
const ownKeys = Reflect.ownKeys(value);
if (keys.length !== value.length || ownKeys.length !== value.length + 1) {
throw new TypeError("JSON arrays must be dense and contain no named properties");
}
for (let index = 0; index < value.length; index += 1) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
throw new TypeError("JSON arrays must contain enumerable data properties only");
}
assertJsonValueInternal(descriptor.value, ancestors, depth + 1, budget);
}
return;
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError("JSON objects must be plain records");
}
const stringKeys = Object.keys(value);
const ownKeys = Reflect.ownKeys(value);
if (ownKeys.length !== stringKeys.length) {
throw new TypeError("JSON objects must not contain symbols or non-enumerable properties");
}
for (const key of stringKeys) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor || !("value" in descriptor)) {
throw new TypeError("JSON objects must not contain accessor properties");
}
assertJsonValueInternal(descriptor.value, ancestors, depth + 1, budget);
}
} finally {
ancestors.delete(value);
}
}
export function assertJsonValue(value: unknown): asserts value is JsonValue {
assertJsonValueInternal(value, new WeakSet(), 0, { nodes: 0 });
}
export interface JsonValuePropertyObservation {
readonly depth: number;
readonly parentKey: string | number | undefined;
readonly key: string | number;
readonly value: JsonValue;
}
export type JsonValuePropertyObserver = (
observation: JsonValuePropertyObservation,
) => void;
function serializeValidatedJsonValue(
value: JsonValue,
observer: JsonValuePropertyObserver | undefined,
depth: number,
parentKey: string | number | undefined,
): string {
if (value === null || typeof value !== "object") {
const serialized = JSON.stringify(value);
if (serialized === undefined) throw new TypeError("Value is not serializable JSON");
return serialized;
}
if (Array.isArray(value)) {
const serializedItems: string[] = [];
for (let index = 0; index < value.length; index += 1) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
if (!descriptor || !("value" in descriptor)) {
throw new TypeError("JSON arrays must contain data properties only");
}
const item = descriptor.value as JsonValue;
observer?.({ depth, parentKey, key: index, value: item });
serializedItems.push(serializeValidatedJsonValue(item, observer, depth + 1, index));
}
return `[${serializedItems.join(",")}]`;
}
const serializedEntries: string[] = [];
for (const key of Object.keys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor || !("value" in descriptor)) {
throw new TypeError("JSON objects must contain data properties only");
}
const propertyValue = descriptor.value as JsonValue;
observer?.({ depth, parentKey, key, value: propertyValue });
serializedEntries.push(
`${JSON.stringify(key)}:${serializeValidatedJsonValue(
propertyValue,
observer,
depth + 1,
key,
)}`,
);
}
return `{${serializedEntries.join(",")}}`;
}
export function serializeJsonValueWithPropertyObserver(
value: unknown,
observer: JsonValuePropertyObserver | undefined,
): string {
assertJsonValue(value);
return serializeValidatedJsonValue(value, observer, 0, undefined);
}
export function serializeJsonValue(value: unknown): string {
return serializeJsonValueWithPropertyObserver(value, undefined);
}

View File

@@ -0,0 +1,215 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
COMPANION_STDIO_MAX_CONTENT_BYTES,
ContentLengthFrameDecoder,
encodeContentLengthFrame,
} from "./stdioFraming.ts";
test("content-length framing round-trips fragmented and coalesced JSON messages", () => {
const first = encodeContentLengthFrame({ jsonrpc: "2.0", id: 1, method: "plugin.initialize" });
const second = encodeContentLengthFrame({ jsonrpc: "2.0", id: 1, result: { ok: true } });
const joined = new Uint8Array(first.byteLength + second.byteLength);
joined.set(first);
joined.set(second, first.byteLength);
const decoder = new ContentLengthFrameDecoder();
assert.deepEqual(decoder.push(joined.subarray(0, 7)), []);
assert.deepEqual(decoder.push(joined.subarray(7, first.byteLength + 3)), [
{ jsonrpc: "2.0", id: 1, method: "plugin.initialize" },
]);
assert.deepEqual(decoder.push(joined.subarray(first.byteLength + 3)), [
{ jsonrpc: "2.0", id: 1, result: { ok: true } },
]);
assert.doesNotThrow(() => decoder.finish());
for (let split = 0; split <= first.byteLength; split += 1) {
const splitDecoder = new ContentLengthFrameDecoder();
const message = { jsonrpc: "2.0", id: 1, method: "plugin.initialize" };
assert.deepEqual(
splitDecoder.push(first.subarray(0, split)),
split === first.byteLength ? [message] : [],
);
assert.deepEqual(
splitDecoder.push(first.subarray(split)),
split === first.byteLength ? [] : [message],
);
assert.doesNotThrow(() => splitDecoder.finish());
}
});
test("content-length framing rejects ambiguous headers and oversized payloads", () => {
assert.throws(
() => encodeContentLengthFrame(undefined as never),
/Unsupported JSON value type/,
);
assert.throws(
() => encodeContentLengthFrame({ value: Number.NaN } as never),
/JSON numbers must be finite/,
);
assert.throws(
() => encodeContentLengthFrame({
streamId: "stream-1",
sequence: 1,
kind: "chunk",
data: { encoding: "transfer", byteLength: 4 },
}),
/cannot be encoded over companion stdio/,
);
let accessorReads = 0;
const accessorFrame = { streamId: "stream-1", sequence: 1 } as Record<string, unknown>;
Object.defineProperty(accessorFrame, "kind", {
enumerable: true,
get() {
accessorReads += 1;
return "chunk";
},
});
assert.throws(
() => encodeContentLengthFrame(accessorFrame as never),
/must not contain accessor properties/,
);
assert.equal(accessorReads, 0, "framing must reject accessors without invoking them");
let proxyReads = 0;
const proxyFrame = new Proxy({
streamId: "stream-1",
sequence: 1,
kind: "chunk",
data: { encoding: "transfer", byteLength: 4 },
}, {
get(target, property, receiver) {
proxyReads += 1;
return Reflect.get(target, property, receiver);
},
});
assert.throws(
() => encodeContentLengthFrame(proxyFrame),
/cannot be encoded over companion stdio/,
);
assert.equal(proxyReads, 0, "framing must inspect descriptor values instead of reading proxy fields");
let inheritedReads = 0;
const pollutedPrototype = {} as Record<string, unknown>;
Object.defineProperty(pollutedPrototype, "kind", {
enumerable: true,
get() {
inheritedReads += 1;
return "chunk";
},
});
const pollutedFrame = Object.assign(Object.create(pollutedPrototype), {
streamId: "stream-1",
sequence: 1,
data: { encoding: "transfer", byteLength: 4 },
});
assert.throws(
() => encodeContentLengthFrame(pollutedFrame),
/plain records/,
);
assert.equal(inheritedReads, 0, "framing must reject polluted prototypes without reading them");
const duplicate = new ContentLengthFrameDecoder();
assert.throws(
() => duplicate.push("Content-Length: 2\r\ncontent-length: 2\r\n\r\n{}"),
/Duplicate companion stdio header/,
);
const whitespaceBeforeColon = new ContentLengthFrameDecoder();
assert.throws(
() => whitespaceBeforeColon.push("Content-Length : 2\r\n\r\n{}"),
/Malformed companion stdio header/,
);
const unsupported = new ContentLengthFrameDecoder();
assert.throws(
() => unsupported.push("Content-Length: 2\r\nX-Mode: unsafe\r\n\r\n{}"),
/Unsupported companion stdio header/,
);
const oversized = new ContentLengthFrameDecoder({ maxContentBytes: 4 });
assert.throws(
() => oversized.push("Content-Length: 5\r\n\r\n12345"),
/exceeds 4 bytes/,
);
const truncated = new ContentLengthFrameDecoder();
assert.deepEqual(truncated.push("Content-Length: 5\r\n\r\n12"), []);
assert.throws(() => truncated.finish(), /truncated frame/);
assert.throws(
() => new ContentLengthFrameDecoder({
maxContentBytes: COMPANION_STDIO_MAX_CONTENT_BYTES + 1,
}),
/must be an integer between/,
);
for (const payload of ["1e999", '{"value":1e999}']) {
const nonFinite = new ContentLengthFrameDecoder();
const payloadBytes = new TextEncoder().encode(payload).byteLength;
assert.throws(
() => nonFinite.push(`Content-Length: ${payloadBytes}\r\n\r\n${payload}`),
/outside the JSON value contract: JSON numbers must be finite/,
);
}
});
test("content-length framing accepts a split delimiter at the header byte limit", () => {
const maxHeaderBytes = 32;
const header = `Content-Length:${" ".repeat(16)}2`;
assert.equal(new TextEncoder().encode(header).byteLength, maxHeaderBytes);
const separator = "\r\n\r\n";
for (let split = 0; split <= separator.length; split += 1) {
const decoder = new ContentLengthFrameDecoder({ maxHeaderBytes });
assert.deepEqual(decoder.push(`${header}${separator.slice(0, split)}`), []);
assert.deepEqual(decoder.push(`${separator.slice(split)}{}`), [{}]);
assert.doesNotThrow(() => decoder.finish());
}
const oversized = new ContentLengthFrameDecoder({ maxHeaderBytes });
assert.throws(
() => oversized.push(`${header} \r\n\r\n{}`),
/header exceeds 32 bytes/,
);
});
test("content-length framing stays linear under adversarial byte fragmentation", () => {
const message = { value: "x".repeat(100_000) };
const frame = encodeContentLengthFrame(message);
const decoder = new ContentLengthFrameDecoder();
const startedAt = performance.now();
let decoded: unknown[] = [];
for (let index = 0; index < frame.byteLength; index += 1) {
const messages = decoder.push(frame.subarray(index, index + 1));
if (messages.length > 0) decoded = messages;
}
const elapsedMs = performance.now() - startedAt;
assert.deepEqual(decoded, [message]);
assert.doesNotThrow(() => decoder.finish());
assert.ok(
elapsedMs < 3_000,
`byte-fragmented frame decoding took ${Math.round(elapsedMs)}ms`,
);
});
test("content-length framing coalesces fragmented headers without losing the body", () => {
const header = `Content-Length:${" ".repeat(2_000)}2\r\n\r\n`;
const bytes = new TextEncoder().encode(`${header}{}`);
const decoder = new ContentLengthFrameDecoder();
let decoded: unknown[] = [];
for (let index = 0; index < bytes.byteLength; index += 1) {
const messages = decoder.push(bytes.subarray(index, index + 1));
if (messages.length > 0) decoded = messages;
}
assert.deepEqual(decoded, [{}]);
assert.doesNotThrow(() => decoder.finish());
});
test("content-length framing snapshots Buffer input before returning", () => {
const decoder = new ContentLengthFrameDecoder();
const prefix = Buffer.from("Content-Length: 2\r\n\r\n{");
assert.deepEqual(decoder.push(prefix), []);
prefix.fill(0);
assert.deepEqual(decoder.push("}"), [{}]);
assert.doesNotThrow(() => decoder.finish());
});

View File

@@ -0,0 +1,272 @@
import type {
JsonValue,
RpcMessage,
StreamFrame,
} from "./generated/plugin-contract.js";
import {
assertJsonValue,
serializeJsonValueWithPropertyObserver,
} from "./jsonValue.js";
export const COMPANION_STDIO_MAX_HEADER_BYTES = 8 * 1024;
export const COMPANION_STDIO_MAX_CONTENT_BYTES = 16 * 1024 * 1024;
const HEADER_SEPARATOR = new Uint8Array([13, 10, 13, 10]);
const ABSOLUTE_MAX_HEADER_BYTES = 64 * 1024;
const BYTE_QUEUE_SLAB_BYTES = 64 * 1024;
const encoder = new TextEncoder();
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
interface ByteQueueChunk {
readonly bytes: Uint8Array;
length: number;
}
class ByteQueue {
readonly #chunks: ByteQueueChunk[] = [];
#headIndex = 0;
#headOffset = 0;
#byteLength = 0;
get byteLength(): number {
return this.#byteLength;
}
push(chunk: Uint8Array): void {
if (chunk.byteLength === 0) return;
let inputOffset = 0;
while (inputOffset < chunk.byteLength) {
let tail = this.#chunks.at(-1);
if (!tail || tail.length === tail.bytes.byteLength) {
const remaining = chunk.byteLength - inputOffset;
const capacity = remaining >= BYTE_QUEUE_SLAB_BYTES
? remaining
: BYTE_QUEUE_SLAB_BYTES;
tail = { bytes: new Uint8Array(capacity), length: 0 };
this.#chunks.push(tail);
}
const take = Math.min(
tail.bytes.byteLength - tail.length,
chunk.byteLength - inputOffset,
);
tail.bytes.set(chunk.subarray(inputOffset, inputOffset + take), tail.length);
tail.length += take;
inputOffset += take;
this.#byteLength += take;
}
}
indexOf(needle: Uint8Array, limit: number): number {
let matched = 0;
let index = 0;
for (let chunkIndex = this.#headIndex; chunkIndex < this.#chunks.length; chunkIndex += 1) {
const chunk = this.#chunks[chunkIndex];
const start = chunkIndex === this.#headIndex ? this.#headOffset : 0;
for (let offset = start; offset < chunk.length; offset += 1) {
if (index >= limit) return -1;
const byte = chunk.bytes[offset];
if (byte === needle[matched]) {
matched += 1;
if (matched === needle.byteLength) return index - needle.byteLength + 1;
} else {
matched = byte === needle[0] ? 1 : 0;
}
index += 1;
}
}
return -1;
}
consume(byteLength: number): Uint8Array {
if (byteLength < 0 || byteLength > this.#byteLength) {
throw new RangeError(`Cannot consume ${byteLength} bytes from ${this.#byteLength}`);
}
const output = new Uint8Array(byteLength);
let outputOffset = 0;
let remaining = byteLength;
while (remaining > 0) {
const head = this.#chunks[this.#headIndex];
const available = head.length - this.#headOffset;
const take = Math.min(available, remaining);
output.set(
head.bytes.subarray(this.#headOffset, this.#headOffset + take),
outputOffset,
);
outputOffset += take;
remaining -= take;
this.#headOffset += take;
this.#byteLength -= take;
if (this.#headOffset === head.length) {
this.#headIndex += 1;
this.#headOffset = 0;
}
}
if (this.#byteLength === 0) {
this.#chunks.length = 0;
this.#headIndex = 0;
} else if (this.#headIndex >= 1_024 && this.#headIndex * 2 >= this.#chunks.length) {
this.#chunks.splice(0, this.#headIndex);
this.#headIndex = 0;
}
return output;
}
}
function decodeAscii(bytes: Uint8Array): string {
for (const byte of bytes) {
if (byte > 0x7f) throw new Error("Companion stdio headers must contain ASCII only");
}
return utf8Decoder.decode(bytes);
}
function parseContentLength(headerBytes: Uint8Array, maxContentBytes: number): number {
const values = new Map<string, string>();
for (const line of decodeAscii(headerBytes).split("\r\n")) {
const match = /^([A-Za-z][A-Za-z0-9-]*):[ \t]*(.*)$/.exec(line);
if (!match) throw new Error(`Malformed companion stdio header: ${line}`);
const name = match[1].toLowerCase();
const value = match[2].trim();
if (values.has(name)) throw new Error(`Duplicate companion stdio header: ${name}`);
if (name !== "content-length" && name !== "content-type") {
throw new Error(`Unsupported companion stdio header: ${name}`);
}
values.set(name, value);
}
const rawLength = values.get("content-length");
if (!rawLength || !/^(0|[1-9]\d*)$/.test(rawLength)) {
throw new Error("Companion stdio frame requires one decimal Content-Length header");
}
const contentLength = Number(rawLength);
if (!Number.isSafeInteger(contentLength) || contentLength <= 0) {
throw new Error("Companion stdio Content-Length must be a positive safe integer");
}
if (contentLength > maxContentBytes) {
throw new Error(`Companion stdio frame exceeds ${maxContentBytes} bytes`);
}
const contentType = values.get("content-type")?.toLowerCase();
if (contentType !== undefined
&& contentType !== "application/json"
&& contentType !== "application/json; charset=utf-8") {
throw new Error(`Unsupported companion stdio Content-Type: ${contentType}`);
}
return contentLength;
}
export function encodeContentLengthFrame(
value: JsonValue | RpcMessage | StreamFrame,
): Uint8Array {
let rootKind: JsonValue | undefined;
let rootDataEncoding: JsonValue | undefined;
const serialized = serializeJsonValueWithPropertyObserver(value, (observation) => {
if (observation.depth === 0 && observation.key === "kind") {
rootKind = observation.value;
} else if (observation.depth === 1
&& observation.parentKey === "data"
&& observation.key === "encoding") {
rootDataEncoding = observation.value;
}
});
if (rootKind === "chunk" && rootDataEncoding === "transfer") {
throw new Error("Transfer stream chunks cannot be encoded over companion stdio");
}
const content = encoder.encode(serialized);
if (content.byteLength === 0 || content.byteLength > COMPANION_STDIO_MAX_CONTENT_BYTES) {
throw new Error(
`Companion stdio content must be between 1 and ${COMPANION_STDIO_MAX_CONTENT_BYTES} bytes`,
);
}
const header = encoder.encode(
`Content-Length: ${content.byteLength}\r\nContent-Type: application/json; charset=utf-8\r\n\r\n`,
);
const frame = new Uint8Array(header.byteLength + content.byteLength);
frame.set(header, 0);
frame.set(content, header.byteLength);
return frame;
}
export interface ContentLengthFrameDecoderOptions {
readonly maxHeaderBytes?: number;
readonly maxContentBytes?: number;
}
export class ContentLengthFrameDecoder {
readonly #queue = new ByteQueue();
readonly #maxHeaderBytes: number;
readonly #maxContentBytes: number;
#expectedContentBytes: number | undefined;
constructor(options: ContentLengthFrameDecoderOptions = {}) {
this.#maxHeaderBytes = options.maxHeaderBytes ?? COMPANION_STDIO_MAX_HEADER_BYTES;
this.#maxContentBytes = options.maxContentBytes ?? COMPANION_STDIO_MAX_CONTENT_BYTES;
if (!Number.isInteger(this.#maxHeaderBytes)
|| this.#maxHeaderBytes < 32
|| this.#maxHeaderBytes > ABSOLUTE_MAX_HEADER_BYTES) {
throw new RangeError(
`maxHeaderBytes must be an integer between 32 and ${ABSOLUTE_MAX_HEADER_BYTES}`,
);
}
if (!Number.isInteger(this.#maxContentBytes)
|| this.#maxContentBytes < 1
|| this.#maxContentBytes > COMPANION_STDIO_MAX_CONTENT_BYTES) {
throw new RangeError(
`maxContentBytes must be an integer between 1 and ${COMPANION_STDIO_MAX_CONTENT_BYTES}`,
);
}
}
push(chunk: Uint8Array | string): JsonValue[] {
this.#queue.push(typeof chunk === "string" ? encoder.encode(chunk) : chunk);
const messages: JsonValue[] = [];
while (true) {
if (this.#expectedContentBytes === undefined) {
const separatorIndex = this.#queue.indexOf(
HEADER_SEPARATOR,
this.#maxHeaderBytes + HEADER_SEPARATOR.byteLength,
);
if (separatorIndex === -1) {
const maximumIncompleteHeaderBytes = this.#maxHeaderBytes
+ HEADER_SEPARATOR.byteLength
- 1;
if (this.#queue.byteLength > maximumIncompleteHeaderBytes) {
throw new Error(`Companion stdio header exceeds ${this.#maxHeaderBytes} bytes`);
}
return messages;
}
if (separatorIndex > this.#maxHeaderBytes) {
throw new Error(`Companion stdio header exceeds ${this.#maxHeaderBytes} bytes`);
}
const header = this.#queue.consume(separatorIndex + HEADER_SEPARATOR.byteLength)
.subarray(0, separatorIndex);
this.#expectedContentBytes = parseContentLength(header, this.#maxContentBytes);
}
if (this.#queue.byteLength < this.#expectedContentBytes) return messages;
const content = this.#queue.consume(this.#expectedContentBytes);
this.#expectedContentBytes = undefined;
let value: unknown;
try {
value = JSON.parse(utf8Decoder.decode(content));
} catch (error) {
throw new Error(
`Companion stdio payload is not valid UTF-8 JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
try {
assertJsonValue(value);
} catch (error) {
throw new Error(
`Companion stdio payload is outside the JSON value contract: ${error instanceof Error ? error.message : String(error)}`,
);
}
messages.push(value);
}
}
finish(): void {
if (this.#expectedContentBytes !== undefined || this.#queue.byteLength > 0) {
throw new Error("Companion stdio stream ended with a truncated frame");
}
}
}

View File

@@ -0,0 +1,417 @@
import assert from "node:assert/strict";
import test from "node:test";
import { runInNewContext } from "node:vm";
import {
PLUGIN_JSON_MAX_DEPTH,
PLUGIN_JSON_MAX_NODES,
assertJsonValue,
serializeJsonValue,
} from "./jsonValue.ts";
import {
PLUGIN_STREAM_MAX_CHUNK_BYTES,
PLUGIN_STREAM_MAX_CREDIT_BYTES,
PLUGIN_STREAM_MAX_ID_LENGTH,
PLUGIN_STREAM_MAX_WINDOW_BYTES,
PLUGIN_STREAM_MIN_WINDOW_BYTES,
assertStreamChunkData,
assertStreamFrame,
createBase64StreamChunk,
createJsonStreamChunk,
createMessagePortStreamEnvelope,
materializeStreamChunk,
} from "./streamTransport.ts";
import { PLUGIN_WIRE_MAX_SAFE_INTEGER } from "./generated/plugin-contract-limits.ts";
test("validated JSON serialization matches standard JSON bytes for plain values", () => {
const values = [
null,
true,
false,
0,
-0,
1.25,
1e30,
"quotes \" slashes \\ controls \n unicode 你好",
[],
[null, true, 3, "value", { nested: [1, 2, 3] }],
{ first: 1, second: "two", third: false },
{ 10: "ten", 2: "two", tail: "last" },
];
for (const value of values) {
assert.equal(serializeJsonValue(value), JSON.stringify(value));
}
});
test("JSON validation rejects excessive structural depth and node counts", () => {
let deepValue: unknown = null;
for (let depth = 0; depth <= PLUGIN_JSON_MAX_DEPTH; depth += 1) {
deepValue = [deepValue];
}
assert.throws(
() => assertJsonValue(deepValue),
new RegExp(`must not exceed ${PLUGIN_JSON_MAX_DEPTH} levels`),
);
const wideValue = Array.from({ length: PLUGIN_JSON_MAX_NODES }, () => null);
assert.throws(
() => assertJsonValue(wideValue),
new RegExp(`must not contain more than ${PLUGIN_JSON_MAX_NODES} nodes`),
);
});
test("JSON stream chunks use verified UTF-8 byte accounting", () => {
const chunk = createJsonStreamChunk({ text: "你好" });
assert.equal(chunk.encoding, "json");
assert.equal(chunk.byteLength, new TextEncoder().encode('{"text":"你好"}').byteLength);
assert.deepEqual(materializeStreamChunk(chunk), {
encoding: "json",
value: { text: "你好" },
});
assert.throws(
() => materializeStreamChunk({ ...chunk, byteLength: chunk.byteLength + 1 }),
/JSON byteLength mismatch/,
);
assert.throws(
() => createJsonStreamChunk({ value: Number.NaN } as never),
/JSON numbers must be finite/,
);
assert.throws(
() => createJsonStreamChunk({ value: undefined } as never),
/Unsupported JSON value type/,
);
const sparse = new Array(2) as never;
assert.throws(() => createJsonStreamChunk(sparse), /JSON arrays must be dense/);
const accessor = {} as Record<string, unknown>;
Object.defineProperty(accessor, "value", { enumerable: true, get: () => "unsafe" });
assert.throws(
() => createJsonStreamChunk(accessor as never),
/must not contain accessor properties/,
);
class CustomJsonValue {
readonly value = "validated";
toJSON() {
return { value: "different" };
}
}
assert.throws(
() => createJsonStreamChunk(new CustomJsonValue() as never),
/plain records/,
);
const arrayWithInheritedToJson = ["validated"];
Object.setPrototypeOf(arrayWithInheritedToJson, {
toJSON: () => ["different"],
});
const inheritedToJsonChunk = createJsonStreamChunk(arrayWithInheritedToJson);
assert.equal(inheritedToJsonChunk.byteLength, new TextEncoder().encode('["validated"]').byteLength);
const nullPrototypeValue = Object.assign(Object.create(null), { value: "validated" });
assert.deepEqual(createJsonStreamChunk(nullPrototypeValue), {
encoding: "json",
value: nullPrototypeValue,
byteLength: new TextEncoder().encode('{"value":"validated"}').byteLength,
});
});
test("base64 stream chunks round-trip bytes and reject length or encoding ambiguity", () => {
const bytes = new Uint8Array([0, 1, 2, 127, 128, 253, 254, 255]);
const chunk = createBase64StreamChunk(bytes);
const materialized = materializeStreamChunk(chunk);
assert.equal(materialized.encoding, "binary");
assert.deepEqual(materialized.bytes, bytes);
assert.throws(
() => materializeStreamChunk({ ...chunk, byteLength: bytes.byteLength + 1 }),
/byteLength mismatch/,
);
assert.throws(
() => materializeStreamChunk({ encoding: "base64", value: "not-base64", byteLength: 1 }),
/canonical RFC 4648 base64/,
);
assert.throws(
() => materializeStreamChunk({ encoding: "base64", value: "AB==", byteLength: 1 }),
/canonical RFC 4648 base64/,
);
assert.throws(
() => materializeStreamChunk({
encoding: "base64",
value: "",
byteLength: PLUGIN_STREAM_MAX_CHUNK_BYTES + 1,
}),
/byteLength must be an integer between/,
);
for (let length = 0; length <= 257; length += 1) {
const sample = Uint8Array.from(
{ length },
(_, index) => (length * 17 + index * 31) & 0xff,
);
const roundTrip = materializeStreamChunk(createBase64StreamChunk(sample));
assert.equal(roundTrip.encoding, "binary");
assert.deepEqual(roundTrip.bytes, sample, `base64 length ${length}`);
}
});
test("stream chunk assertions validate inline bytes before accepting frames", () => {
const jsonChunk = createJsonStreamChunk({ text: "你好" });
const base64Chunk = createBase64StreamChunk(new Uint8Array([0, 1, 2, 255]));
assert.doesNotThrow(() => assertStreamChunkData(jsonChunk));
assert.doesNotThrow(() => assertStreamChunkData(base64Chunk));
assert.doesNotThrow(() => assertStreamChunkData({ encoding: "transfer", byteLength: 4 }));
const invalidInlineChunks: readonly [unknown, RegExp][] = [
[{ ...jsonChunk, byteLength: jsonChunk.byteLength + 1 }, /JSON byteLength mismatch/],
[{ ...base64Chunk, byteLength: base64Chunk.byteLength + 1 }, /base64 byteLength mismatch/],
[
{ encoding: "base64", value: "not-base64", byteLength: 1 },
/canonical RFC 4648 base64/,
],
[{ encoding: "base64", value: "AB==", byteLength: 1 }, /canonical RFC 4648 base64/],
];
for (const [data, expectedError] of invalidInlineChunks) {
assert.throws(() => assertStreamChunkData(data), expectedError);
assert.throws(
() => assertStreamFrame({ streamId: "stream-1", sequence: 1, kind: "chunk", data }),
expectedError,
);
}
});
test("MessagePort stream envelopes carry and validate the transferred ArrayBuffer", () => {
const transfer = new Uint8Array([1, 2, 3, 4]).buffer;
const frame = {
streamId: "stream-1",
sequence: 1,
kind: "chunk" as const,
data: { encoding: "transfer" as const, byteLength: 4 },
};
const envelope = createMessagePortStreamEnvelope(frame, transfer);
assert.equal(envelope.transfer, transfer);
const materialized = materializeStreamChunk(frame.data, envelope.transfer);
assert.equal(materialized.encoding, "binary");
assert.deepEqual(materialized.bytes, new Uint8Array([1, 2, 3, 4]));
const crossRealmTransfer = runInNewContext("new ArrayBuffer(4)") as ArrayBuffer;
assert.equal(crossRealmTransfer instanceof ArrayBuffer, false);
const crossRealmMaterialized = materializeStreamChunk(frame.data, crossRealmTransfer);
assert.equal(crossRealmMaterialized.encoding, "binary");
assert.equal(crossRealmMaterialized.bytes.byteLength, 4);
assert.throws(
() => createMessagePortStreamEnvelope(frame),
/require an ArrayBuffer/,
);
assert.throws(
() => createMessagePortStreamEnvelope(frame, { byteLength: 4 } as never),
/require a real, attached ArrayBuffer/,
);
assert.throws(
() => createMessagePortStreamEnvelope(
frame,
{
byteLength: 4,
[Symbol.toStringTag]: "ArrayBuffer",
} as never,
),
/require a real, attached ArrayBuffer/,
);
const detached = new ArrayBuffer(4);
structuredClone(detached, { transfer: [detached] });
assert.throws(
() => createMessagePortStreamEnvelope(
{ ...frame, data: { ...frame.data, byteLength: 0 } },
detached,
),
/require a real, attached ArrayBuffer/,
);
assert.throws(
() => materializeStreamChunk(
{ encoding: "bogus", byteLength: 4 } as never,
transfer,
),
/Unsupported stream chunk encoding/,
);
assert.throws(
() => createMessagePortStreamEnvelope(
{ streamId: "stream-1", sequence: 0, kind: "open", windowBytes: 65_536 },
transfer,
),
/Only transfer-encoded chunk frames/,
);
assert.deepEqual(createMessagePortStreamEnvelope({
streamId: "stream-1",
sequence: PLUGIN_WIRE_MAX_SAFE_INTEGER,
kind: "windowUpdate",
creditBytes: 4096,
}), {
frame: {
streamId: "stream-1",
sequence: PLUGIN_WIRE_MAX_SAFE_INTEGER,
kind: "windowUpdate",
creditBytes: 4096,
},
});
assert.throws(
() => createMessagePortStreamEnvelope({
streamId: "stream-1",
sequence: PLUGIN_WIRE_MAX_SAFE_INTEGER + 1,
kind: "windowUpdate",
creditBytes: 4096,
}),
/sequence must be a safe integer/,
);
assert.doesNotThrow(() => createMessagePortStreamEnvelope({
streamId: "stream-1",
sequence: 0,
kind: "open",
windowBytes: PLUGIN_STREAM_MIN_WINDOW_BYTES,
}));
assert.doesNotThrow(() => createMessagePortStreamEnvelope({
streamId: "stream-1",
sequence: 0,
kind: "open",
windowBytes: PLUGIN_STREAM_MAX_WINDOW_BYTES,
}));
for (const windowBytes of [
0,
PLUGIN_STREAM_MIN_WINDOW_BYTES - 1,
PLUGIN_STREAM_MAX_WINDOW_BYTES + 1,
Number.POSITIVE_INFINITY,
]) {
assert.throws(
() => createMessagePortStreamEnvelope({
streamId: "stream-1",
sequence: 0,
kind: "open",
windowBytes,
}),
/windowBytes must be an integer between|JSON numbers must be finite/,
);
}
assert.doesNotThrow(() => createMessagePortStreamEnvelope({
streamId: "stream-1",
sequence: 0,
kind: "windowUpdate",
creditBytes: PLUGIN_STREAM_MAX_CREDIT_BYTES,
}));
for (const creditBytes of [
0,
PLUGIN_STREAM_MAX_CREDIT_BYTES + 1,
Number.POSITIVE_INFINITY,
]) {
assert.throws(
() => createMessagePortStreamEnvelope({
streamId: "stream-1",
sequence: 0,
kind: "windowUpdate",
creditBytes,
}),
/creditBytes must be an integer between|JSON numbers must be finite/,
);
}
});
test("MessagePort stream envelopes reject frames outside the complete wire schema", () => {
const validError = {
streamId: "stream-1",
sequence: 1,
kind: "error",
error: { code: -32001, message: "cancelled", data: { retryable: false } },
};
assert.doesNotThrow(() => assertStreamFrame(validError));
assert.doesNotThrow(() => createMessagePortStreamEnvelope(validError));
const invalidFrames: readonly [unknown, RegExp][] = [
[null, /plain JSON object/],
[[], /plain JSON object/],
[{ streamId: "", sequence: 1, kind: "end" }, /between 1 and/],
[
{ streamId: "x".repeat(PLUGIN_STREAM_MAX_ID_LENGTH + 1), sequence: 1, kind: "end" },
/between 1 and/,
],
[{ streamId: "stream-1", sequence: 1, kind: "bogus" }, /Unsupported stream frame kind/],
[{ streamId: "stream-1", sequence: 0, kind: "open" }, /missing or unsupported/],
[
{ streamId: "stream-1", sequence: 0, kind: "open", windowBytes: 4096, extra: true },
/missing or unsupported/,
],
[{ streamId: "stream-1", sequence: 1, kind: "chunk", data: null }, /plain JSON object/],
[
{
streamId: "stream-1",
sequence: 1,
kind: "chunk",
data: { encoding: "bogus", byteLength: 0 },
},
/Unsupported stream chunk encoding/,
],
[
{
streamId: "stream-1",
sequence: 1,
kind: "chunk",
data: { encoding: "transfer", byteLength: 0, value: "extra" },
},
/missing or unsupported/,
],
[{ streamId: "stream-1", sequence: 1, kind: "end", data: null }, /missing or unsupported/],
[
{
streamId: "stream-1",
sequence: 1,
kind: "error",
error: { code: -1, message: "bad" },
},
/supported RPC error code/,
],
[
{
streamId: "stream-1",
sequence: 1,
kind: "error",
error: { code: -32001, message: "", data: null },
},
/between 1 and/,
],
[
{
streamId: "stream-1",
sequence: 1,
kind: "error",
error: { code: -32001, message: "bad", extra: true },
},
/missing or unsupported/,
],
[{ streamId: "stream-1", sequence: 0, kind: "cancel" }, /sequence must be a safe integer/],
[
{
streamId: "stream-1",
sequence: 0,
kind: "windowUpdate",
creditBytes: 1,
extra: true,
},
/missing or unsupported/,
],
];
for (const [frame, expectedError] of invalidFrames) {
assert.throws(() => createMessagePortStreamEnvelope(frame), expectedError);
}
let getterRead = false;
const accessorFrame = { streamId: "stream-1", sequence: 1 } as Record<string, unknown>;
Object.defineProperty(accessorFrame, "kind", {
enumerable: true,
get: () => {
getterRead = true;
return "end";
},
});
assert.throws(
() => createMessagePortStreamEnvelope(accessorFrame),
/accessor properties/,
);
assert.equal(getterRead, false);
});

View File

@@ -0,0 +1,436 @@
import type {
JsonValue,
RpcErrorObject,
StreamChunkData,
StreamFrame,
} from "./generated/plugin-contract.js";
import {
PLUGIN_RPC_ERROR_CODES,
PLUGIN_STREAM_MAX_CHUNK_BYTES,
PLUGIN_STREAM_MAX_CREDIT_BYTES,
PLUGIN_STREAM_MAX_ID_LENGTH,
PLUGIN_STREAM_MAX_WINDOW_BYTES,
PLUGIN_STREAM_MIN_WINDOW_BYTES,
PLUGIN_WIRE_MAX_SAFE_INTEGER,
} from "./generated/plugin-contract-limits.js";
import { assertJsonValue, serializeJsonValue } from "./jsonValue.js";
export {
PLUGIN_STREAM_MAX_CHUNK_BYTES,
PLUGIN_STREAM_MAX_CREDIT_BYTES,
PLUGIN_STREAM_MAX_FRAME_JSON_BYTES,
PLUGIN_STREAM_MAX_ID_LENGTH,
PLUGIN_STREAM_MAX_WINDOW_BYTES,
PLUGIN_STREAM_MIN_WINDOW_BYTES,
} from "./generated/plugin-contract-limits.js";
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const BASE64_VALUE = new Map(
[...BASE64_ALPHABET].map((character, index) => [character, index] as const),
);
export interface MessagePortStreamEnvelope {
readonly frame: StreamFrame;
readonly transfer?: ArrayBuffer;
}
const PLUGIN_STREAM_MAX_BASE64_CHARACTERS = 4 * Math.ceil(PLUGIN_STREAM_MAX_CHUNK_BYTES / 3);
const RPC_ERROR_CODES = new Set<number>(PLUGIN_RPC_ERROR_CODES);
export type MaterializedStreamChunk =
| { readonly encoding: "json"; readonly value: JsonValue }
| { readonly encoding: "binary"; readonly bytes: Uint8Array };
const jsonEncoder = new TextEncoder();
const arrayBufferByteLength = Object.getOwnPropertyDescriptor(
ArrayBuffer.prototype,
"byteLength",
)?.get;
function materializeArrayBuffer(value: unknown): Uint8Array {
if (!arrayBufferByteLength) {
throw new TypeError("ArrayBuffer byteLength getter is unavailable");
}
try {
arrayBufferByteLength.call(value);
return new Uint8Array(value as ArrayBuffer);
} catch {
throw new TypeError("Transfer stream chunks require a real, attached ArrayBuffer");
}
}
function serializedJsonByteLength(value: JsonValue): number {
const serialized = serializeJsonValue(value);
return jsonEncoder.encode(serialized).byteLength;
}
function assertChunkByteLength(byteLength: number): void {
if (!Number.isInteger(byteLength)
|| byteLength < 0
|| byteLength > PLUGIN_STREAM_MAX_CHUNK_BYTES) {
throw new RangeError(
`Stream chunk byteLength must be an integer between 0 and ${PLUGIN_STREAM_MAX_CHUNK_BYTES}`,
);
}
}
type JsonRecord = Record<string, JsonValue>;
function readJsonRecord(value: unknown, label: string): JsonRecord {
assertJsonValue(value);
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${label} must be a plain JSON object`);
}
const record: JsonRecord = Object.create(null) as JsonRecord;
for (const key of Object.keys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor || !("value" in descriptor)) {
throw new TypeError(`${label} must contain data properties only`);
}
record[key] = descriptor.value as JsonValue;
}
return record;
}
function assertExactKeys(
record: JsonRecord,
expectedKeys: readonly string[],
label: string,
): void {
const actualKeys = Object.keys(record);
if (actualKeys.length !== expectedKeys.length
|| expectedKeys.some((key) => !Object.hasOwn(record, key))) {
throw new TypeError(`${label} has missing or unsupported properties`);
}
}
function assertBoundedString(
value: JsonValue | undefined,
minimum: number,
maximum: number,
label: string,
): asserts value is string {
if (typeof value !== "string") {
throw new TypeError(`${label} must be a string`);
}
const length = [...value].length;
if (length < minimum || length > maximum) {
throw new RangeError(`${label} must contain between ${minimum} and ${maximum} characters`);
}
}
function parseRpcErrorObject(value: unknown): RpcErrorObject {
const error = readJsonRecord(value, "Stream error");
const expectedKeys = Object.hasOwn(error, "data")
? ["code", "message", "data"]
: ["code", "message"];
assertExactKeys(error, expectedKeys, "Stream error");
if (typeof error.code !== "number"
|| !Number.isInteger(error.code)
|| !RPC_ERROR_CODES.has(error.code)) {
throw new RangeError("Stream error code is not a supported RPC error code");
}
assertBoundedString(error.message, 1, 2048, "Stream error message");
return Object.hasOwn(error, "data")
? {
code: error.code as RpcErrorObject["code"],
message: error.message,
data: error.data ?? null,
}
: { code: error.code as RpcErrorObject["code"], message: error.message };
}
function parseStreamChunkDataShape(value: unknown): StreamChunkData {
const data = readJsonRecord(value, "Stream chunk data");
if (data.encoding === "json") {
assertExactKeys(data, ["encoding", "value", "byteLength"], "JSON stream chunk data");
if (typeof data.byteLength !== "number") {
throw new TypeError("Stream chunk byteLength must be a number");
}
assertChunkByteLength(data.byteLength);
return { encoding: "json", value: data.value ?? null, byteLength: data.byteLength };
} else if (data.encoding === "base64") {
assertExactKeys(data, ["encoding", "value", "byteLength"], "Base64 stream chunk data");
if (typeof data.value !== "string") {
throw new TypeError("Base64 stream chunk value must be a string");
}
if (typeof data.byteLength !== "number") {
throw new TypeError("Stream chunk byteLength must be a number");
}
assertChunkByteLength(data.byteLength);
return { encoding: "base64", value: data.value, byteLength: data.byteLength };
} else if (data.encoding === "transfer") {
assertExactKeys(data, ["encoding", "byteLength"], "Transfer stream chunk data");
if (typeof data.byteLength !== "number") {
throw new TypeError("Stream chunk byteLength must be a number");
}
assertChunkByteLength(data.byteLength);
return { encoding: "transfer", byteLength: data.byteLength };
} else {
throw new TypeError("Unsupported stream chunk encoding");
}
}
function assertInlineStreamChunkBytes(data: StreamChunkData): void {
if (data.encoding === "json") {
const byteLength = serializedJsonByteLength(data.value);
if (byteLength !== data.byteLength) {
throw new Error(
`Stream JSON byteLength mismatch: declared ${data.byteLength}, encoded ${byteLength}`,
);
}
} else if (data.encoding === "base64") {
decodeValidatedBase64(data.value, data.byteLength);
}
}
function parseStreamChunkData(value: unknown): StreamChunkData {
const data = parseStreamChunkDataShape(value);
assertInlineStreamChunkBytes(data);
return data;
}
export function assertStreamChunkData(value: unknown): asserts value is StreamChunkData {
parseStreamChunkData(value);
}
function assertStreamSequence(kind: StreamFrame["kind"], sequence: number): void {
const minimum = kind === "open" || kind === "windowUpdate" ? 0 : 1;
if (!Number.isSafeInteger(sequence)
|| sequence < minimum
|| sequence > PLUGIN_WIRE_MAX_SAFE_INTEGER
|| (kind === "open" && sequence !== 0)) {
const expected = kind === "open"
? "exactly 0"
: `a safe integer between ${minimum} and ${PLUGIN_WIRE_MAX_SAFE_INTEGER}`;
throw new RangeError(`Stream ${kind} sequence must be ${expected}`);
}
}
function assertStreamWindowBytes(windowBytes: number): void {
if (!Number.isInteger(windowBytes)
|| windowBytes < PLUGIN_STREAM_MIN_WINDOW_BYTES
|| windowBytes > PLUGIN_STREAM_MAX_WINDOW_BYTES) {
throw new RangeError(
`Stream open windowBytes must be an integer between ${PLUGIN_STREAM_MIN_WINDOW_BYTES} and ${PLUGIN_STREAM_MAX_WINDOW_BYTES}`,
);
}
}
function assertStreamCreditBytes(creditBytes: number): void {
if (!Number.isInteger(creditBytes)
|| creditBytes < 1
|| creditBytes > PLUGIN_STREAM_MAX_CREDIT_BYTES) {
throw new RangeError(
`Stream windowUpdate creditBytes must be an integer between 1 and ${PLUGIN_STREAM_MAX_CREDIT_BYTES}`,
);
}
}
function parseStreamFrame(value: unknown): StreamFrame {
const frame = readJsonRecord(value, "Stream frame");
assertBoundedString(frame.streamId, 1, PLUGIN_STREAM_MAX_ID_LENGTH, "Stream frame streamId");
if (typeof frame.kind !== "string") {
throw new TypeError("Stream frame kind must be a string");
}
if (typeof frame.sequence !== "number") {
throw new TypeError("Stream frame sequence must be a number");
}
switch (frame.kind) {
case "open": {
assertExactKeys(frame, ["streamId", "sequence", "kind", "windowBytes"], "Open stream frame");
if (typeof frame.windowBytes !== "number") {
throw new TypeError("Stream open windowBytes must be a number");
}
assertStreamSequence(frame.kind, frame.sequence);
assertStreamWindowBytes(frame.windowBytes);
return {
streamId: frame.streamId,
sequence: 0,
kind: "open",
windowBytes: frame.windowBytes,
};
}
case "chunk": {
assertExactKeys(frame, ["streamId", "sequence", "kind", "data"], "Chunk stream frame");
assertStreamSequence(frame.kind, frame.sequence);
return {
streamId: frame.streamId,
sequence: frame.sequence,
kind: "chunk",
data: parseStreamChunkData(frame.data),
};
}
case "end":
case "cancel": {
assertExactKeys(frame, ["streamId", "sequence", "kind"], `${frame.kind} stream frame`);
assertStreamSequence(frame.kind, frame.sequence);
return { streamId: frame.streamId, sequence: frame.sequence, kind: frame.kind };
}
case "error": {
assertExactKeys(frame, ["streamId", "sequence", "kind", "error"], "Error stream frame");
assertStreamSequence(frame.kind, frame.sequence);
return {
streamId: frame.streamId,
sequence: frame.sequence,
kind: "error",
error: parseRpcErrorObject(frame.error),
};
}
case "windowUpdate": {
assertExactKeys(
frame,
["streamId", "sequence", "kind", "creditBytes"],
"Window-update stream frame",
);
if (typeof frame.creditBytes !== "number") {
throw new TypeError("Stream windowUpdate creditBytes must be a number");
}
assertStreamSequence(frame.kind, frame.sequence);
assertStreamCreditBytes(frame.creditBytes);
return {
streamId: frame.streamId,
sequence: frame.sequence,
kind: "windowUpdate",
creditBytes: frame.creditBytes,
};
}
default:
throw new TypeError(`Unsupported stream frame kind: ${frame.kind}`);
}
}
export function assertStreamFrame(value: unknown): asserts value is StreamFrame {
parseStreamFrame(value);
}
function encodeBase64(bytes: Uint8Array): string {
let output = "";
for (let offset = 0; offset < bytes.byteLength; offset += 3) {
const first = bytes[offset];
const hasSecond = offset + 1 < bytes.byteLength;
const hasThird = offset + 2 < bytes.byteLength;
const second = hasSecond ? bytes[offset + 1] : 0;
const third = hasThird ? bytes[offset + 2] : 0;
output += BASE64_ALPHABET[first >> 2];
output += BASE64_ALPHABET[((first & 0x03) << 4) | (second >> 4)];
output += hasSecond
? BASE64_ALPHABET[((second & 0x0f) << 2) | (third >> 6)]
: "=";
output += hasThird ? BASE64_ALPHABET[third & 0x3f] : "=";
}
return output;
}
function decodeBase64(value: string): Uint8Array {
if (value.length > PLUGIN_STREAM_MAX_BASE64_CHARACTERS) {
throw new RangeError(
`Stream base64 data exceeds ${PLUGIN_STREAM_MAX_BASE64_CHARACTERS} characters`,
);
}
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
throw new Error("Stream base64 data is not canonical RFC 4648 base64");
}
if (value.length === 0) return new Uint8Array(0);
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
const output = new Uint8Array((value.length / 4) * 3 - padding);
let outputOffset = 0;
for (let offset = 0; offset < value.length; offset += 4) {
const first = BASE64_VALUE.get(value[offset]) ?? 0;
const second = BASE64_VALUE.get(value[offset + 1]) ?? 0;
const third = BASE64_VALUE.get(value[offset + 2]) ?? 0;
const fourth = BASE64_VALUE.get(value[offset + 3]) ?? 0;
if (outputOffset < output.byteLength) output[outputOffset++] = (first << 2) | (second >> 4);
if (outputOffset < output.byteLength) output[outputOffset++] = (second << 4) | (third >> 2);
if (outputOffset < output.byteLength) output[outputOffset++] = (third << 6) | fourth;
}
if (encodeBase64(output) !== value) {
throw new Error("Stream base64 data is not canonical RFC 4648 base64");
}
return output;
}
function decodeValidatedBase64(value: string, declaredByteLength: number): Uint8Array {
const bytes = decodeBase64(value);
if (bytes.byteLength !== declaredByteLength) {
throw new Error(
`Stream base64 byteLength mismatch: declared ${declaredByteLength}, decoded ${bytes.byteLength}`,
);
}
return bytes;
}
export function createBase64StreamChunk(bytes: Uint8Array): StreamChunkData {
assertChunkByteLength(bytes.byteLength);
return {
encoding: "base64",
value: encodeBase64(bytes),
byteLength: bytes.byteLength,
};
}
export function createJsonStreamChunk(value: JsonValue): StreamChunkData {
const byteLength = serializedJsonByteLength(value);
assertChunkByteLength(byteLength);
return {
encoding: "json",
value,
byteLength,
};
}
function materializeValidatedStreamChunk(
data: StreamChunkData,
transfer?: ArrayBuffer,
): MaterializedStreamChunk {
if (data.encoding === "json") {
if (transfer !== undefined) {
throw new Error("JSON stream chunks must not include a transferable buffer");
}
assertInlineStreamChunkBytes(data);
return { encoding: "json", value: data.value };
}
if (data.encoding === "base64") {
if (transfer !== undefined) {
throw new Error("Base64 stream chunks must not include a transferable buffer");
}
const bytes = decodeValidatedBase64(data.value, data.byteLength);
return { encoding: "binary", bytes };
}
if (transfer === undefined) {
throw new Error("Transfer stream chunks require an ArrayBuffer in the message envelope");
}
const bytes = materializeArrayBuffer(transfer);
if (bytes.byteLength !== data.byteLength) {
throw new Error(
`Stream transfer byteLength mismatch: declared ${data.byteLength}, received ${bytes.byteLength}`,
);
}
return { encoding: "binary", bytes };
}
export function materializeStreamChunk(
data: unknown,
transfer?: ArrayBuffer,
): MaterializedStreamChunk {
return materializeValidatedStreamChunk(parseStreamChunkDataShape(data), transfer);
}
export function createMessagePortStreamEnvelope(
frame: unknown,
transfer?: ArrayBuffer,
): MessagePortStreamEnvelope {
const validatedFrame = parseStreamFrame(frame);
if (validatedFrame.kind === "chunk") {
if (validatedFrame.data.encoding === "transfer") {
materializeValidatedStreamChunk(validatedFrame.data, transfer);
} else if (transfer !== undefined) {
throw new Error("Only transfer-encoded chunk frames may include an ArrayBuffer");
}
} else if (transfer !== undefined) {
throw new Error("Only transfer-encoded chunk frames may include an ArrayBuffer");
}
return transfer === undefined
? { frame: validatedFrame }
: { frame: validatedFrame, transfer };
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}

View File

@@ -0,0 +1,20 @@
{
"name": "@netcatty/plugin-sdk",
"version": "0.1.0-internal",
"private": true,
"type": "module",
"license": "GPL-3.0-or-later",
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.build.json"
},
"dependencies": {
"@netcatty/plugin-contract": "0.1.0-internal"
}
}

View File

@@ -0,0 +1,397 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import * as ts from "typescript";
import {
CancellationError,
CancellationTokenSource,
definePlugin,
DisposableStore,
PluginError,
PLUGIN_ERROR_WIRE_CODES,
pluginErrorToRpcError,
throwIfCancellationRequested,
} from "./index.ts";
import type { PluginSecretStore, SecretRef } from "./index.ts";
const testSecretRef: SecretRef = {
kind: "secret",
id: "secret-reference-1",
key: "token",
};
const testSecretStore: PluginSecretStore = {
async get() {
return testSecretRef;
},
async set() {
return testSecretRef;
},
async delete() {},
};
function assertSdkTypeChecks(source: string) {
const sdkDirectory = dirname(fileURLToPath(import.meta.url));
const fixturePath = join(sdkDirectory, "__provider-overload-fixture.ts");
const compilerOptions: ts.CompilerOptions = {
allowImportingTsExtensions: true,
module: ts.ModuleKind.NodeNext,
moduleResolution: ts.ModuleResolutionKind.NodeNext,
noEmit: true,
skipLibCheck: true,
strict: true,
target: ts.ScriptTarget.ES2022,
};
const host = ts.createCompilerHost(compilerOptions, true);
const fileExists = host.fileExists.bind(host);
const readCompilerFile = host.readFile.bind(host);
host.fileExists = (fileName) => fileName === fixturePath || fileExists(fileName);
host.readFile = (fileName) => fileName === fixturePath ? source : readCompilerFile(fileName);
const program = ts.createProgram([fixturePath], compilerOptions, host);
const diagnostics = ts.getPreEmitDiagnostics(program)
.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
assert.deepEqual(
diagnostics.map((diagnostic) => {
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
if (!diagnostic.file || diagnostic.start === undefined) {
return `TS${diagnostic.code}: ${message}`;
}
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
return `${diagnostic.file.fileName}:${line + 1}:${character + 1} TS${diagnostic.code}: ${message}`;
}),
[],
);
}
test("PluginError maps stable SDK codes to stable JSON-RPC wire errors", () => {
const error = new PluginError("permission_denied", "Approval required", { scope: "terminal" });
assert.deepEqual(pluginErrorToRpcError(error), {
code: -32007,
message: "Approval required",
data: {
pluginCode: "permission_denied",
details: { scope: "terminal" },
},
});
assert.equal(PLUGIN_ERROR_WIRE_CODES.cancelled, -32001);
assert.equal(PLUGIN_ERROR_WIRE_CODES.internal, -32013);
assert.equal(new Set(Object.values(PLUGIN_ERROR_WIRE_CODES)).size, 16);
for (const code of Object.keys(PLUGIN_ERROR_WIRE_CODES)) {
const mapped = pluginErrorToRpcError(new PluginError(
code as keyof typeof PLUGIN_ERROR_WIRE_CODES,
code,
));
assert.equal(mapped.code, PLUGIN_ERROR_WIRE_CODES[code as keyof typeof PLUGIN_ERROR_WIRE_CODES]);
assert.deepEqual(mapped.data, { pluginCode: code });
}
});
test("PluginError wire mapping covers the exact contract schema enums", async () => {
const schema = JSON.parse(await readFile(
new URL("../../plugin-contract/schema/plugin-contract.schema.json", import.meta.url),
"utf8",
));
assert.deepEqual(
Object.keys(PLUGIN_ERROR_WIRE_CODES).sort(),
[...schema.$defs.PluginErrorName.enum].sort(),
);
assert.deepEqual(
Object.values(PLUGIN_ERROR_WIRE_CODES).sort((left, right) => left - right),
[...schema.$defs.PluginWireErrorCode.enum].sort((left, right) => left - right),
);
});
test("definePlugin preserves the exact plugin object", () => {
const plugin = definePlugin({ activate() {} });
assert.equal(typeof plugin.activate, "function");
});
test("PluginSecretStore exposes opaque references instead of plaintext reads", async () => {
assert.deepEqual(await testSecretStore.get("token"), testSecretRef);
assert.deepEqual(await testSecretStore.set("token", "already-known-value"), testSecretRef);
assert.equal("value" in testSecretRef, false);
assert.equal(testSecretRef.key, "token");
});
test("terminal interceptor typing stays specialized while broad ProviderKind helpers remain compatible", async () => {
const source = await readFile(new URL("./index.ts", import.meta.url), "utf8");
assert.match(
source,
/kind: Exclude<\s*ProviderKind,\s*TerminalInterceptorKind \| OrdinaryTerminalProviderKind \| "connection" \| "authentication" \| "importer" \| "sync"\s*>,\s*handler: PluginProviderHandler/u,
);
assert.match(
source,
/type ProviderHandlerForKind<[\s\S]*K extends TerminalInterceptorKind[\s\S]*TerminalInterceptorHandler/u,
);
assert.match(
source,
/kind: K,\s*handler: ProviderHandlerForKind<NoInfer<K>, TPayload, TResult>/u,
);
});
test("provider registrations infer typed connection importer and sync stream invocations", () => {
assertSdkTypeChecks(`
import { definePlugin } from "./index.ts";
import type {
ConnectionProviderHandler,
ConnectionProviderResultByOperation,
AuthenticationResult,
ImporterKeyDraft,
ImporterProviderHandler,
SyncProviderHandler,
SyncProviderResultByOperation,
} from "./index.ts";
const resizeAck: ConnectionProviderResultByOperation["resize"] = null;
void resizeAck;
// @ts-expect-error connection control operations acknowledge with JSON null, never object payloads.
const invalidResizeAck: ConnectionProviderResultByOperation["resize"] = { ok: true };
void invalidResizeAck;
const invalidConnectionProvider: ConnectionProviderHandler = {
validateConfiguration: () => ({ valid: true, issues: [] }),
probe: () => ({ available: true }),
open: () => ({ connectionId: "connection-1", status: "connected" }),
// @ts-expect-error resize must return the resize control acknowledgement, not a probe result.
resize: () => ({ available: true }),
signal: () => null,
reconnect: () => null,
close: () => null,
getStatus: () => ({ status: "connected" }),
};
void invalidConnectionProvider;
const inlineImporterKey: ImporterKeyDraft = {
label: "Inline key",
type: "ED25519",
privateKey: "private",
};
const fileImporterKey: ImporterKeyDraft = {
label: "File key",
type: "ED25519",
filePath: "/keys/id_ed25519",
};
void inlineImporterKey;
void fileImporterKey;
// @ts-expect-error runtime validation requires exactly one key source.
const ambiguousImporterKey: ImporterKeyDraft = {
label: "Ambiguous key",
type: "ED25519",
privateKey: "private",
filePath: "/keys/id_ed25519",
};
void ambiguousImporterKey;
const invalidImporterProvider: ImporterProviderHandler = {
// @ts-expect-error detect must return a detection result, not parse counters.
detect: () => ({ parsed: 0, warnings: 0, errors: 0 }),
parse: () => ({ parsed: 0, warnings: 0, errors: 0 }),
};
void invalidImporterProvider;
const disconnectAck: SyncProviderResultByOperation["disconnect"] = null;
void disconnectAck;
// @ts-expect-error disconnect acknowledges with JSON null.
const invalidDisconnectAck: SyncProviderResultByOperation["disconnect"] = { ok: true };
void invalidDisconnectAck;
const invalidSyncProvider: SyncProviderHandler = {
connect: () => ({ account: { id: "a" } }),
disconnect: () => null,
getAccount: () => ({ account: null }),
getCapabilities: () => ({ revisions: true, conditionalWrites: true, atomicReplacement: true }),
// @ts-expect-error readObject must return a SyncReadObjectResult, not write result.
readObject: () => ({ created: true }),
writeObject: () => ({ created: true }),
deleteObject: () => ({ deleted: true }),
};
void invalidSyncProvider;
// @ts-expect-error challenge results must include the exact challenge payload.
const incompleteAuthenticationResult: AuthenticationResult = { status: "challenge" };
void incompleteAuthenticationResult;
definePlugin({
activate(context) {
context.providers.register("com.example.connection", "connection", {
async open(invocation) {
const input = await invocation.input;
const chunk: Uint8Array | null = await input.read();
if (chunk) {
await invocation.output.write(chunk);
}
await invocation.output.end();
return { connectionId: "connection-1", status: "connected" };
},
validateConfiguration(invocation) {
const configuration = invocation.payload.configuration;
void configuration;
return { valid: true, issues: [] };
},
probe() {
return { available: true };
},
resize() {
return null;
},
signal() {
return null;
},
reconnect() {
return null;
},
close() {
return null;
},
getStatus() {
return {
status: "connected",
diagnostics: [{ severity: "warning", message: "using fallback host key algorithm" }],
};
},
});
// @ts-expect-error connection Providers use operation-keyed handlers so each operation has its exact result.
context.providers.register("com.example.connection.invalid", "connection", async () => ({ available: true }));
context.providers.register("com.example.importer", "importer", {
async parse(invocation) {
const input = await invocation.input;
await invocation.output.write(new Uint8Array([65]));
await input.read();
return { parsed: 0, warnings: 0, errors: 0 };
},
detect(invocation) {
const sampleData: string = invocation.payload.sample.data;
void sampleData;
return { confidence: 1 };
},
});
context.providers.register("com.example.sync", "sync", {
connect(invocation) {
void invocation.payload.configuration;
return { account: { id: "acct" } };
},
disconnect() {
return null;
},
getAccount() {
return { account: { id: "acct" } };
},
getCapabilities() {
return {
revisions: true,
conditionalWrites: true,
atomicReplacement: true,
maxObjectBytes: 1024,
};
},
async readObject(invocation) {
if (invocation.output) {
await invocation.output.write(new Uint8Array([1, 2, 3]));
await invocation.output.end();
return { found: true, byteLength: 3, streamed: true, revision: "r1" };
}
return {
found: true,
byteLength: 3,
encoding: "base64",
data: "AQID",
revision: "r1",
};
},
async writeObject(invocation) {
if (invocation.input) {
const stream = await invocation.input;
await stream.read();
}
return { created: true, revision: "r2" };
},
deleteObject() {
return { deleted: true };
},
});
// @ts-expect-error sync Providers use operation-keyed handlers.
context.providers.register("com.example.sync.invalid", "sync", async () => ({ account: { id: "x" } }));
},
});
`);
});
test("DisposableStore disposes every item once", () => {
const store = new DisposableStore();
const calls: string[] = [];
store.add({ dispose: () => calls.push("first") });
store.add({ dispose: () => calls.push("second") });
store.dispose();
store.dispose();
assert.deepEqual(calls, ["first", "second"]);
});
test("DisposableStore disposes rejected late additions", () => {
const store = new DisposableStore();
store.dispose();
let disposed = false;
assert.throws(
() => store.add({ dispose: () => { disposed = true; } }),
(error) => error instanceof PluginError && error.code === "unavailable",
);
assert.equal(disposed, true);
});
test("CancellationTokenSource notifies listeners once", () => {
const source = new CancellationTokenSource();
let count = 0;
source.token.onCancellationRequested(() => count += 1);
source.cancel();
source.cancel();
assert.equal(count, 1);
assert.equal(source.token.isCancellationRequested, true);
assert.throws(
() => throwIfCancellationRequested(source.token),
CancellationError,
);
});
test("CancellationTokenSource notifies every listener before reporting failures", () => {
const source = new CancellationTokenSource();
const calls: string[] = [];
source.token.onCancellationRequested(() => {
calls.push("failing");
throw new Error("listener failed");
});
source.token.onCancellationRequested(() => calls.push("surviving"));
assert.throws(
() => source.cancel(),
(error) => error instanceof AggregateError
&& error.errors.length === 1
&& error.errors[0] instanceof Error
&& error.errors[0].message === "listener failed",
);
assert.deepEqual(calls, ["failing", "surviving"]);
assert.equal(source.token.isCancellationRequested, true);
assert.doesNotThrow(() => source.cancel());
});
test("CancellationTokenSource finishes disposal when a cancellation listener fails", () => {
const source = new CancellationTokenSource();
source.token.onCancellationRequested(() => {
throw new Error("listener failed");
});
assert.throws(() => source.dispose(true), AggregateError);
assert.doesNotThrow(() => source.dispose(true));
});

View File

@@ -0,0 +1,924 @@
import type {
AuthenticationBeginPayload,
AuthenticationResponsePayload,
AuthenticationResult,
ConnectionConfigurationPayload,
ConnectionControlResult,
ConnectionControlPayload,
ConnectionOpenPayload,
ConnectionOpenResult,
ConnectionProbeResult,
ConnectionResizePayload,
ConnectionSignalPayload,
ConnectionStatusResult,
ConnectionValidateResult,
CredentialRef,
FeatureId,
ImporterDetectPayload,
ImporterDetectResult,
ImporterParsePayload,
ImporterParseResult,
JsonValue,
PluginErrorData,
PluginErrorName,
PluginId,
ProviderKind,
PluginWireErrorCode,
RpcErrorObject,
SecretLeaseRef,
SecretRef,
SemanticVersion,
SyncCapabilitiesResult,
SyncConnectPayload,
SyncConnectResult,
SyncDeleteObjectPayload,
SyncDeleteObjectResult,
SyncDisconnectPayload,
SyncDisconnectResult,
SyncGetAccountPayload,
SyncGetAccountResult,
SyncGetCapabilitiesPayload,
SyncReadObjectPayload,
SyncReadObjectResult,
SyncWriteObjectPayload,
SyncWriteObjectResult,
TerminalSessionSnapshot,
} from "@netcatty/plugin-contract";
export type * from "@netcatty/plugin-contract";
export interface Disposable {
dispose(): void;
}
export type CancellationListener = () => void;
export interface CancellationToken {
readonly isCancellationRequested: boolean;
onCancellationRequested(listener: CancellationListener): Disposable;
}
export interface PluginLogger {
debug(message: string, fields?: Readonly<Record<string, JsonValue>>): void;
info(message: string, fields?: Readonly<Record<string, JsonValue>>): void;
warn(message: string, fields?: Readonly<Record<string, JsonValue>>): void;
error(message: string, fields?: Readonly<Record<string, JsonValue>>): void;
}
export interface PluginKeyValueStore {
get<T extends JsonValue>(key: string): Promise<T | undefined>;
set(key: string, value: JsonValue): Promise<void>;
delete(key: string): Promise<void>;
keys(): Promise<readonly string[]>;
}
export interface PluginSecretStore {
get(key: string): Promise<SecretRef | undefined>;
set(key: string, value: string): Promise<SecretRef>;
delete(key: string): Promise<void>;
}
export interface PluginSettingOptions {
readonly scopeId?: string;
}
export interface PluginSettingChangeEvent {
readonly settingId: string;
readonly scope: string;
readonly scopeId: string;
readonly source: "host" | "plugin";
}
export interface PluginSettings {
get<T extends JsonValue | SecretRef>(settingId: string, options?: PluginSettingOptions): Promise<T | undefined>;
update(settingId: string, value: JsonValue, options?: PluginSettingOptions): Promise<Readonly<{ restartRequired: boolean }>>;
onDidChange(listener: (event: PluginSettingChangeEvent) => void): Disposable;
}
export interface PluginCommandInvocation {
readonly source: "host" | "plugin" | string;
readonly context?: Readonly<Record<string, JsonValue>>;
}
export type PluginCommandHandler = (args: JsonValue | undefined, invocation: PluginCommandInvocation) => JsonValue | void | Promise<JsonValue | void>;
export interface PluginCommands {
registerCommand(commandId: string, handler: PluginCommandHandler): Disposable;
executeCommand<T extends JsonValue = JsonValue>(commandId: string, args?: JsonValue): Promise<T>;
}
export interface PluginContextKeys {
set(key: string, value: JsonValue): Promise<void>;
}
export interface PluginViews {
onDidReceiveMessage(viewId: string, listener: (message: JsonValue) => void): Disposable;
postMessage(viewId: string, message: JsonValue): void;
getState<T extends JsonValue = JsonValue>(viewId: string, scopeId: string): Promise<T | undefined>;
setState(viewId: string, scopeId: string, state: JsonValue): Promise<void>;
}
export interface PluginProviderInvocation<TPayload extends JsonValue = JsonValue> {
readonly providerId: string;
readonly kind: ProviderKind;
readonly operation: string;
readonly requestId: string;
readonly payload: TPayload | undefined;
readonly deadlineMs: number | undefined;
readonly cancellationToken: CancellationToken;
}
export type PluginProviderHandler<
TPayload extends JsonValue = JsonValue,
TResult extends JsonValue = JsonValue,
> = (invocation: PluginProviderInvocation<TPayload>) => TResult | void | Promise<TResult | void>;
type TypedPluginProviderInvocation<TPayload> = Omit<PluginProviderInvocation, "payload"> & {
readonly payload: TPayload;
};
type ProviderHandlerForKind<
K extends ProviderKind,
TPayload extends JsonValue,
TResult extends JsonValue,
> = K extends TerminalInterceptorKind
? TerminalInterceptorHandler
: K extends OrdinaryTerminalProviderKind
? OrdinaryTerminalProviderHandler<K>
: K extends "connection"
? ConnectionProviderHandler
: K extends "authentication"
? AuthenticationProviderHandler
: K extends "importer"
? ImporterProviderHandler
: K extends "sync"
? SyncProviderHandler
: PluginProviderHandler<TPayload, TResult>;
export interface PluginProviders {
register<K extends OrdinaryTerminalProviderKind>(
providerId: string,
kind: K,
handler: OrdinaryTerminalProviderHandler<K>,
): Disposable;
register(
providerId: string,
kind: TerminalInterceptorKind,
handler: TerminalInterceptorHandler,
): Disposable;
register(
providerId: string,
kind: "connection",
handler: ConnectionProviderHandler,
): Disposable;
register(
providerId: string,
kind: "authentication",
handler: AuthenticationProviderHandler,
): Disposable;
register(
providerId: string,
kind: "importer",
handler: ImporterProviderHandler,
): Disposable;
register(
providerId: string,
kind: "sync",
handler: SyncProviderHandler,
): Disposable;
register<TPayload extends JsonValue = JsonValue, TResult extends JsonValue = JsonValue>(
providerId: string,
kind: Exclude<
ProviderKind,
TerminalInterceptorKind | OrdinaryTerminalProviderKind | "connection" | "authentication" | "importer" | "sync"
>,
handler: PluginProviderHandler<TPayload, TResult>,
): Disposable;
register<
K extends ProviderKind,
TPayload extends JsonValue = JsonValue,
TResult extends JsonValue = JsonValue,
>(
providerId: string,
kind: K,
handler: ProviderHandlerForKind<NoInfer<K>, TPayload, TResult>,
): Disposable;
}
export type TerminalInterceptorKind = "terminal.interceptor.input" | "terminal.interceptor.output";
export interface TerminalInterceptorInvocation {
readonly providerId: string;
readonly kind: TerminalInterceptorKind;
readonly direction: "input" | "output";
readonly sequence: number;
readonly session: TerminalSessionSnapshot;
/** UTF-8 terminal data. The buffer is owned by this invocation. */
readonly data: Uint8Array;
}
export type TerminalInterceptorHandler = (
invocation: TerminalInterceptorInvocation,
) => Uint8Array | ArrayBuffer | Promise<Uint8Array | ArrayBuffer>;
export interface TerminalSessionEvent {
readonly type:
| "snapshot"
| "created"
| "connected"
| "reconnected"
| "cwdChanged"
| "titleChanged"
| "resized"
| "alternateScreenChanged"
| "commandSubmitted"
| "commandCompleted"
| "disconnected"
| "disposed";
readonly session: TerminalSessionSnapshot;
readonly exitCode?: number;
}
export interface TerminalProviderPayload {
/** Immutable host snapshot bound to this exact invocation. */
readonly session: TerminalSessionSnapshot;
}
export interface TerminalCompletionPayload extends TerminalProviderPayload {
readonly input: string;
readonly cursor: number;
readonly hostOs: "linux" | "windows" | "macos";
readonly cwdSource: "prompt" | "fallback" | "none" | null;
readonly maximum: number;
}
export interface TerminalCompletionItem {
readonly text: string;
/** When supplied, it must equal text; the host always displays the inserted command. */
readonly displayText?: string;
readonly description?: string;
readonly score?: number;
}
export interface TerminalCompletionResult {
readonly items: readonly TerminalCompletionItem[];
}
export interface TerminalDecorationPayload extends TerminalProviderPayload {
readonly reason: string;
}
export interface TerminalDecorationRule {
readonly id: string;
readonly label: string;
readonly patterns: readonly string[];
readonly color: string;
}
export interface TerminalDecorationResult {
readonly rules: readonly TerminalDecorationRule[];
}
export interface TerminalTextRange {
readonly start: number;
readonly length: number;
}
export interface TerminalLinkItem extends TerminalTextRange {
readonly uri: string;
readonly label?: string;
}
export interface TerminalLineProviderPayload extends TerminalProviderPayload {
readonly line: string;
readonly bufferLineNumber: number;
}
export interface TerminalLinkResult {
readonly links: readonly TerminalLinkItem[];
}
export interface TerminalHoverItem extends TerminalTextRange {
readonly contents: string;
}
export interface TerminalHoverResult {
readonly hovers: readonly TerminalHoverItem[];
}
export interface TerminalMatcherLine {
readonly lineId: string;
readonly line: string;
readonly bufferLineNumber: number;
}
export interface TerminalMatcherPayload extends TerminalProviderPayload {
readonly lines: readonly TerminalMatcherLine[];
}
export interface TerminalOutputMatchItem extends TerminalTextRange {
/** Host-provided line identifier from the provideMatches request batch. */
readonly lineId: string;
readonly label: string;
readonly severity?: "info" | "warning" | "error" | "success";
readonly color?: string;
}
export interface TerminalMatcherResult {
readonly matches: readonly TerminalOutputMatchItem[];
}
export interface TerminalAnnotationItem {
readonly text: string;
readonly color?: string;
}
export interface TerminalSemanticResult {
readonly classification?: string;
readonly description?: string;
readonly destructive?: boolean;
readonly idempotent?: boolean;
readonly annotations?: readonly TerminalAnnotationItem[];
}
export interface TerminalSemanticPayload extends TerminalProviderPayload {
readonly command: string;
}
export interface TerminalPromptPayload extends TerminalProviderPayload {
readonly reason: "commandCompleted";
readonly promptLine?: string;
readonly bufferLineNumber?: number;
}
export interface TerminalPromptResult {
readonly annotations: readonly TerminalAnnotationItem[];
}
export interface TerminalBackgroundLayer {
readonly id: string;
readonly color: string;
/** Defaults to a host-owned safe opacity of 0.15. */
readonly opacity?: number;
}
export interface TerminalBackgroundResult {
readonly layers: readonly TerminalBackgroundLayer[];
/** Optional bounded host refresh cadence. The host clamps this to 250-60000 ms. */
readonly refreshAfterMs?: number;
}
export interface TerminalBackgroundPayload extends TerminalProviderPayload {
readonly reason: string;
readonly terminalBackground?: string;
}
export type TerminalThemeColorName =
| "background" | "foreground" | "cursor" | "selection"
| "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan" | "white"
| "brightBlack" | "brightRed" | "brightGreen" | "brightYellow"
| "brightBlue" | "brightMagenta" | "brightCyan" | "brightWhite";
export interface TerminalThemePayload extends TerminalProviderPayload {
readonly reason: string;
readonly currentTheme: {
readonly type: "dark" | "light";
readonly colors: Readonly<Record<TerminalThemeColorName, string>>;
};
}
export interface TerminalThemeResult {
readonly colors: Readonly<Partial<Record<TerminalThemeColorName, string>>>;
}
export interface OrdinaryTerminalProviderPayloadByKind {
readonly "terminal.completion": TerminalCompletionPayload;
readonly "terminal.decoration": TerminalDecorationPayload;
readonly "terminal.link": TerminalLineProviderPayload;
readonly "terminal.hover": TerminalLineProviderPayload;
readonly "terminal.matcher": TerminalMatcherPayload;
readonly "terminal.semantic": TerminalSemanticPayload;
readonly "terminal.prompt": TerminalPromptPayload;
readonly "terminal.background": TerminalBackgroundPayload;
readonly "terminal.theme": TerminalThemePayload;
}
export interface OrdinaryTerminalProviderResultByKind {
readonly "terminal.completion": TerminalCompletionResult;
readonly "terminal.decoration": TerminalDecorationResult;
readonly "terminal.link": TerminalLinkResult;
readonly "terminal.hover": TerminalHoverResult;
readonly "terminal.matcher": TerminalMatcherResult;
readonly "terminal.semantic": TerminalSemanticResult;
readonly "terminal.prompt": TerminalPromptResult;
readonly "terminal.background": TerminalBackgroundResult;
readonly "terminal.theme": TerminalThemeResult;
}
export interface OrdinaryTerminalProviderOperationByKind {
readonly "terminal.completion": "provideCompletions";
readonly "terminal.decoration": "provideDecorations";
readonly "terminal.link": "provideLinks";
readonly "terminal.hover": "provideHovers";
readonly "terminal.matcher": "provideMatches";
readonly "terminal.semantic": "provideSemantics";
readonly "terminal.prompt": "provideAnnotations";
readonly "terminal.background": "provideBackgrounds";
readonly "terminal.theme": "provideTheme";
}
export type OrdinaryTerminalProviderKind = keyof OrdinaryTerminalProviderPayloadByKind;
export interface OrdinaryTerminalProviderInvocation<K extends OrdinaryTerminalProviderKind> {
readonly providerId: string;
readonly kind: K;
readonly operation: OrdinaryTerminalProviderOperationByKind[K];
readonly requestId: string;
readonly payload: OrdinaryTerminalProviderPayloadByKind[K];
readonly deadlineMs: number | undefined;
readonly cancellationToken: CancellationToken;
}
export type OrdinaryTerminalProviderHandler<K extends OrdinaryTerminalProviderKind> = (
invocation: OrdinaryTerminalProviderInvocation<K>,
) => OrdinaryTerminalProviderResultByKind[K] | Promise<OrdinaryTerminalProviderResultByKind[K]>;
export interface ConnectionProviderInvocationByOperation {
readonly validateConfiguration: TypedPluginProviderInvocation<ConnectionConfigurationPayload> & {
readonly kind: "connection";
readonly operation: "validateConfiguration";
};
readonly probe: TypedPluginProviderInvocation<ConnectionConfigurationPayload> & {
readonly kind: "connection";
readonly operation: "probe";
};
readonly open: TypedPluginProviderInvocation<ConnectionOpenPayload> & {
readonly kind: "connection";
readonly operation: "open";
readonly input: Promise<PluginReadableByteStream>;
readonly output: PluginWritableByteStream;
};
readonly resize: TypedPluginProviderInvocation<ConnectionResizePayload> & {
readonly kind: "connection";
readonly operation: "resize";
};
readonly signal: TypedPluginProviderInvocation<ConnectionSignalPayload> & {
readonly kind: "connection";
readonly operation: "signal";
};
readonly reconnect: TypedPluginProviderInvocation<ConnectionControlPayload> & {
readonly kind: "connection";
readonly operation: "reconnect";
};
readonly close: TypedPluginProviderInvocation<ConnectionControlPayload> & {
readonly kind: "connection";
readonly operation: "close";
};
readonly getStatus: TypedPluginProviderInvocation<ConnectionControlPayload> & {
readonly kind: "connection";
readonly operation: "getStatus";
};
}
export interface ConnectionProviderResultByOperation {
readonly validateConfiguration: ConnectionValidateResult;
readonly probe: ConnectionProbeResult;
readonly open: ConnectionOpenResult;
readonly resize: ConnectionControlResult;
readonly signal: ConnectionControlResult;
readonly reconnect: ConnectionControlResult;
readonly close: ConnectionControlResult;
readonly getStatus: ConnectionStatusResult;
}
export type ConnectionProviderOperation = keyof ConnectionProviderInvocationByOperation;
export type ConnectionProviderInvocation =
ConnectionProviderInvocationByOperation[ConnectionProviderOperation];
export type ConnectionProviderResult =
ConnectionProviderResultByOperation[ConnectionProviderOperation];
export type ConnectionProviderOperationHandler<TOperation extends ConnectionProviderOperation> = (
invocation: ConnectionProviderInvocationByOperation[TOperation],
) => ConnectionProviderResultByOperation[TOperation] | Promise<ConnectionProviderResultByOperation[TOperation]>;
export type ConnectionProviderHandler = Readonly<{
[TOperation in ConnectionProviderOperation]: ConnectionProviderOperationHandler<TOperation>;
}>;
export type AuthenticationProviderInvocation =
| (TypedPluginProviderInvocation<AuthenticationBeginPayload> & {
readonly kind: "authentication";
readonly operation: "begin";
})
| (TypedPluginProviderInvocation<AuthenticationResponsePayload> & {
readonly kind: "authentication";
readonly operation: "respond";
})
| (TypedPluginProviderInvocation<Readonly<{ operationId: string }>> & {
readonly kind: "authentication";
readonly operation: "cancel";
});
export type AuthenticationProviderHandler = (
invocation: AuthenticationProviderInvocation,
) => AuthenticationResult | Promise<AuthenticationResult>;
export interface ImporterProviderInvocationByOperation {
readonly detect: TypedPluginProviderInvocation<ImporterDetectPayload> & {
readonly kind: "importer";
readonly operation: "detect";
};
readonly parse: TypedPluginProviderInvocation<ImporterParsePayload> & {
readonly kind: "importer";
readonly operation: "parse";
readonly input: Promise<PluginReadableByteStream>;
readonly output: PluginWritableByteStream;
};
}
export interface ImporterProviderResultByOperation {
readonly detect: ImporterDetectResult;
readonly parse: ImporterParseResult;
}
export type ImporterProviderOperation = keyof ImporterProviderInvocationByOperation;
export type ImporterProviderInvocation =
ImporterProviderInvocationByOperation[ImporterProviderOperation];
export type ImporterProviderResult =
ImporterProviderResultByOperation[ImporterProviderOperation];
export type ImporterProviderOperationHandler<TOperation extends ImporterProviderOperation> = (
invocation: ImporterProviderInvocationByOperation[TOperation],
) => ImporterProviderResultByOperation[TOperation] | Promise<ImporterProviderResultByOperation[TOperation]>;
export type ImporterProviderHandler = Readonly<{
[TOperation in ImporterProviderOperation]: ImporterProviderOperationHandler<TOperation>;
}>;
export interface SyncProviderInvocationByOperation {
readonly connect: TypedPluginProviderInvocation<SyncConnectPayload> & {
readonly kind: "sync";
readonly operation: "connect";
};
readonly disconnect: TypedPluginProviderInvocation<SyncDisconnectPayload | undefined> & {
readonly kind: "sync";
readonly operation: "disconnect";
};
readonly getAccount: TypedPluginProviderInvocation<SyncGetAccountPayload | undefined> & {
readonly kind: "sync";
readonly operation: "getAccount";
};
readonly getCapabilities: TypedPluginProviderInvocation<SyncGetCapabilitiesPayload | undefined> & {
readonly kind: "sync";
readonly operation: "getCapabilities";
};
readonly readObject: TypedPluginProviderInvocation<SyncReadObjectPayload> & {
readonly kind: "sync";
readonly operation: "readObject";
readonly output?: PluginWritableByteStream;
};
readonly writeObject: TypedPluginProviderInvocation<SyncWriteObjectPayload> & {
readonly kind: "sync";
readonly operation: "writeObject";
readonly input?: Promise<PluginReadableByteStream>;
};
readonly deleteObject: TypedPluginProviderInvocation<SyncDeleteObjectPayload> & {
readonly kind: "sync";
readonly operation: "deleteObject";
};
}
export interface SyncProviderResultByOperation {
readonly connect: SyncConnectResult;
readonly disconnect: SyncDisconnectResult;
readonly getAccount: SyncGetAccountResult;
readonly getCapabilities: SyncCapabilitiesResult;
readonly readObject: SyncReadObjectResult;
readonly writeObject: SyncWriteObjectResult;
readonly deleteObject: SyncDeleteObjectResult;
}
export type SyncProviderOperation = keyof SyncProviderInvocationByOperation;
export type SyncProviderInvocation =
SyncProviderInvocationByOperation[SyncProviderOperation];
export type SyncProviderResult =
SyncProviderResultByOperation[SyncProviderOperation];
export type SyncProviderOperationHandler<TOperation extends SyncProviderOperation> = (
invocation: SyncProviderInvocationByOperation[TOperation],
) => SyncProviderResultByOperation[TOperation] | Promise<SyncProviderResultByOperation[TOperation]>;
export type SyncProviderHandler = Readonly<{
[TOperation in SyncProviderOperation]: SyncProviderOperationHandler<TOperation>;
}>;
export interface PluginTerminalSessions {
onDidChange(listener: (event: TerminalSessionEvent) => void): Disposable;
}
export interface PluginEnvironmentChangeEvent {
readonly locale: string;
readonly theme: string;
readonly reducedMotion: boolean;
readonly highContrast: boolean;
readonly themeTokens: Readonly<Record<string, string>>;
}
export interface PluginEnvironment extends PluginEnvironmentChangeEvent {
onDidChange(listener: (event: PluginEnvironmentChangeEvent) => void): Disposable;
}
export interface PluginCredentialLeaseOptions {
readonly operationId: string;
readonly purpose: string;
readonly ttlMs?: number;
}
export interface PluginCredentialBroker {
createLease(credential: SecretRef | CredentialRef, options: PluginCredentialLeaseOptions): Promise<SecretLeaseRef>;
}
export interface PluginNetworkRequest {
readonly url: string;
readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
readonly headers?: Readonly<Record<string, string>>;
readonly body?: Readonly<{ encoding: "utf8" | "base64"; data: string }>;
readonly timeoutMs?: number;
}
export interface PluginNetworkResponse {
readonly url: string;
readonly status: number;
readonly headers: Readonly<Record<string, string>>;
readonly body: Readonly<{ encoding: "base64"; data: string }>;
}
export interface PluginNetworkClient {
request(request: PluginNetworkRequest): Promise<PluginNetworkResponse>;
}
export interface PluginFilesystemEntry {
readonly name: string;
readonly kind: "file" | "directory" | "other";
}
export interface PluginFilesystemStat {
readonly kind: "file" | "directory" | "other";
readonly size: number;
readonly modifiedAt: number;
}
export interface PluginFilesystemClient {
readFile(path: string, options?: Readonly<{ encoding?: "utf8" | "base64"; maxBytes?: number }>): Promise<string>;
writeFile(path: string, data: string, options: Readonly<{
encoding?: "utf8" | "base64";
overwrite: true;
}>): Promise<void>;
stat(path: string): Promise<PluginFilesystemStat>;
readDirectory(path: string): Promise<readonly PluginFilesystemEntry[]>;
}
export interface PluginCompanionRequestOptions {
readonly timeoutMs?: number;
/**
* Operation-bound one-use leases consumed by the host immediately before
* dispatching this request to the isolated companion. When present, the
* companion receives `{ payload, credentials }` instead of the raw params.
*/
readonly credentialLeases?: Readonly<Record<string, SecretLeaseRef>>;
readonly operationId?: string;
}
export interface PluginCompanionHandle extends Disposable {
readonly id: string;
request<T extends JsonValue = JsonValue>(
method: string,
params?: JsonValue,
options?: PluginCompanionRequestOptions,
): Promise<T>;
stop(): Promise<void>;
}
export interface PluginCompanionService {
start(companionId: string): Promise<PluginCompanionHandle>;
}
export interface PluginReadableByteStream extends Disposable {
readonly id: string;
/**
* Returns the next owned byte chunk or null after a normal end. Calling read
* again releases receive credit for the previous chunk, so consumers should
* finish processing one chunk before requesting the next.
*/
read(): Promise<Uint8Array | null>;
cancel(): void;
}
export interface PluginWritableByteStream extends Disposable {
readonly id: string;
write(data: Uint8Array | ArrayBuffer): Promise<void>;
end(): Promise<void>;
fail(error: Readonly<{ message: string }>): void;
cancel(): void;
}
export interface PluginStreams {
acceptReadable(streamId: string): Promise<PluginReadableByteStream>;
openWritable(streamId: string, options?: Readonly<{ windowBytes?: number }>): Promise<PluginWritableByteStream>;
}
export interface PluginContext {
readonly pluginId: PluginId;
readonly netcattyVersion: SemanticVersion;
readonly apiVersion: SemanticVersion;
readonly enabledFeatures: ReadonlySet<FeatureId>;
readonly subscriptions: DisposableStore;
readonly storage: PluginKeyValueStore;
readonly settings: PluginSettings;
readonly commands: PluginCommands;
readonly contextKeys: PluginContextKeys;
readonly views: PluginViews;
readonly providers: PluginProviders;
readonly terminals: PluginTerminalSessions;
readonly environment: PluginEnvironment;
readonly secrets: PluginSecretStore;
readonly credentials: PluginCredentialBroker;
readonly network: PluginNetworkClient;
readonly filesystem: PluginFilesystemClient;
readonly companions: PluginCompanionService;
readonly streams: PluginStreams;
readonly logger: PluginLogger;
}
export interface NetcattyPlugin {
activate(context: PluginContext): void | Disposable | Promise<void | Disposable>;
deactivate?(): void | Promise<void>;
}
export type PluginErrorCode = PluginErrorName;
export const PLUGIN_ERROR_WIRE_CODES = {
cancelled: -32001,
unknown: -32002,
invalid_argument: -32003,
deadline_exceeded: -32004,
not_found: -32005,
already_exists: -32006,
permission_denied: -32007,
resource_exhausted: -32008,
failed_precondition: -32009,
aborted: -32010,
out_of_range: -32011,
unsupported: -32012,
internal: -32013,
unavailable: -32014,
data_loss: -32015,
unauthenticated: -32016,
} as const satisfies Readonly<Record<PluginErrorCode, PluginWireErrorCode>>;
export class PluginError extends Error {
readonly code: PluginErrorCode;
readonly details?: JsonValue;
constructor(code: PluginErrorCode, message: string, details?: JsonValue) {
super(message);
this.name = "PluginError";
this.code = code;
this.details = details;
}
}
export function pluginErrorToRpcError(error: PluginError): RpcErrorObject {
const data: PluginErrorData = error.details === undefined
? { pluginCode: error.code }
: { pluginCode: error.code, details: error.details };
return {
code: PLUGIN_ERROR_WIRE_CODES[error.code],
message: error.message,
data,
};
}
export class CancellationError extends PluginError {
constructor(message = "The operation was cancelled") {
super("cancelled", message);
this.name = "CancellationError";
}
}
export class DisposableStore implements Disposable {
readonly #items = new Set<Disposable>();
#isDisposed = false;
get isDisposed(): boolean {
return this.#isDisposed;
}
add<T extends Disposable>(disposable: T): T {
if (this.#isDisposed) {
disposable.dispose();
throw new PluginError("unavailable", "Cannot add to a disposed DisposableStore");
}
this.#items.add(disposable);
return disposable;
}
delete(disposable: Disposable): boolean {
return this.#items.delete(disposable);
}
clear(): void {
const items = [...this.#items];
this.#items.clear();
const errors: unknown[] = [];
for (const item of items) {
try {
item.dispose();
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
throw new AggregateError(errors, "One or more plugin disposables failed");
}
}
dispose(): void {
if (this.#isDisposed) return;
this.#isDisposed = true;
this.clear();
}
}
class MutableCancellationToken implements CancellationToken {
readonly #listeners = new Set<CancellationListener>();
#isCancellationRequested = false;
get isCancellationRequested(): boolean {
return this.#isCancellationRequested;
}
onCancellationRequested(listener: CancellationListener): Disposable {
if (this.#isCancellationRequested) {
queueMicrotask(listener);
return { dispose() {} };
}
this.#listeners.add(listener);
return {
dispose: () => {
this.#listeners.delete(listener);
},
};
}
cancel(): void {
if (this.#isCancellationRequested) return;
this.#isCancellationRequested = true;
const listeners = [...this.#listeners];
this.#listeners.clear();
const errors: unknown[] = [];
for (const listener of listeners) {
try {
listener();
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
throw new AggregateError(errors, "One or more cancellation listeners failed");
}
}
dispose(): void {
this.#listeners.clear();
}
}
export class CancellationTokenSource implements Disposable {
readonly #token = new MutableCancellationToken();
#isDisposed = false;
get token(): CancellationToken {
return this.#token;
}
cancel(): void {
if (!this.#isDisposed) this.#token.cancel();
}
dispose(cancel = false): void {
if (this.#isDisposed) return;
try {
if (cancel) this.#token.cancel();
} finally {
this.#token.dispose();
this.#isDisposed = true;
}
}
}
export function definePlugin<T extends NetcattyPlugin>(plugin: T): T {
return plugin;
}
export function throwIfCancellationRequested(token: CancellationToken): void {
if (token.isCancellationRequested) throw new CancellationError();
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}