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
2 changes: 1 addition & 1 deletion i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,7 @@
"AISend": "发送",
"AIYou": "你",
"AIAgentApprovalAlwaysShort": "始终审批",
"AIAgentApprovalAutoShort": "自动",
"AIAgentApprovalAutoShort": "自动审批",
"AIAgentApprovalNeverShort": "无需审批",
"AIAgentApprovalAlways": "每次工具调用前均要求审批",
"AIAgentApprovalAuto": "仅在 Agent 判定有必要时要求审批",
Expand Down
2 changes: 1 addition & 1 deletion i18n/locales/zh_hant.json
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,7 @@
"AITools": "工具",
"AICapabilities": "能力",
"AIAgentApprovalAlwaysShort": "始終審批",
"AIAgentApprovalAutoShort": "自動",
"AIAgentApprovalAutoShort": "自動審批",
"AIAgentApprovalNeverShort": "無需審批",
"AIAgentApprovalAlways": "每次工具呼叫前均要求審批",
"AIAgentApprovalAuto": "僅在 Agent 判定有必要時要求審批",
Expand Down
5 changes: 2 additions & 3 deletions ui/components/RightPanel/WorkspaceAssistantPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,7 @@ const targetLabel = computed(() => {
const label = displayedTarget.value?.label;
if (selectedTarget.value === "workspace")
return [t("RightPanel.LunaAiWorkspaceOnly"), label].filter(Boolean).join(" · ");
if (selectedTarget.value === "auto")
return [t("RightPanel.LunaAiAutomatic"), label || localShellPane.value?.assetName].filter(Boolean).join(" · ");
if (selectedTarget.value === "auto") return label || localShellPane.value?.assetName || t("RightPanel.AIContext");
return label || t("RightPanel.LunaAiTargetChanged");
});
const approvalOptions = computed<AiSelectOption[]>(() => [
Expand Down Expand Up @@ -360,7 +359,7 @@ watch(
:portal="true"
:content="{ side: 'top', align: 'start' }"
:ui="{
base: 'ps-6 pe-6',
base: 'ps-6 pe-6 text-[11px]',
leading: 'ps-1',
trailing: 'pe-1',
content: 'min-w-64 max-w-[calc(100vw-2rem)]',
Expand Down
3 changes: 2 additions & 1 deletion ui/components/RightPanel/ai/AiComposer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const model = defineModel<string>({ required: true });
:aria-label="$t('RightPanel.AIContext')"
:title="contextItems.map((item) => item.title).join('\n')"
class="min-w-16 max-w-full justify-start px-1"
:ui="{ base: 'text-[11px]' }"
>
<span class="min-w-0 truncate">{{ contextItems[0]?.label }}</span>
<span v-if="contextItems.length > 1" class="shrink-0 text-[10px] text-muted">
Expand Down Expand Up @@ -88,7 +89,7 @@ const model = defineModel<string>({ required: true });
:portal="true"
value-key="value"
label-key="label"
:ui="{ content: 'min-w-72', itemDescription: 'whitespace-normal' }"
:ui="{ base: 'text-[11px]', content: 'min-w-72', itemDescription: 'whitespace-normal' }"
@update:model-value="emit('updateApprovalThreshold', $event)"
/>
<USelect
Expand Down
6 changes: 4 additions & 2 deletions ui/components/RightPanel/ai/AiRunStep.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ const props = withDefaults(
executionOverrides: ReadonlyMap<string, string>;
executionMode: string;
approvalMode: string;
ptyExec?: boolean;
backgroundExec: boolean;
readOnly?: boolean;
}>(),
{ expanded: undefined }
{ expanded: undefined, ptyExec: true }
);

const emit = defineEmits<{
Expand Down Expand Up @@ -144,7 +145,7 @@ function statusIconClass() {
}

function selectedExecution(data: TerminalAiEventData) {
return terminalExecutionMode(props.executionOverrides.get(String(data.id)) || data.execution);
return terminalExecutionMode(props.executionOverrides.get(String(data.id)) || data.execution, props);
}

function executionLabel(value: unknown) {
Expand Down Expand Up @@ -247,6 +248,7 @@ function terminalRiskLabel(level: unknown) {
@click="emit('setExecutionOverride', String(execution.command?.id), 'auto')"
/>
<UButton
v-if="ptyExec"
size="xs"
color="neutral"
class="exec-group-btn"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ function setExecutionOverride(id: string, value: string) {
:execution-overrides="terminalSession.executionOverrides"
:execution-mode="terminalSession.executionMode"
:approval-mode="terminalSession.approvalMode"
:pty-exec="terminalSession.ptyExec"
:background-exec="terminalSession.backgroundExec"
:read-only="readOnly"
@decide="decide"
Expand Down
10 changes: 5 additions & 5 deletions ui/components/RightPanel/ai/domains/terminal/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ function handleDecision(session: KokoTerminalAiSession, action: Extract<AiTimeli
if (!decisionId || session.decisions.has(decisionId)) return;

const override = session.executionOverrides.get(decisionId);
const execution = terminalExecutionMode(override || action.data.execution);
const execution = terminalExecutionMode(override || action.data.execution, session);
const toolCallId = String(action.data.toolCallId || "");
if (action.approved && toolCallId && (override || execution !== "auto")) {
session.executionOverrides.set(toolCallId, execution);
Expand Down Expand Up @@ -174,7 +174,8 @@ export const terminalAiPanelDomain: AiPanelDomainAdapter = {
{
label: context.t("RightPanel.AIModePtyShort"),
description: context.t("RightPanel.AIModePty"),
value: "pty"
value: "pty",
disabled: !current.ptyExec
},
{
label: context.t("RightPanel.AIModeBackgroundShort"),
Expand Down Expand Up @@ -253,8 +254,7 @@ export const terminalAiPanelDomain: AiPanelDomainAdapter = {
updateExecutionMode(session, value) {
const current = terminalSession(session);
if (!current) return;
const mode = String(value || "auto");
current.executionMode = mode === "pty" || mode === "background" ? mode : "auto";
current.executionMode = terminalExecutionMode(value, current);
},

handleTimelineAction(session, action) {
Expand All @@ -265,7 +265,7 @@ export const terminalAiPanelDomain: AiPanelDomainAdapter = {
return;
}
if (action.type === "set-execution-override") {
current.executionOverrides.set(action.id, action.value);
current.executionOverrides.set(action.id, terminalExecutionMode(action.value, current));
return;
}
if (action.type === "set-step-expanded") {
Expand Down
4 changes: 1 addition & 3 deletions ui/composables/useAssetAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1205,9 +1205,7 @@ export const useAssetAction = () => {
getAssetDetailRequest(assetId)
.then((assetDetail) => {
const permedAccounts = assetDetail.permed_accounts ?? [];
const permedProtocols = (assetDetail.permed_protocols ?? []).filter(
(protocol: PermedProtocol) => protocol?.name !== "winrm"
);
const permedProtocols = assetDetail.permed_protocols ?? [];

useEventBus().emit("assetDetailUpdated", {
assetId,
Expand Down
4 changes: 1 addition & 3 deletions ui/composables/useConnectionFormState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,9 +348,7 @@ export function useConnectionFormState() {
return {
...asset,
permedAccounts: detail.permed_accounts ?? asset.permedAccounts ?? [],
permedProtocols: (detail.permed_protocols ?? asset.permedProtocols ?? []).filter(
(protocol: PermedProtocol) => protocol?.name !== "winrm"
)
permedProtocols: detail.permed_protocols ?? asset.permedProtocols ?? []
};
};

Expand Down
2 changes: 1 addition & 1 deletion ui/composables/useSessionWindowConnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export function toAdminAssetItem(
const item = transformAssetDetail(assetId, {
...asset,
permed_protocols: (asset.permed_protocols || asset.protocols || []).filter(
(protocol) => protocol?.name && protocol.name !== "winrm"
(protocol) => protocol?.name
) as AssetDetail["permed_protocols"],
permed_accounts: [
{
Expand Down
4 changes: 1 addition & 3 deletions ui/composables/useSidebarAssetActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,7 @@ export function useSidebarAssetActions() {
return {
...asset,
permedAccounts: detail.permed_accounts ?? asset.permedAccounts ?? [],
permedProtocols: (detail.permed_protocols ?? asset.permedProtocols ?? []).filter(
(protocol: { name?: string }) => protocol?.name !== "winrm"
)
permedProtocols: detail.permed_protocols ?? asset.permedProtocols ?? []
};
};

Expand Down
11 changes: 11 additions & 0 deletions ui/composables/useWorkspaceAssistantSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,17 @@ describe("Workspace Assistant capability", () => {
expect(() => workspaceAssistantConnectionChoices(asset, "ssh", "missing")).toThrow("not authorized");
});

it("offers an authorized WinRM connection to the assistant", () => {
const asset = {
permedProtocols: [{ name: "winrm", public: true }],
permedAccounts: [{ ...manualAccount, id: "windows-admin" }]
} as any;
expect(workspaceAssistantConnectionChoices(asset, "winrm", "windows-admin")).toMatchObject({
protocol: "winrm",
account: { id: "windows-admin" }
});
});

it("notifies the timeline for every shallow AI SDK message update", () => {
const message = {
id: "assistant-1",
Expand Down
2 changes: 1 addition & 1 deletion ui/composables/useWorkspaceAssistantSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ function normalizeCandidate(value: unknown) {

function uniqueProtocols(protocols: PermedProtocol[]) {
const names = protocols
.filter((protocol) => protocol?.name !== "winrm" && (isDesktopRuntime() || protocol?.public !== false))
.filter((protocol) => isDesktopRuntime() || protocol?.public !== false)
.map((protocol) => boundedString(protocol.name, 64).toLowerCase())
.filter(Boolean);
return [...new Set(names)];
Expand Down
1 change: 1 addition & 0 deletions ui/koko/composables/terminal/terminalCommandSuggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ const CASE_INSENSITIVE_PROFILES = new Set<KokoTerminalCommandProfile>([

export function resolveTerminalCommandProfile(profile: ConnectorTerminalProfile = {}): KokoTerminalCommandProfile {
const protocol = String(profile.protocol || "").toLowerCase();
if (protocol === "winrm") return "windows";
if (protocol === "mariadb" || protocol === "mysql") return "mysql";
if (protocol === "pg" || protocol === "postgres" || protocol === "postgresql") return "postgresql";
if (["redis", "mongodb", "oracle", "dameng", "sqlserver"].includes(protocol)) {
Expand Down
25 changes: 18 additions & 7 deletions ui/koko/composables/terminal/useTerminalAiSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ export interface KokoTerminalAiSessionOptions {
sendMcpFrame?: (frame: KokoMcpRequestFrame | KokoMcpCancelFrame) => void;
}

export function terminalExecutionMode(value: unknown) {
export function terminalExecutionMode(value: unknown, capabilities?: { ptyExec: boolean }) {
const mode = String(value || "auto").toLowerCase();
if (mode === "pty") return "pty";
if (mode === "pty") return capabilities?.ptyExec === false ? "auto" : "pty";
if (mode === "background" || mode === "background_exec") return "background";
return "auto";
}
Expand Down Expand Up @@ -65,6 +65,7 @@ export interface KokoTerminalAiSession {
connected: boolean;
enabled: boolean;
sessionInfoReady: boolean;
ptyExec: boolean;
backgroundExec: boolean;
backgroundReason: string;
backgroundReasonCode: string;
Expand Down Expand Up @@ -323,8 +324,12 @@ function createSession(
transformToolArguments: (toolCallId, toolName, argumentsValue) => {
if (!toolName.startsWith("execute_") || !isRecord(argumentsValue)) return argumentsValue;
const override = session?.executionOverrides.get(toolCallId);
const execution = terminalExecutionMode(override || session?.executionMode);
return execution === "auto" && !override ? argumentsValue : { ...argumentsValue, execution };
const selected = terminalExecutionMode(override || session?.executionMode);
const requested = selected === "auto" && !override ? argumentsValue.execution : selected;
const execution = terminalExecutionMode(requested, session);
return selected === "auto" && !override && execution === terminalExecutionMode(requested)
? argumentsValue
: { ...argumentsValue, execution };
},
sendFrame: (frame) => {
const target = session?.socket;
Expand Down Expand Up @@ -418,6 +423,7 @@ function createSession(
connected: socket.readyState === WebSocket.OPEN,
enabled: false,
sessionInfoReady: false,
ptyExec: true,
backgroundExec: false,
backgroundReason: "",
backgroundReasonCode: "",
Expand Down Expand Up @@ -653,7 +659,12 @@ export function handleKokoTerminalAiWireMessage(paneId: string, message: unknown
(tool) => tool._meta?.["com.jumpserver/toolKind"] === "command" || tool.name === "execute_command"
);
const executionModes = commandTool?._meta?.["com.jumpserver/executionModes"];
session.ptyExec = !Array.isArray(executionModes) || executionModes.includes("pty");
session.backgroundExec = Array.isArray(executionModes) && executionModes.includes("background");
session.executionMode = terminalExecutionMode(session.executionMode, session);
for (const [id, mode] of session.executionOverrides) {
session.executionOverrides.set(id, terminalExecutionMode(mode, session));
}
if (!session.backgroundExec && session.executionMode === "background") session.executionMode = "auto";
void session.agent.actions.attachManifest(manifestFromFrame(frame)).catch((error) => {
session.errorCode = "agent_unavailable";
Expand Down Expand Up @@ -685,7 +696,7 @@ export function handleKokoTerminalAiMessage(paneId: string, message: unknown) {
session.backgroundReasonCode = String(capability.backgroundReasonCode || capability.reasonCode || "");
session.approvalThreshold = Number(capability.approvalThreshold) || 2;
if (capability.executionMode !== undefined) {
session.executionMode = terminalExecutionMode(capability.executionMode);
session.executionMode = terminalExecutionMode(capability.executionMode, session);
}
if (!session.enabled) {
session.inputLocked = false;
Expand Down Expand Up @@ -774,7 +785,7 @@ export function handleKokoTerminalAiMessage(paneId: string, message: unknown) {
const policy = partData(message, "data-policy");
if (policy) {
session.approvalThreshold = Number(policy.approvalThreshold) || session.approvalThreshold;
session.executionMode = String(policy.executionMode || session.executionMode);
session.executionMode = terminalExecutionMode(policy.executionMode || session.executionMode, session);
return;
}

Expand Down Expand Up @@ -821,7 +832,7 @@ export function sendKokoTerminalAiControl(paneId: string, message: TerminalAiCha
session.errorText = error instanceof Error ? error.message : "Failed to update approval mode";
});
}
session.executionMode = terminalExecutionMode(data.executionMode || session.executionMode);
session.executionMode = terminalExecutionMode(data.executionMode || session.executionMode, session);
handled = true;
}
if (part.type === "data-approval" || part.type === "data-metadata-approval") {
Expand Down
31 changes: 31 additions & 0 deletions ui/koko/tests/terminal/terminalAiSessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,37 @@ it("derives background execution availability from the command tool manifest", a
);

expect(session.backgroundExec).toBe(true);
expect(session.ptyExec).toBe(true);
});

it("honors supported terminal modes when approving commands and relaying model calls", async () => {
const session = createSession("native-command-modes");
const sendMcpFrame = vi.fn();
registerKokoTerminalAiSession(session.paneId, session.socket!, session.terminalId, { sendMcpFrame });
session.executionMode = "pty";
session.executionOverrides.set("old-call", "pty");
const resourceId = await agentHarness.attach(handleKokoTerminalAiWireMessage, session.paneId, "terminal", undefined, [
{ name: "execute_command", inputSchema: {}, _meta: { "com.jumpserver/executionModes": ["auto", "background"] } }
]);
expect(session).toMatchObject({ ptyExec: false, backgroundExec: true, executionMode: "auto" });
expect(session.executionOverrides.has("old-call")).toBe(false);
const context = { paneId: session.paneId, surface: null, now: 0, t: (key: string) => key };
terminalAiPanelDomain.handleTimelineAction?.(
session,
{ domain: "terminal", type: "set-execution-override", id: "approval-1", value: "pty" },
context
);
expect(session.executionOverrides.get("approval-1")).toBe("auto");
expect(
terminalAiPanelDomain.describe(session, context, []).modeOptions.find((option) => option.value === "pty")?.disabled
).toBe(true);
agentHarness.emit(resourceId, {
type: "tool.call",
run_id: "run-1",
tool_call_id: "tool-1",
payload: { tool_name: "execute_command", arguments: { command: "Get-Location", execution: "pty" } }
});
expect(sendMcpFrame.mock.calls[0]?.[0].data.params.arguments).toEqual({ command: "Get-Location", execution: "auto" });
});

it("queues prompts through the enabled pane and rejects unavailable sessions", async () => {
Expand Down
1 change: 1 addition & 0 deletions ui/koko/tests/terminal/terminalCommandSuggestions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ describe("terminal command suggestions", () => {
it("resolves SSH platforms and database protocols", () => {
expect(resolveTerminalCommandProfile({ protocol: "ssh", assetPlatform: "Linux" })).toBe("linux");
expect(resolveTerminalCommandProfile({ protocol: "ssh", assetPlatform: "Windows Server" })).toBe("windows");
expect(resolveTerminalCommandProfile({ protocol: "winrm" })).toBe("windows");
expect(resolveTerminalCommandProfile({ protocol: "mariadb" })).toBe("mysql");
expect(resolveTerminalCommandProfile({ protocol: "pg" })).toBe("postgresql");
expect(resolveTerminalCommandProfile({ protocol: "redis" })).toBe("redis");
Expand Down
8 changes: 8 additions & 0 deletions ui/shared/connectors/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,11 @@ describe("Dameng workspace capabilities", () => {
expect(findDeclaredCapability("dameng", WEB_DB_NATIVE_VALUE)?.component).toBe("chen");
});
});

it("routes WinRM through Koko's built-in terminal", () => {
expect(findDeclaredCapability("winrm", WEB_CLI_NATIVE_VALUE)).toMatchObject({
component: "koko",
surface: "terminal",
backendConnectMethod: "web_cli"
});
});
1 change: 1 addition & 0 deletions ui/shared/connectors/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const COMPONENT_WORKSPACE_CAPABILITIES: WorkspaceCapabilityDeclaration[]
protocols: [
"ssh",
"telnet",
"winrm",
"clickhouse",
"dameng",
"mariadb",
Expand Down
2 changes: 1 addition & 1 deletion ui/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function transformAssetDetail(assetId: string, detail: AssetDetail): Asse
category: assetDetailFieldValue(detail.category),
type: assetDetailFieldValue(detail.type),
permedAccounts: detail.permed_accounts ?? [],
permedProtocols: (detail.permed_protocols ?? []).filter((protocol) => protocol?.name !== "winrm")
permedProtocols: detail.permed_protocols ?? []
};
}

Expand Down
Loading