001/* 002 * SPDX-License-Identifier: Apache-2.0 003 * 004 * Copyright 2025-2026 The Enola <https://enola.dev> Authors 005 * 006 * Licensed under the Apache License, Version 2.0 (the "License"); 007 * you may not use this file except in compliance with the License. 008 * You may obtain a copy of the License at 009 * 010 * https://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018package dev.enola.chat; 019 020import static java.nio.file.Files.*; 021 022import com.google.common.collect.ImmutableList; 023import com.google.common.collect.ImmutableMap; 024import com.google.common.collect.ImmutableSet; 025 026import dev.enola.common.exec.ExecPATH; 027import dev.enola.common.exec.vorburger.ExpectedExitCode; 028import dev.enola.common.exec.vorburger.Runner; 029import dev.enola.common.exec.vorburger.VorburgerExecRunner; 030import dev.enola.common.io.resource.ClasspathResource; 031import dev.enola.common.linereader.IO; 032import dev.enola.identity.Hostnames; 033import dev.enola.identity.Subject; 034 035import org.slf4j.Logger; 036import org.slf4j.LoggerFactory; 037 038import java.io.File; 039import java.io.IOException; 040import java.nio.file.Path; 041import java.time.Duration; 042import java.util.*; 043 044public class ExecAgent extends AbstractAgent { 045 046 // TODO Must check if command is a path or on the PATH 047 048 // TODO If PATH contains "." then this won't really work yet as-is 049 050 // TODO Offer tab completion of all available commands in Chat 051 // see https://jline.org/docs/tab-completion 052 053 // TODO Ctrl-R FZF History Search Widget; see https://github.com/jline/jline3/issues/1246 054 055 // TODO cd without argument should CWD to $HOME 056 // TODO implicit cd with dirname (UNLESS it's also a command on PATH), like Fish Shell does 057 058 // TODO Support "who am i"; see https://github.com/vorburger/ch.vorburger.exec/issues/269 059 060 private static final Logger LOG = LoggerFactory.getLogger(ExecAgent.class); 061 062 private final Runner runner; 063 private final Map<String, File> executablesMap; 064 private final List<String> executables; 065 private final Set<String> commandWords = loadCommandWords(); 066 067 private Path cwd = Path.of("."); 068 private final String forceExecPrefix; 069 070 /** 071 * Constructor. 072 * 073 * @param pbx the PBX 074 * @param runner the exec runner 075 * @param executablesMap the executables on PATH 076 * @param forceExecPrefix the prefix to force execution of a command, e.g. "$ " or "!", or 077 * whatever. (Note that <a href="https://github.com/jline/jline3/issues/1218">JLine handles 078 * "!" as history expansion, so that needs to disabled</a>.) 079 */ 080 public ExecAgent( 081 Switchboard pbx, 082 Runner runner, 083 Map<String, File> executablesMap, 084 String forceExecPrefix) { 085 super( 086 tbf.create(Subject.Builder.class, Subject.class) 087 .iri("http://" + Hostnames.LOCAL) 088 .label(Hostnames.LOCAL) 089 .comment("Executes Commands on " + Hostnames.LOCAL) 090 .build(), 091 pbx); 092 this.runner = runner; 093 this.executablesMap = ImmutableMap.copyOf(executablesMap); // skipcq: JAVA-E1086 094 // skipcq: JAVA-E1086 095 this.executables = ImmutableList.copyOf(executablesMap.keySet().stream().sorted().toList()); 096 this.forceExecPrefix = forceExecPrefix; 097 } 098 099 public ExecAgent(Switchboard pbx, IO io) { 100 this(pbx, new VorburgerExecRunner(), ExecPATH.scan(), "$ "); 101 } 102 103 @Override 104 public void accept(Message message) { 105 // Skip processing self-replies from itself; this might need some more thought? 106 if (message.from().iri().equals(subject().iri())) return; 107 108 // /commands is inspired e.g. by Fish's "command --all", 109 // see https://fishshell.com/docs/current/cmds/command.html 110 if (handle(message, "/commands", () -> reply(message, String.join("\n", executables)))) 111 return; 112 113 if (handle(message, "cd", this::cd)) return; 114 115 // See https://github.com/enola-dev/enola/issues/1354 116 var potentialCommand = message.content(); 117 if (potentialCommand.startsWith(forceExecPrefix) 118 && potentialCommand.length() > forceExecPrefix.length()) { 119 execute(potentialCommand.substring(forceExecPrefix.length()), false, message); 120 return; 121 } 122 123 execute(potentialCommand, true, message); 124 } 125 126 private void cd(String path) { 127 cwd = cwd.resolve(path.trim()); 128 } 129 130 private void execute(String potentialCommand, boolean checkCommandWords, Message replyTo) { 131 var executable = executable(potentialCommand); 132 var executablePath = cwd.resolve(executable); 133 if (!executable.startsWith("/") 134 && !executable.startsWith("./") 135 && !executable.startsWith("../")) { 136 if (checkCommandWords && commandWords.contains(executable)) return; 137 var executableFile = executablesMap.get(executable); 138 if (executableFile == null) { 139 LOG.info("Unknown executable: {}", executable); 140 return; 141 } 142 } else if (!isRegularFile(executablePath) 143 || !isReadable(executablePath) 144 || !isExecutable(executablePath)) return; 145 146 // TODO Allow running without timeout? 147 var timeout = Duration.ofDays(1); 148 149 // TODO Support streaming outputBuilder into Chat (see also LangChain4jAgent) 150 var outputBuilder = new StringBuilder(); 151 try { 152 // TODO Feedback exitCode also to Chat (somehow; but how?!) 153 // Well, just like in Bash/Fish, with Emoji emoji (😊 for success, 😞 for failure) 154 // in the NEXT prompt... how how to "generalize" this here? 155 // TODO Run command in $SHELL instead of hard-coding bash -c. 156 var exitCode = 157 runner.bash( 158 ExpectedExitCode.SUCCESS, 159 cwd, 160 potentialCommand, 161 outputBuilder, 162 timeout); 163 LOG.debug("Executed: {} => {}", potentialCommand, exitCode); 164 var output = outputBuilder.toString(); 165 if (!output.trim().isEmpty()) { 166 reply(replyTo, output); 167 } 168 169 } catch (Exception e) { 170 LOG.warn("Failed to execute: {}", potentialCommand, e); 171 reply( 172 replyTo, 173 "Failed to execute: " + executable + "; due to " + e.getMessage() + "\n"); 174 } 175 } 176 177 private String executable(String messageContent) { 178 String command; 179 var idx = messageContent.indexOf(' '); 180 if (idx == -1) { 181 command = messageContent.trim(); 182 } else { 183 command = messageContent.substring(0, idx).trim(); 184 } 185 186 idx = command.indexOf(';'); 187 if (idx > -1) { 188 command = command.substring(0, idx).trim(); 189 } 190 191 idx = command.indexOf('|'); 192 if (idx > -1) { 193 command = command.substring(0, idx).trim(); 194 } 195 196 idx = command.indexOf("&&"); 197 if (idx > -1) { 198 command = command.substring(0, idx + 1).trim(); 199 } 200 201 idx = command.indexOf('<'); 202 if (idx > -1) { 203 command = command.substring(0, idx).trim(); 204 } 205 206 idx = command.indexOf('>'); 207 if (idx > -1) { 208 command = command.substring(0, idx).trim(); 209 } 210 211 return command; 212 } 213 214 // See https://github.com/enola-dev/enola/issues/1354 215 private Set<String> loadCommandWords() { 216 var builder = ImmutableSet.<String>builder(); 217 try { 218 new ClasspathResource("command-words.txt").charSource().forEachLine(builder::add); 219 } catch (IOException e) { 220 throw new IllegalStateException("Processing /command-words.txt failed?!", e); 221 } 222 return builder.build(); 223 } 224}