mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-01 20:44:53 +00:00
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
/**
|
|
* Bridge Protocol - Communication between VS Code extension and webview.
|
|
*
|
|
* Architecture:
|
|
* - Webview calls Methods via RPC (request/response)
|
|
* - Extension broadcasts Events to webview (one-way notifications)
|
|
*
|
|
* RPC flow: webview.call(method, params) -> extension.dispatch -> webview.resolve(result)
|
|
* Event flow: extension.broadcast(event, data) -> webview.on(event, handler)
|
|
*/
|
|
|
|
export const Methods = {
|
|
CheckWorkspace: "checkWorkspace",
|
|
GetInputHistory: "getInputHistory",
|
|
AddInputHistory: "addInputHistory",
|
|
|
|
GetSlashCommands: "getSlashCommands",
|
|
CheckLoginStatus: "checkLoginStatus",
|
|
Login: "login",
|
|
Logout: "logout",
|
|
SaveConfig: "saveConfig",
|
|
GetExtensionConfig: "getExtensionConfig",
|
|
OpenSettings: "openSettings",
|
|
OpenFolder: "openFolder",
|
|
GetModels: "getModels",
|
|
|
|
GetMCPServers: "getMCPServers",
|
|
AddMCPServer: "addMCPServer",
|
|
UpdateMCPServer: "updateMCPServer",
|
|
RemoveMCPServer: "removeMCPServer",
|
|
AuthMCP: "authMCP",
|
|
ResetAuthMCP: "resetAuthMCP",
|
|
TestMCP: "testMCP",
|
|
|
|
StreamChat: "streamChat",
|
|
AbortChat: "abortChat",
|
|
ResetSession: "resetSession",
|
|
SetPlanMode: "setPlanMode",
|
|
SteerChat: "steerChat",
|
|
RespondApproval: "respondApproval",
|
|
|
|
GetKimiSessions: "getKimiSessions",
|
|
GetAllKimiSessions: "getAllKimiSessions",
|
|
GetRegisteredWorkDirs: "getRegisteredWorkDirs",
|
|
SetWorkDir: "setWorkDir",
|
|
BrowseWorkDir: "browseWorkDir",
|
|
LoadKimiSessionHistory: "loadKimiSessionHistory",
|
|
DeleteKimiSession: "deleteKimiSession",
|
|
ForkKimiSession: "forkKimiSession",
|
|
GetProjectFiles: "getProjectFiles",
|
|
PickMedia: "pickMedia",
|
|
OpenFile: "openFile",
|
|
CheckFileExists: "checkFileExists",
|
|
CheckFilesExist: "checkFilesExist",
|
|
OpenFileDiff: "openFileDiff",
|
|
TrackFiles: "trackFiles",
|
|
ClearTrackedFiles: "clearTrackedFiles",
|
|
RevertFiles: "revertFiles",
|
|
KeepChanges: "keepChanges",
|
|
GetImageDataUri: "getImageDataUri",
|
|
ShowLogs: "showLogs",
|
|
ReloadWebview: "reloadWebview",
|
|
RespondQuestion: "respondQuestion",
|
|
} as const;
|
|
|
|
export type RpcMethod = (typeof Methods)[keyof typeof Methods];
|
|
|
|
export interface RpcMessage {
|
|
readonly id: string;
|
|
readonly method: RpcMethod;
|
|
readonly params?: unknown;
|
|
}
|
|
|
|
export interface RpcResult {
|
|
readonly id: string;
|
|
readonly result?: unknown;
|
|
readonly error?: string;
|
|
}
|
|
|
|
export type RpcMessageValidation =
|
|
| { readonly ok: true; readonly message: RpcMessage }
|
|
| {
|
|
readonly ok: false;
|
|
readonly id: string;
|
|
readonly method: string;
|
|
readonly error: string;
|
|
};
|
|
|
|
export const Events = {
|
|
ExtensionConfigChanged: "extensionConfigChanged",
|
|
MCPServersChanged: "mcpServersChanged",
|
|
StreamEvent: "streamEvent",
|
|
FocusInput: "focusInput",
|
|
InsertMention: "insertMention",
|
|
NewConversation: "newConversation",
|
|
FileChangesUpdated: "fileChangesUpdated",
|
|
RollbackInput: "rollbackInput",
|
|
LoginUrl: "loginUrl",
|
|
} as const;
|
|
|
|
const rpcMethods = new Set<string>(Object.values(Methods));
|
|
|
|
/** Validates the untrusted Webview message before any host-side handler runs. */
|
|
export function validateRpcMessage(value: unknown): RpcMessageValidation {
|
|
if (!isPlainObject(value)) {
|
|
return invalidMessage("", "<invalid>", "Invalid bridge request: expected a plain object.");
|
|
}
|
|
|
|
const id = value["id"];
|
|
if (!Object.hasOwn(value, "id") || typeof id !== "string" || id.trim().length === 0) {
|
|
return invalidMessage("", safeMethod(value["method"]), "Invalid bridge request: id must be a non-empty string.");
|
|
}
|
|
|
|
const method = value["method"];
|
|
if (!Object.hasOwn(value, "method") || typeof method !== "string" || method.trim().length === 0) {
|
|
return invalidMessage(id, "<invalid>", "Invalid bridge request: method must be a non-empty string.");
|
|
}
|
|
if (!rpcMethods.has(method)) {
|
|
return invalidMessage(id, method, `Unknown bridge method: ${method}`);
|
|
}
|
|
const params = Object.hasOwn(value, "params") ? value["params"] : undefined;
|
|
if (!validateParams(method as RpcMethod, params)) {
|
|
return invalidMessage(id, method, `Invalid bridge params for method: ${method}`);
|
|
}
|
|
|
|
return { ok: true, message: { id, method: method as RpcMethod, params } };
|
|
}
|
|
|
|
function validateParams(method: RpcMethod, params: unknown): boolean {
|
|
switch (method) {
|
|
case Methods.CheckWorkspace:
|
|
case Methods.GetInputHistory:
|
|
case Methods.GetSlashCommands:
|
|
case Methods.CheckLoginStatus:
|
|
case Methods.Login:
|
|
case Methods.Logout:
|
|
case Methods.GetExtensionConfig:
|
|
case Methods.OpenSettings:
|
|
case Methods.OpenFolder:
|
|
case Methods.GetModels:
|
|
case Methods.GetMCPServers:
|
|
case Methods.AbortChat:
|
|
case Methods.ResetSession:
|
|
case Methods.GetKimiSessions:
|
|
case Methods.GetAllKimiSessions:
|
|
case Methods.GetRegisteredWorkDirs:
|
|
case Methods.BrowseWorkDir:
|
|
case Methods.ClearTrackedFiles:
|
|
case Methods.ShowLogs:
|
|
case Methods.ReloadWebview:
|
|
return params === undefined;
|
|
|
|
case Methods.AddInputHistory:
|
|
return hasString(params, "text");
|
|
case Methods.SaveConfig:
|
|
return isPlainObject(params)
|
|
&& typeof params["model"] === "string"
|
|
&& isOptionalType(params["thinking"], "boolean")
|
|
&& isOptionalType(params["effort"], "string")
|
|
&& isOptionalType(params["effortChanged"], "boolean");
|
|
case Methods.AddMCPServer:
|
|
return isMcpServerConfig(params);
|
|
case Methods.UpdateMCPServer:
|
|
return isMcpUpdate(params);
|
|
case Methods.RemoveMCPServer:
|
|
case Methods.AuthMCP:
|
|
case Methods.ResetAuthMCP:
|
|
case Methods.TestMCP:
|
|
return hasNonEmptyString(params, "name");
|
|
case Methods.StreamChat:
|
|
return isStreamChatParams(params);
|
|
case Methods.RespondApproval:
|
|
return isPlainObject(params)
|
|
&& isNonEmptyString(params["requestId"])
|
|
&& (params["response"] === "approve"
|
|
|| params["response"] === "approve_for_session"
|
|
|| params["response"] === "reject");
|
|
case Methods.RespondQuestion:
|
|
return isPlainObject(params)
|
|
&& isNonEmptyString(params["rpcRequestId"])
|
|
&& isNonEmptyString(params["questionRequestId"])
|
|
&& isStringRecord(params["answers"]);
|
|
case Methods.SetPlanMode:
|
|
return hasBoolean(params, "enabled");
|
|
case Methods.SteerChat:
|
|
return isPlainObject(params) && isContent(params["content"]);
|
|
case Methods.GetProjectFiles:
|
|
return params === undefined || (
|
|
isPlainObject(params)
|
|
&& isOptionalType(params["query"], "string")
|
|
&& isOptionalType(params["directory"], "string")
|
|
);
|
|
case Methods.SetWorkDir:
|
|
return isPlainObject(params) && (params["workDir"] === null || typeof params["workDir"] === "string");
|
|
case Methods.LoadKimiSessionHistory:
|
|
return hasNonEmptyString(params, "kimiSessionId");
|
|
case Methods.DeleteKimiSession:
|
|
return hasNonEmptyString(params, "sessionId");
|
|
case Methods.ForkKimiSession:
|
|
return isPlainObject(params)
|
|
&& isNonEmptyString(params["sessionId"])
|
|
&& Number.isInteger(params["turnIndex"])
|
|
&& (params["turnIndex"] as number) >= 0;
|
|
case Methods.PickMedia:
|
|
return isPlainObject(params)
|
|
&& (params["maxCount"] === undefined
|
|
|| (Number.isInteger(params["maxCount"]) && (params["maxCount"] as number) >= 0))
|
|
&& isOptionalType(params["includeVideo"], "boolean");
|
|
case Methods.OpenFile:
|
|
case Methods.OpenFileDiff:
|
|
case Methods.CheckFileExists:
|
|
case Methods.GetImageDataUri:
|
|
return hasString(params, "filePath");
|
|
case Methods.CheckFilesExist:
|
|
case Methods.TrackFiles:
|
|
return hasStringArray(params, "paths");
|
|
case Methods.RevertFiles:
|
|
case Methods.KeepChanges:
|
|
return isPlainObject(params) && isOptionalType(params["filePath"], "string");
|
|
}
|
|
}
|
|
|
|
function isStreamChatParams(value: unknown): boolean {
|
|
return isPlainObject(value)
|
|
&& isContent(value["content"])
|
|
&& typeof value["model"] === "string"
|
|
&& isOptionalType(value["effort"], "string")
|
|
&& isOptionalType(value["thinking"], "boolean")
|
|
&& isOptionalType(value["planMode"], "boolean")
|
|
&& isOptionalType(value["sessionId"], "string");
|
|
}
|
|
|
|
function isContent(value: unknown): boolean {
|
|
return typeof value === "string" || (Array.isArray(value) && value.every(isContentPart));
|
|
}
|
|
|
|
function isContentPart(value: unknown): boolean {
|
|
if (!isPlainObject(value) || typeof value["type"] !== "string") return false;
|
|
switch (value["type"]) {
|
|
case "text":
|
|
return typeof value["text"] === "string";
|
|
case "think":
|
|
return typeof value["think"] === "string"
|
|
&& (value["encrypted"] === undefined
|
|
|| value["encrypted"] === null
|
|
|| typeof value["encrypted"] === "string");
|
|
case "image_url":
|
|
case "audio_url":
|
|
case "video_url": {
|
|
const media = value[value["type"]];
|
|
return isPlainObject(media)
|
|
&& typeof media["url"] === "string"
|
|
&& (media["id"] === undefined || media["id"] === null || typeof media["id"] === "string");
|
|
}
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isMcpUpdate(value: unknown): boolean {
|
|
if (!isPlainObject(value)) return false;
|
|
if (Object.hasOwn(value, "server")) {
|
|
return isNonEmptyString(value["originalName"]) && isMcpServerConfig(value["server"]);
|
|
}
|
|
return isMcpServerConfig(value);
|
|
}
|
|
|
|
function isMcpServerConfig(value: unknown): boolean {
|
|
return isPlainObject(value)
|
|
&& isNonEmptyString(value["name"])
|
|
&& (value["transport"] === "stdio" || value["transport"] === "http")
|
|
&& isOptionalType(value["url"], "string")
|
|
&& isOptionalType(value["command"], "string")
|
|
&& (value["args"] === undefined || isStringArray(value["args"]))
|
|
&& (value["env"] === undefined || isStringRecord(value["env"]))
|
|
&& (value["headers"] === undefined || isStringRecord(value["headers"]))
|
|
&& (value["auth"] === undefined || value["auth"] === "oauth")
|
|
&& isOptionalType(value["bearerTokenEnvVar"], "string");
|
|
}
|
|
|
|
function hasString(value: unknown, key: string): boolean {
|
|
return isPlainObject(value) && typeof value[key] === "string";
|
|
}
|
|
|
|
function hasNonEmptyString(value: unknown, key: string): boolean {
|
|
return isPlainObject(value) && isNonEmptyString(value[key]);
|
|
}
|
|
|
|
function hasBoolean(value: unknown, key: string): boolean {
|
|
return isPlainObject(value) && typeof value[key] === "boolean";
|
|
}
|
|
|
|
function hasStringArray(value: unknown, key: string): boolean {
|
|
return isPlainObject(value) && isStringArray(value[key]);
|
|
}
|
|
|
|
function isStringArray(value: unknown): boolean {
|
|
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
}
|
|
|
|
function isStringRecord(value: unknown): boolean {
|
|
return isPlainObject(value) && Object.values(value).every((item) => typeof item === "string");
|
|
}
|
|
|
|
function isOptionalType(value: unknown, type: "string" | "boolean"): boolean {
|
|
return value === undefined || typeof value === type;
|
|
}
|
|
|
|
function isNonEmptyString(value: unknown): value is string {
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
}
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
if (value === null || typeof value !== "object") return false;
|
|
const prototype = Object.getPrototypeOf(value);
|
|
return prototype === Object.prototype || prototype === null;
|
|
}
|
|
|
|
function invalidMessage(id: string, method: string, error: string): RpcMessageValidation {
|
|
return { ok: false, id, method, error };
|
|
}
|
|
|
|
function safeMethod(value: unknown): string {
|
|
return typeof value === "string" && value.length > 0 ? value : "<invalid>";
|
|
}
|