Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,48 @@
"PrimaryUser": "Primary",
"RemoveShareUser": "Remove user",
"BatchCommand": "Batch",
"AIEmptyTitle": "AI assistant",
"AIEmptyDescription": "Ask questions and get ops suggestions or command help.",
"AIInputPlaceholder": "Describe your task or the command you want to run…",
"AIUnavailableTitle": "AI is unavailable for this terminal",
"AIUnavailableDescription": "Select a connected Koko terminal with Terminal AI enabled.",
"AIHeaderDescription": "Uses the active terminal context",
"AIEmptyTitle": "Ready to assist this terminal",
"AIEmptyDescription": "Ask for analysis or have Terminal AI plan and execute a task.",
"AIInputPlaceholder": "Ask about this terminal or describe a task…",
"AISend": "Send",
"AIPlaceholderReply": "AI integration is coming soon. Use the batch command panel for now.",
"AIYou": "You",
"AIApprovalAll": "Approve every command",
"AIApprovalRisk2": "Approve risk 2 and above",
"AIApprovalRisk3": "Approve risk 3 and above",
"AIApprovalRisk4": "Approve risk 4 only",
"AIModeAuto": "AI chooses",
"AIModePty": "Current PTY only",
"AIModeBackground": "Background only",
"AIExecutionPlan": "Execution plan",
"AIStep": "Step {count}",
"AICommand": "Command",
"AIRisk": "Risk {level}",
"AIAutoApproved": "Auto approved",
"AICurrentPty": "Current PTY",
"AIBackgroundExecution": "Background",
"AIApprove": "Approve",
"AIReject": "Reject",
"AIExecutionResult": "Execution result",
"AICommandAcl": "Command ACL",
"AIInterrupt": "Interrupt task",
"AISendFailed": "Failed to send the Terminal AI message",
"AIPolicyFailed": "Failed to update the Terminal AI policy",
"AIApprovalFailed": "Failed to submit the Terminal AI decision",
"AIInterruptFailed": "Failed to interrupt the Terminal AI task",
"AIStatusApproved": "Approved",
"AIStatusAutoApproved": "Auto approved",
"AIStatusAwaitingApproval": "Awaiting approval",
"AIStatusCompleted": "Completed",
"AIStatusFailed": "Failed",
"AIStatusRunning": "Running",
"AIStatusInterrupted": "Interrupted",
"AIStatusPending": "Pending",
"AIStatusRejected": "Rejected",
"AIStatusReviewing": "Reviewing",
"AIStatusSkipped": "Skipped",
"SFTPSearchPlaceholder": "Search assets for file transfer…",
"SFTPEmptyTitle": "Choose an SFTP asset",
"SFTPEmptyDescription": "Search for an asset or reuse the active terminal host.",
Expand Down Expand Up @@ -527,6 +564,7 @@
"removedFromShare": "Removed from shared session",
"websocketClosed": "WebSocket closed",
"websocketConnectionClosed": "WebSocket connection is closed. Refresh the page.",
"invalidMessage": "Received an invalid terminal message",
"shareLink": "Link",
"verificationCode": "Code",
"shareLinkCopied": "Share link copied"
Expand Down
46 changes: 42 additions & 4 deletions i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,48 @@
"PrimaryUser": "主用户",
"RemoveShareUser": "移除用户",
"BatchCommand": "批量命令",
"AIEmptyTitle": "AI 助手",
"AIEmptyDescription": "输入问题,获取运维建议与命令辅助。",
"AIInputPlaceholder": "描述你的问题或想执行的运维任务…",
"AIUnavailableTitle": "当前终端不可使用 AI",
"AIUnavailableDescription": "请选择一个已连接且已启用终端 AI 的 Koko 终端。",
"AIHeaderDescription": "基于当前激活终端的上下文",
"AIEmptyTitle": "准备协助当前终端",
"AIEmptyDescription": "可以分析终端问题,也可以规划并执行操作。",
"AIInputPlaceholder": "询问当前终端或描述要完成的任务…",
"AISend": "发送",
"AIPlaceholderReply": "AI 能力接入中。当前可先使用批量命令面板。",
"AIYou": "你",
"AIApprovalAll": "全部命令审批",
"AIApprovalRisk2": "风险 2 及以上审批",
"AIApprovalRisk3": "风险 3 及以上审批",
"AIApprovalRisk4": "仅风险 4 审批",
"AIModeAuto": "AI 自动选择",
"AIModePty": "仅当前 PTY",
"AIModeBackground": "仅后台执行",
"AIExecutionPlan": "执行计划",
"AIStep": "步骤 {count}",
"AICommand": "命令",
"AIRisk": "风险 {level}",
"AIAutoApproved": "自动放行",
"AICurrentPty": "当前 PTY",
"AIBackgroundExecution": "后台执行",
"AIApprove": "批准",
"AIReject": "拒绝",
"AIExecutionResult": "执行结果",
"AICommandAcl": "命令 ACL",
"AIInterrupt": "中断任务",
"AISendFailed": "发送终端 AI 消息失败",
"AIPolicyFailed": "更新终端 AI 策略失败",
"AIApprovalFailed": "提交终端 AI 审批失败",
"AIInterruptFailed": "中断终端 AI 任务失败",
"AIStatusApproved": "已批准",
"AIStatusAutoApproved": "自动批准",
"AIStatusAwaitingApproval": "等待审批",
"AIStatusCompleted": "完成",
"AIStatusFailed": "失败",
"AIStatusRunning": "执行中",
"AIStatusInterrupted": "已中断",
"AIStatusPending": "待执行",
"AIStatusRejected": "已拒绝",
"AIStatusReviewing": "分析结果",
"AIStatusSkipped": "未执行",
"SFTPSearchPlaceholder": "搜索要传输文件的资产…",
"SFTPEmptyTitle": "选择 SFTP 资产",
"SFTPEmptyDescription": "搜索资产,或复用当前活动终端对应主机。",
Expand Down Expand Up @@ -530,6 +567,7 @@
"removedFromShare": "已从共享会话中移除",
"websocketClosed": "WebSocket 已关闭",
"websocketConnectionClosed": "WebSocket 连接已关闭,请刷新页面",
"invalidMessage": "收到无效的终端消息",
"shareLink": "链接",
"verificationCode": "验证码",
"shareLinkCopied": "分享链接已复制"
Expand Down
8 changes: 0 additions & 8 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,6 @@ export default defineNuxtConfig({
rewrite: (path) => path.replace(/^\/luna/, ""),
configure: bindProxyErrorHandler("luna-koko-ws")
},
"/luna/koko/": {
target: kokoTarget,
secure: false,
ws: true,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/luna/, ""),
configure: bindProxyErrorHandler("luna-koko-http")
},
"/koko/ws/": {
target: kokoTarget.replace(/^http/i, "ws"),
secure: false,
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"author": "JumpServer",
"license": "MIT",
"engines": {
"node": ">=20.19"
"node": ">=22"
},
"scripts": {
"dev": "nuxt dev",
Expand Down Expand Up @@ -40,6 +40,7 @@
"reset": "rm -rf node_modules .nuxt dist && cd src-tauri && cargo clean"
},
"dependencies": {
"@ai-sdk/vue": "4.0.44",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
Expand Down Expand Up @@ -85,11 +86,14 @@
"@xterm/xterm": "^6.0.0",
"ag-grid-community": "^36.0.0",
"ag-grid-vue3": "^36.0.0",
"ai": "7.0.44",
"clipboard-polyfill": "^4.1.1",
"codemirror": "^6.0.2",
"dompurify": "3.4.12",
"fflate": "^0.8.2",
"guacamole-common-js-jumpserver": "1.1.0-c",
"js-untar": "^2.0.0",
"marked": "17.0.6",
"mitt": "^3.0.1",
"nuxt": "^4.4.6",
"pinia": "^3.0.4",
Expand Down
90 changes: 90 additions & 0 deletions packages/koko/app/composables/terminal/envelope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
export const ENVELOPE_VERSION = 0x01;
export const ENVELOPE_TERMINAL_INPUT = 0x01;
export const ENVELOPE_TERMINAL_OUTPUT = 0x02;
export const ENVELOPE_TERMINAL_COMMAND = 0x03;
export const ENVELOPE_ERROR = 0x04;
export const ENVELOPE_CHAT = 0x05;
export const ENVELOPE_TERMINAL_CREATE = 0x06;
export const ENVELOPE_TERMINAL_CLOSE = 0x07;

const HEADER_SIZE = 6;
const MAX_PAYLOAD_SIZE = 10 * 1024 * 1024;
const encoder = new TextEncoder();
const decoder = new TextDecoder("utf-8", { fatal: true });

export interface TerminalEnvelope {
type: number;
payload: Uint8Array;
}

export interface TerminalCommandEnvelope {
terminalId?: number;
command: string;
params?: Record<string, unknown>;
requestId?: string;
timestamp?: number;
}

export function buildEnvelope(type: number, payload: Uint8Array) {
if (payload.byteLength > MAX_PAYLOAD_SIZE) {
throw new Error("WebSocket envelope payload is too large");
}

const frame = new Uint8Array(HEADER_SIZE + payload.byteLength);
const view = new DataView(frame.buffer);
frame[0] = ENVELOPE_VERSION;
frame[1] = type;
view.setUint32(2, payload.byteLength, false);
frame.set(payload, HEADER_SIZE);
return frame;
}

export function parseEnvelope(value: ArrayBuffer | Uint8Array): TerminalEnvelope {
const frame = value instanceof Uint8Array ? value : new Uint8Array(value);
if (frame.byteLength < HEADER_SIZE || frame[0] !== ENVELOPE_VERSION) {
throw new Error("Invalid WebSocket envelope");
}

const length = new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getUint32(2, false);
if (length > MAX_PAYLOAD_SIZE || frame.byteLength !== HEADER_SIZE + length) {
throw new Error("Invalid WebSocket envelope length");
}

return { type: frame[1]!, payload: frame.slice(HEADER_SIZE) };
}

export function buildJSONEnvelope(type: number, value: unknown) {
return buildEnvelope(type, encoder.encode(JSON.stringify(value)));
}

export function parseJSONPayload<T>(payload: Uint8Array): T {
return JSON.parse(decoder.decode(payload)) as T;
}

export function buildTerminalInput(terminalId: string | number, value: string | Uint8Array) {
const normalizedTerminalId = Number(terminalId);
if (!Number.isInteger(normalizedTerminalId) || normalizedTerminalId <= 0) {
throw new Error("A valid terminalId is required");
}

const data = typeof value === "string" ? encoder.encode(value) : value;
const payload = new Uint8Array(4 + data.byteLength);
new DataView(payload.buffer).setUint32(0, normalizedTerminalId, false);
payload.set(data, 4);
return buildEnvelope(ENVELOPE_TERMINAL_INPUT, payload);
}

export function parseTerminalPayload(payload: Uint8Array) {
if (payload.byteLength < 4) {
throw new Error("Terminal envelope payload is too short");
}

return {
terminalId: new DataView(payload.buffer, payload.byteOffset, payload.byteLength).getUint32(0, false),
data: payload.slice(4)
};
}

export function createRequestId(prefix = "request") {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
12 changes: 8 additions & 4 deletions packages/koko/app/composables/terminal/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ export enum TerminalWebSocketProtocol {

export interface TerminalIncomingMessage {
id: string;
type: MESSAGE_TYPE;
type: string;
data?: string;
err?: string;
raw?: string | number[];
terminalId?: number;
requestId?: string;
}

const messageTypes = new Set<string>(Object.values(MESSAGE_TYPE));
Expand All @@ -32,13 +34,15 @@ export function parseTerminalIncomingMessage(raw: unknown): TerminalIncomingMess
if (!raw || typeof raw !== "object") return null;
const message = raw as Record<string, unknown>;

if (typeof message.id !== "string" || !isTerminalMessageType(message.type)) return null;
if (typeof message.type !== "string" || !message.type) return null;

return {
id: message.id,
id: typeof message.id === "string" ? message.id : "",
type: message.type,
data: typeof message.data === "string" ? message.data : undefined,
err: typeof message.err === "string" ? message.err : undefined,
raw: typeof message.raw === "string" || Array.isArray(message.raw) ? message.raw : undefined
raw: typeof message.raw === "string" || Array.isArray(message.raw) ? message.raw : undefined,
terminalId: typeof message.terminalId === "number" ? message.terminalId : undefined,
requestId: typeof message.requestId === "string" ? message.requestId : undefined
};
}
Loading
Loading