blob: e4c50a487a9d6c82a6a780828ef18b98445a11ec [file]
// Copyright 2026 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package agentprocess reports and controls the live processes of a discovered
// agent installation.
package agentprocess
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
const (
listCacheTTL = 3 * time.Second
listTimeout = 10 * time.Second
)
// Target identifies one installation and says how it is run.
type Target struct {
AgentId string
Path string
Owner string
// Executable is the file to launch, empty when none was resolved.
Executable string
// Desktop marks a windowed app, which is started without a console.
Desktop bool
}
// Status is the run state shown beside an agent installation.
type Status struct {
Running bool `json:"running"`
Pids []int `json:"pids"`
CanStart bool `json:"canStart"`
Detail string `json:"detail,omitempty"`
}
// Process is one live process on the host. The account it runs as is left out
// on purpose: an installation found under one account is routinely started by
// another, so the owner would rule out processes that do belong to it.
type Process struct {
Pid int
Path string
Command string
}
var processCache = struct {
sync.Mutex
result []Process
withCommands bool
updatedAt time.Time
}{}
// StatusOf reports the processes belonging to one installation.
func StatusOf(target Target) Status {
return statusOf(target, needsCommands(target))
}
// Refresh drops the cached listing, so the next status is read from the host.
// Whatever was just started or stopped is only visible after that.
func Refresh() {
invalidate()
}
func statusOf(target Target, withCommands bool) Status {
status := Status{Pids: []int{}, CanStart: target.Executable != ""}
if !status.CanStart {
status.Detail = "no launcher was found for this installation"
}
for _, process := range snapshot(withCommands) {
if matches(target, process) {
status.Pids = append(status.Pids, process.Pid)
}
}
status.Running = len(status.Pids) > 0
return status
}
// Start launches the agent: a desktop app opens on its own, a CLI opens in a
// console window, since that is the only way it is usable.
func Start(target Target) error {
if target.Executable == "" {
return errors.New("no launcher was found for this installation")
}
err := start(target)
invalidate()
return err
}
// Stop ends every process of the installation. The console window a CLI was
// started in owns it, and only a command line names the agent inside that
// window, so stopping always reads them however the status was reported.
func Stop(target Target) error {
pids := statusOf(target, true).Pids
if len(pids) == 0 {
return nil
}
var failures []string
for _, pid := range pids {
if err := stop(pid); err != nil {
failures = append(failures, strconv.Itoa(pid)+": "+err.Error())
}
}
invalidate()
// A process that exited between the listing and the signal is not a failure
// worth reporting, so only a still-running one is.
if len(failures) > 0 && statusOf(target, true).Running {
return errors.New(strings.Join(failures, "; "))
}
return nil
}
// needsCommands reports whether the installation can only be recognised by a
// command line: a package manager records the package directory rather than a
// program, and what runs is the interpreter.
func needsCommands(target Target) bool {
return !samePath(target.Executable, target.Path)
}
// snapshot lists the host's processes, reusing a recent listing because one page
// load asks for the status of every installation. Reading command lines costs
// far more than reading image paths on some hosts, so it is only done for the
// installations that cannot be recognised without them.
func snapshot(withCommands bool) []Process {
processCache.Lock()
defer processCache.Unlock()
fresh := time.Since(processCache.updatedAt) < listCacheTTL
if fresh && (processCache.withCommands || !withCommands) {
return processCache.result
}
ctx, cancel := context.WithTimeout(context.Background(), listTimeout)
defer cancel()
processCache.result = list(ctx, withCommands)
processCache.withCommands = withCommands
processCache.updatedAt = time.Now()
return processCache.result
}
func invalidate() {
processCache.Lock()
processCache.updatedAt = time.Time{}
processCache.Unlock()
}
// matches recognises an installation among the running processes by its files:
// an agent installed by a package manager runs through an interpreter, so the
// process image is node rather than the agent, and only the command line names
// the installation.
func matches(target Target, process Process) bool {
if process.Pid == os.Getpid() {
return false
}
for _, candidate := range []string{target.Executable, target.Path} {
if candidate == "" {
continue
}
if samePath(process.Path, candidate) || containsPath(process.Command, candidate) {
return true
}
if isRelocatedCopy(candidate, process.Path) {
return true
}
}
return false
}
// isRelocatedCopy recognises the copy a self-updating desktop app runs: the
// installed file stays a stub beside one directory per version, and what starts
// is the same program inside the current one.
func isRelocatedCopy(installed, path string) bool {
if installed == "" || path == "" {
return false
}
if !sameName(filepath.Base(installed), filepath.Base(path)) {
return false
}
return isUnder(filepath.Dir(installed), path)
}
func sameName(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
// isUnder reports whether path sits somewhere below dir.
func isUnder(dir, path string) bool {
dir, path = filepath.Clean(dir), filepath.Clean(path)
if runtime.GOOS == "windows" {
dir, path = strings.ToLower(dir), strings.ToLower(path)
}
relative, err := filepath.Rel(dir, path)
if err != nil {
return false
}
return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
func samePath(left, right string) bool {
if left == "" || right == "" {
return false
}
left, right = filepath.Clean(left), filepath.Clean(right)
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func containsPath(command, path string) bool {
if command == "" || path == "" {
return false
}
path = filepath.Clean(path)
if textContains(command, path) {
return true
}
// A shim reaches its target through its own directory, so the command line
// spells the file with a relative hop the recorded installation has not.
for _, argument := range commandArguments(command) {
if textContains(filepath.Clean(argument), path) {
return true
}
}
return false
}
func textContains(text, path string) bool {
if runtime.GOOS == "windows" {
return strings.Contains(strings.ToLower(text), strings.ToLower(path))
}
return strings.Contains(text, path)
}
// commandArguments splits a command line the way its quoting reads, so an
// argument holding a path with spaces stays one string.
func commandArguments(command string) []string {
var arguments []string
for i, part := range strings.Split(command, `"`) {
if i%2 == 1 {
arguments = append(arguments, part)
continue
}
arguments = append(arguments, strings.Fields(part)...)
}
return arguments
}