← Files WebMCP KitARCHIVED FILE

skills/implement/interactive/protocol.ts

11.8 KB · Oct 2, 2026 · 00:31 UTC

↓ Download file

import {
  closeSync,
  constants,
  fstatSync,
  fsyncSync,
  lstatSync,
  openSync,
  readFileSync,
  writeSync,
} from "node:fs";
import { join } from "node:path";

export const SERVICE = "webmcp-explorer";
export const PROTOCOL_VERSION = 1;
export const MAX_JOURNAL_LINE_BYTES = 64 * 1024;
export const MAX_JOURNAL_BYTES = 64 * 1024 * 1024;
export const MAX_REQUEST_BYTES = MAX_JOURNAL_LINE_BYTES;
export const MAX_REQUEST_ID_BYTES = 128;
export const MAX_IDENTIFIER_BYTES = 256;
export const MAX_TEXT_BYTES = 16 * 1024;
export const MAX_PICKS = 100;

export const REQUEST_TYPES = [
  "pick",
  "comment",
  "feedback",
  "submit",
  "approve",
  "cancel",
] as const;
export type RequestType = (typeof REQUEST_TYPES)[number];

export type CanonicalPayload =
  | { suggestion: string; picked: boolean }
  | { suggestion: string | null; text: string }
  | { picks: Array<{ suggestion: string; note: string }> }
  | Record<string, never>;

export interface RecordedEnvelope {
  event_id: string;
  run_id: string;
  order: number;
  type: RequestType;
  ts: string;
  payload: CanonicalPayload;
}

export interface RunFileV1 {
  version: 1;
  run_id: string;
  capability: string;
  workspace: string;
  port: number;
  pid: number;
  started_at: string;
}

export interface ValidRequest {
  request_id: string;
  type: RequestType;
  payload: CanonicalPayload;
}

export type RequestValidation =
  | { ok: true; value: ValidRequest }
  | { ok: false; request_id?: string; message: string };

export class AppendLimitError extends Error {
  constructor() {
    super("append would exceed the journal byte limit");
    this.name = "AppendLimitError";
  }
}

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const TYPES = new Set<string>(REQUEST_TYPES);

export function isUuid(value: unknown): value is string {
  return typeof value === "string" && UUID.test(value);
}

export function isIsoTimestamp(value: unknown): value is string {
  if (typeof value !== "string") return false;
  const time = Date.parse(value);
  return Number.isFinite(time) && new Date(time).toISOString() === value;
}

export function isPlainObject(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function hasOnlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
  const actual = Object.keys(value).sort();
  const expected = [...keys].sort();
  return actual.length === expected.length && actual.every((key, i) => key === expected[i]);
}

function boundedString(
  value: unknown,
  maxBytes: number,
  options: { allowEmpty?: boolean; identifier?: boolean } = {},
): value is string {
  if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > maxBytes) return false;
  if (!options.allowEmpty && value.trim().length === 0) return false;
  if (options.identifier && (value !== value.trim() || /[\u0000-\u001f\u007f]/.test(value))) return false;
  return true;
}

function validSuggestion(value: unknown): value is string {
  return boundedString(value, MAX_IDENTIFIER_BYTES, { identifier: true });
}

function canonicalPayload(type: RequestType, payload: unknown): CanonicalPayload | null {
  if (!isPlainObject(payload)) return null;
  if (type === "pick") {
    if (!hasOnlyKeys(payload, ["suggestion", "picked"])) return null;
    if (!validSuggestion(payload.suggestion) || typeof payload.picked !== "boolean") return null;
    return { suggestion: payload.suggestion, picked: payload.picked };
  }
  if (type === "comment" || type === "feedback") {
    if (!hasOnlyKeys(payload, ["suggestion", "text"])) return null;
    if (payload.suggestion !== null && !validSuggestion(payload.suggestion)) return null;
    if (!boundedString(payload.text, MAX_TEXT_BYTES)) return null;
    return { suggestion: payload.suggestion, text: payload.text } as {
      suggestion: string | null;
      text: string;
    };
  }
  if (type === "submit") {
    if (!hasOnlyKeys(payload, ["picks"]) || !Array.isArray(payload.picks)) return null;
    if (payload.picks.length > MAX_PICKS) return null;
    const picks: Array<{ suggestion: string; note: string }> = [];
    for (const pick of payload.picks) {
      if (!isPlainObject(pick) || !hasOnlyKeys(pick, ["suggestion", "note"])) return null;
      if (!validSuggestion(pick.suggestion)) return null;
      if (!boundedString(pick.note, MAX_TEXT_BYTES, { allowEmpty: true })) return null;
      picks.push({ suggestion: pick.suggestion, note: pick.note });
    }
    return { picks };
  }
  if (!hasOnlyKeys(payload, [])) return null;
  return {};
}

export function validateRequest(value: unknown): RequestValidation {
  if (!isPlainObject(value)) return { ok: false, message: "Request must be a JSON object." };
  const candidateRequestId = boundedString(value.request_id, MAX_REQUEST_ID_BYTES, {
    identifier: true,
  })
    ? value.request_id
    : undefined;
  if (!hasOnlyKeys(value, ["request_id", "type", "payload"])) {
    return { ok: false, request_id: candidateRequestId, message: "Invalid request envelope." };
  }
  if (!candidateRequestId) return { ok: false, message: "Invalid request_id." };
  if (typeof value.type !== "string" || !TYPES.has(value.type)) {
    return { ok: false, request_id: candidateRequestId, message: "Unknown event type." };
  }
  const type = value.type as RequestType;
  const payload = canonicalPayload(type, value.payload);
  if (payload === null) {
    return { ok: false, request_id: candidateRequestId, message: `Invalid ${type} payload.` };
  }
  return { ok: true, value: { request_id: candidateRequestId, type, payload } };
}

export function parseRunFile(value: unknown): RunFileV1 | null {
  if (!isPlainObject(value)) return null;
  if (
    !hasOnlyKeys(value, [
      "version",
      "run_id",
      "capability",
      "workspace",
      "port",
      "pid",
      "started_at",
    ]) ||
    value.version !== PROTOCOL_VERSION ||
    !isUuid(value.run_id) ||
    !isUuid(value.capability) ||
    value.run_id === value.capability ||
    typeof value.workspace !== "string" ||
    value.workspace.length === 0 ||
    !Number.isInteger(value.port) ||
    (value.port as number) < 1 ||
    (value.port as number) > 65535 ||
    !Number.isSafeInteger(value.pid) ||
    (value.pid as number) < 1 ||
    !isIsoTimestamp(value.started_at)
  ) {
    return null;
  }
  return value as unknown as RunFileV1;
}

export function parseRecordedEnvelope(value: unknown): RecordedEnvelope | null {
  if (!isPlainObject(value)) return null;
  if (
    !hasOnlyKeys(value, ["event_id", "run_id", "order", "type", "ts", "payload"]) ||
    !isUuid(value.event_id) ||
    !isUuid(value.run_id) ||
    !Number.isSafeInteger(value.order) ||
    (value.order as number) < 1 ||
    typeof value.type !== "string" ||
    !TYPES.has(value.type) ||
    !isIsoTimestamp(value.ts)
  ) {
    return null;
  }
  const payload = canonicalPayload(value.type as RequestType, value.payload);
  if (payload === null) return null;
  return {
    event_id: value.event_id,
    run_id: value.run_id,
    order: value.order as number,
    type: value.type as RequestType,
    ts: value.ts,
    payload,
  };
}

export function readRegularText(path: string, maxBytes = Number.POSITIVE_INFINITY): string | null {
  let before;
  try {
    before = lstatSync(path);
    if (!before.isFile() || before.isSymbolicLink() || before.size > maxBytes) return null;
  } catch {
    return null;
  }
  portablePathTestBarrier("read", path);
  let fd: number | undefined;
  try {
    fd = openSync(path, constants.O_RDONLY | noFollowFlag());
    const after = fstatSync(fd);
    if (
      !after.isFile() ||
      after.size > maxBytes ||
      !sameFile(before, after) ||
      !pathNamesFile(path, after)
    ) {
      return null;
    }
    return readFileSync(fd, "utf8");
  } catch {
    return null;
  } finally {
    if (fd !== undefined) closeSync(fd);
  }
}

export function appendDurable(
  path: string,
  value: unknown,
  maxBytes = Number.POSITIVE_INFINITY,
): void {
  const buffer = Buffer.from(`\n${JSON.stringify(value)}\n`, "utf8");
  for (let attempt = 0; attempt < 8; attempt++) {
    let before;
    try {
      before = lstatSync(path);
      if (!before.isFile() || before.isSymbolicLink()) {
        throw new Error("destination is not a regular file");
      }
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
    }
    if (before) portablePathTestBarrier("append", path);

    let fd: number | undefined;
    try {
      if (before) {
        try {
          fd = openSync(path, constants.O_APPEND | constants.O_WRONLY | noFollowFlag());
        } catch (error) {
          if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
          throw error;
        }
        const opened = fstatSync(fd);
        if (!opened.isFile() || !sameFile(before, opened) || !pathNamesFile(path, opened)) {
          throw new Error("destination identity changed before append");
        }
      } else {
        try {
          fd = openSync(
            path,
            constants.O_APPEND |
              constants.O_CREAT |
              constants.O_EXCL |
              constants.O_WRONLY |
              noFollowFlag(),
            0o644,
          );
        } catch (error) {
          if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
          throw error;
        }
        const opened = fstatSync(fd);
        if (!opened.isFile() || !pathNamesFile(path, opened)) {
          throw new Error("new append destination lost its identity");
        }
      }
      // Identity is proven before the first byte reaches either a pre-existing or newly
      // created file. This is the Windows fallback for O_NOFOLLOW and defense in depth on
      // platforms that provide it.
      if (fstatSync(fd).size + buffer.length > maxBytes) throw new AppendLimitError();
      const written = writeSync(fd, buffer, 0, buffer.length);
      if (written !== buffer.length) throw new Error(`short write (${written}/${buffer.length} bytes)`);
      fsyncSync(fd);
      return;
    } finally {
      if (fd !== undefined) closeSync(fd);
    }
  }
  throw new Error("destination stayed contended during append");
}

function noFollowFlag(): number {
  if (process.env.WEBMCP_TEST_DISABLE_NOFOLLOW === "1") return 0;
  return (constants as unknown as Record<string, number | undefined>).O_NOFOLLOW ?? 0;
}

function sameFile(left: { dev: number; ino: number }, right: { dev: number; ino: number }): boolean {
  return left.dev === right.dev && left.ino === right.ino;
}

function pathNamesFile(path: string, opened: { dev: number; ino: number }): boolean {
  try {
    const current = lstatSync(path);
    return current.isFile() && !current.isSymbolicLink() && sameFile(current, opened);
  } catch {
    return false;
  }
}

// Deterministic test seam for swapping a final path after lstat but before open while
// O_NOFOLLOW is disabled. It is inert outside a deliberately configured subprocess.
function portablePathTestBarrier(operation: "read" | "append", path: string): void {
  const directory = process.env.WEBMCP_TEST_PATH_BARRIER;
  if (
    !directory ||
    process.env.WEBMCP_TEST_PATH_OPERATION !== operation ||
    process.env.WEBMCP_TEST_PATH_TARGET !== path
  ) {
    return;
  }
  const observed = join(directory, `${operation}-observed-${process.pid}`);
  const release = join(directory, `${operation}-release-${process.pid}`);
  let marker: number | undefined;
  try {
    marker = openSync(observed, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
  } finally {
    if (marker !== undefined) closeSync(marker);
  }
  const deadline = Date.now() + 5_000;
  while (true) {
    try {
      if (lstatSync(release).isFile()) return;
    } catch {
      // Wait below.
    }
    if (Date.now() >= deadline) throw new Error("portable-path test barrier timed out");
    Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
  }
}

SHA-256: 59332d8934f2c726294c69d22256962467b623261654d9766e21d9f3a88b754b