diff --git a/CHANGELOG.md b/CHANGELOG.md index 11fea3d..fc867ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.4.7-session-routing-dev (source) + +- Added Linux `execution_context=auto|service|user|desktop|system` routing to `process_exec` and `process_spawn`. +- Added a desktop-session bridge that is launched by the graphical login session so GUI/Polkit/portal/keyring/clipboard children inherit the real login-session cgroup instead of `cycomagent@USER.service`; the socket checks same UID/PID credentials and the peer CyComAgent executable. +- Kept `system` execution behind `privileged=true`, central policy, and the existing Linux root broker. +- Added conservative auto-routing for known session-sensitive commands while leaving unknown/headless commands in the service context. +- Added XDG autostart packaging for the login-session bridge plus bridge status/capability reporting. +- Added durable external-job tracking for processes launched through the session bridge. +- Added bridge, routing, timeout, job-persistence, and peer/session regression coverage. + ## 0.4.6-anyapp-dev (source) - Added optional Linux `anyapp_*` tools backed by a persistent standalone MCP helper. diff --git a/Makefile b/Makefile index 3e778a0..e1d843e 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ BINARY := cycomagent -VERSION ?= 0.4.6-anyapp-dev +VERSION ?= 0.4.7-session-routing-dev GO ?= go LDFLAGS := -s -w -X main.version=$(VERSION) STAGE := .release/CyComAgent-MCP diff --git a/README.md b/README.md index 178f670..fa392ca 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ CyComAgent-MCP is an AI-native computer runtime that exposes a real machine — and optional remote machines — as a compact set of composable MCP primitives. The model supplies the reasoning; CyComAgent supplies filesystem, process, persistent job, service, network, desktop, durable state, multi-machine target, policy, audit, plugin, and privileged-execution capabilities. -`v0.4.6-anyapp-dev` adds an optional Linux Any App bridge with native KDE Wayland window control, accessibility, screenshots, and targeted input. The helper runs independently of the ChatGPT application. Existing macOS, Termux, and Windows paths remain available. This is the source development version; the curl bootstrap continues to install the published `v0.4.5-macos-dev` release until new release assets are published. +`v0.4.7-session-routing-dev` adds Linux execution-context routing so GUI/session-sensitive commands can be born inside the active graphical login session instead of inheriting the CyComAgent system-service cgroup. The existing optional Linux Any App bridge, macOS, Termux, and Windows paths remain available. This is the source development version; the curl bootstrap continues to install the latest published release until new release assets are published. ## What “Full Power” means @@ -65,19 +65,27 @@ Application state is explicit (`job_*`, `state_*`, `target_*`) rather than hidde `process_exec`, `process_spawn`, `process_inspect`, `process_signal` -`process_exec` is the universal escape hatch and now accepts optional UTF-8 `stdin`. Commands run in a process group so timeout/cancellation kills the group instead of leaving descendants behind. +`process_exec` is the universal escape hatch and accepts optional UTF-8 `stdin`. Commands run in a process group so timeout/cancellation kills the group instead of leaving descendants behind. On Linux it also accepts `execution_context=auto|service|user|desktop|system`: `desktop`/`user` route through the active login-session bridge, `service` stays in the CyComAgent service, and `system` requires `privileged=true` so the existing policy/root-broker boundary remains authoritative. `auto` is conservative and only routes a small set of known session-sensitive commands automatically; callers should explicitly request `desktop` for arbitrary GUI programs. ### Persistent jobs — 5 `job_list`, `job_get`, `job_tail`, `job_signal`, `job_prune` -`process_spawn` persists job metadata/logs on disk. If the runtime dies while the process continues, a new runtime instance rediscovers it. +`process_spawn` persists job metadata/logs on disk. It accepts the same non-root execution contexts; desktop/user jobs are launched by the login-session bridge while the normal job store still owns their PID, log and signalling metadata. If the runtime dies while a process continues, a new runtime instance rediscovers the PID metadata. ### System / network / capability — 8 `system_info`, `system_env`, `service_control`, `network_request`, `network_tcp`, `network_resolve`, `capabilities_list`, `plugins_reload` -`capabilities_list` includes detected binaries, adapter availability/scores, policy, plugins, root broker, and targets. +`capabilities_list` includes detected binaries, adapter availability/scores, policy, plugins, root broker, targets, and desktop-session-bridge status. + +### Linux desktop-session bridge + +The system runtime intentionally remains a boot-time service, but GUI programs, Polkit actions, desktop portals, notifications, keyrings and compositor/clipboard commands sometimes must be *born* in the active graphical login session. Copying `DISPLAY`, `WAYLAND_DISPLAY` or the session D-Bus address into a system service does not change its logind/systemd session cgroup, so it is not sufficient for those cases. The bridge refuses to start unless its own cgroup contains a real `session-N.scope`, preventing a copied environment from being mistaken for session identity. + +`cycomagent --session-bridge` is a small per-login-session Unix-socket helper. The system installer places an XDG autostart entry at `/etc/xdg/autostart/cycomagent-session-bridge.desktop`; desktop environments that honor XDG autostart *and preserve the login-session cgroup* can launch it automatically. If an environment delegates XDG autostart applications into `user@UID.service`, or on minimal compositors such as Labwc, start the same command from the compositor/session autostart file instead. The socket is private to the user and verifies the connecting UID/PID with `SO_PEERCRED`, then requires the peer executable to resolve to the same CyComAgent binary as the bridge. + +When the bridge is absent, service/headless execution keeps working normally. Explicit `desktop`/`user` execution returns a clear unavailable error instead of silently falling back into the wrong cgroup. ### Desktop — 2 diff --git a/RELEASE_NOTES_v0.4.7-session-routing-dev.md b/RELEASE_NOTES_v0.4.7-session-routing-dev.md new file mode 100644 index 0000000..97f0481 --- /dev/null +++ b/RELEASE_NOTES_v0.4.7-session-routing-dev.md @@ -0,0 +1,23 @@ +# v0.4.7-session-routing-dev - Linux Execution Context Routing + +This source development update separates **where CyComAgent decides a command should execute** from the long-lived MCP service that requested it. + +## What changed + +- `process_exec` and `process_spawn` accept `execution_context=auto|service|user|desktop|system`. +- Linux `desktop` and `user` execution routes through `cycomagent --session-bridge`, a per-login-session helper started by the graphical desktop. +- Session-routed children inherit the real login-session cgroup and desktop environment, fixing operations whose authorization depends on active-session identity (for example Polkit power-profile switching). +- `system` execution still requires `privileged=true` and remains behind central policy plus the Linux root broker. +- `auto` stays conservative: it recognizes a small set of well-known session-sensitive commands and otherwise remains in the system-service execution context. +- Desktop/user background jobs remain visible through `job_*` using durable PID/log metadata. +- `system_info` and `capabilities_list` report the desktop-session bridge and its session/cgroup metadata. + +## Bridge lifecycle + +The systemd installer places `/etc/xdg/autostart/cycomagent-session-bridge.desktop`. Full desktop environments normally launch it on graphical login. Minimal compositors that do not consume XDG autostart entries should start `/usr/local/bin/cycomagent --session-bridge` from their compositor/session autostart. + +The bridge refuses to start unless its own cgroup contains a real `session-N.scope`, so copying graphical environment variables into a service cannot impersonate a login session. Its socket is user-private (`0600`), validates the peer UID/PID with Linux `SO_PEERCRED`, and requires the peer executable to resolve to the same CyComAgent binary as the bridge. If no graphical bridge exists, normal service/headless execution is unaffected; explicit desktop/user requests fail clearly instead of silently launching in the wrong cgroup. + +## Validation + +The test suite covers synchronous session execution, environment/stdin forwarding, process-group timeout cleanup, background log handling, execution-context resolution, and durable external-job persistence. Linux, macOS and Android/Termux builds continue to share the portable core; the actual session bridge is Linux-only in this development version. diff --git a/cmd/cycomagent/main.go b/cmd/cycomagent/main.go index 057f23a..919cf6c 100644 --- a/cmd/cycomagent/main.go +++ b/cmd/cycomagent/main.go @@ -15,9 +15,10 @@ import ( "github.com/Cytech-Team/CyComAgent-MCP/internal/platform" "github.com/Cytech-Team/CyComAgent-MCP/internal/runtime" + "github.com/Cytech-Team/CyComAgent-MCP/internal/sessionbridge" ) -var version = "0.4.6-anyapp-dev" +var version = "0.4.7-session-routing-dev" func main() { mode := flag.String("mode", envOr("CYCOM_MODE", "http"), "transport: http or stdio") @@ -28,12 +29,23 @@ func main() { token := flag.String("token", envOr("CYCOM_TOKEN", ""), "optional bearer/X-CyCom-Token for HTTP MCP") strict := flag.Bool("strict-mcp", runtime.EnvBool("CYCOM_STRICT_MCP", false), "strictly validate modern MCP routing/version headers") allowRemote := flag.Bool("allow-unauthenticated-remote", runtime.EnvBool("CYCOM_ALLOW_UNAUTHENTICATED_REMOTE", false), "allow non-loopback HTTP bind without CYCOM_TOKEN") + sessionBridge := flag.Bool("session-bridge", false, "run the per-login-session execution bridge instead of the MCP runtime") + sessionSocket := flag.String("session-socket", envOr("CYCOM_SESSION_SOCKET", sessionbridge.DefaultSocketPath()), "desktop session bridge unix socket") showVer := flag.Bool("version", false, "print version and exit") flag.Parse() if *showVer { fmt.Println(version) return } + if *sessionBridge { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + log.Printf("CyComAgent desktop session bridge listening on %s", *sessionSocket) + if err := sessionbridge.Run(ctx, *sessionSocket); err != nil { + log.Fatal(err) + } + return + } if *pluginDir == "" { *pluginDir = filepath.Join(*stateDir, "plugins.d") } @@ -46,7 +58,7 @@ func main() { } else if goruntime.GOOS == "darwin" { instructions = "CyComAgent is running in experimental macOS mode. The portable Go core is available; launchd, screencapture and AppleScript/cliclick adapters may require normal macOS TCC permissions. Local privileged execution is intentionally unavailable until a native macOS privilege broker exists." } else if goruntime.GOOS == "linux" { - instructions = "CyComAgent is running in Linux computer_runtime mode. When anyapp_* tools are available, prefer anyapp_get_app_state plus semantic/window-targeted anyapp actions for GUI work; use desktop_capture/desktop_input as generic fallback tools. Use structured filesystem/process/service/network tools for non-GUI operations." + instructions = "CyComAgent is running in Linux computer_runtime mode. When anyapp_* tools are available, prefer anyapp_get_app_state plus semantic/window-targeted anyapp actions for GUI work; use desktop_capture/desktop_input as generic fallback tools. For process_exec/process_spawn, use execution_context=desktop when launching GUI apps or commands that need the active login session (Polkit, notifications, portals, keyrings, clipboard/compositor access); use service for daemon/headless work. Use structured filesystem/process/service/network tools for non-GUI operations." } rt, err := runtime.New(runtime.Config{Version: version, StateDir: *stateDir, PluginDir: *pluginDir, RootSocket: *rootSocket, StrictMCP: *strict, Instructions: instructions}) if err != nil { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b87720e..d24e228 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -72,6 +72,25 @@ OS / OpenSSH / external binaries / privilege broker Policy and audit are registry-wide rather than being reimplemented by each tool. +## Execution-context routing + +The Linux MCP runtime remains a system service for boot-time availability and recovery, but not every child process belongs in that service cgroup. Session-sensitive commands can be routed through a small bridge that is itself started by the graphical login session: + +```text +cycomagent@USER.service + | + | Unix socket, same UID only + v +cycomagent --session-bridge +(user.slice/.../session-N.scope) + | + +--> GUI app / Polkit action / portal / notification / keyring +``` + +`process_exec` and `process_spawn` expose `execution_context` with `auto`, `service`, `user`, `desktop`, and `system`. The `desktop` and current Linux `user` paths use the login-session bridge; `service` preserves the existing runtime behavior; and `system` requires `privileged=true` and therefore still passes through policy plus the root broker. `auto` only recognizes a conservative set of well-known session-sensitive commands and otherwise stays in `service`. + +The bridge inherits the real graphical environment and, more importantly, the real logind/systemd session cgroup. It does not attempt to manufacture desktop identity by copying environment variables, and startup is rejected when the bridge's own cgroup has no `session-N.scope`. Its Unix socket is mode `0600`; the bridge verifies peer UID/PID with `SO_PEERCRED` and requires the peer executable to resolve to the same CyComAgent binary before accepting a request. + ## Capability-driven adapters `capabilities_list` reports candidate adapters and availability/score. The model can choose among generic capabilities based on the actual machine rather than assuming one OS/toolchain. diff --git a/docs/STABILITY.md b/docs/STABILITY.md index 9c1063c..af9dfc0 100644 --- a/docs/STABILITY.md +++ b/docs/STABILITY.md @@ -14,6 +14,8 @@ The engineering goal is stronger and measurable: | tunnel-client restart | CyComAgent remains alive | | CyComAgent crash | systemd restarts runtime | | root broker crash | systemd restarts broker; non-root tools remain usable | +| desktop-session bridge absent/crashed | service/headless execution remains usable; explicit desktop/user execution fails clearly until the login-session bridge returns | +| graphical logout/login | old bridge disappears with the login session; the next desktop autostart creates the new session bridge/socket | | temporary network outage | local runtime remains ready | | reboot | runtime/root/tunnel services start without GUI login | | long job + MCP reconnect | job remains addressable by ID | @@ -43,7 +45,8 @@ A release intended for unattended deployment should pass: 5. temporary network loss and return; 6. concurrent file/process tool calls; 7. command timeouts and large output caps; -8. persistent job recovery. +8. persistent job recovery; +9. desktop-session bridge routing verifies child cgroup/session identity and preserves Polkit-visible active-session behavior. `scripts/chaos-test.sh` automates the process-kill subset. diff --git a/internal/jobs/manager.go b/internal/jobs/manager.go index cf8fe0b..33e560b 100644 --- a/internal/jobs/manager.go +++ b/internal/jobs/manager.go @@ -21,19 +21,20 @@ import ( ) type Job struct { - ID string `json:"id"` - Command string `json:"command"` - Cwd string `json:"cwd,omitempty"` - Env map[string]string `json:"env,omitempty"` - Shell string `json:"shell"` - PID int `json:"pid"` - PGID int `json:"pgid,omitempty"` - Status string `json:"status"` - ExitCode *int `json:"exit_code,omitempty"` - LogFile string `json:"log_file"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Error string `json:"error,omitempty"` + ID string `json:"id"` + Command string `json:"command"` + Cwd string `json:"cwd,omitempty"` + Env map[string]string `json:"env,omitempty"` + Shell string `json:"shell"` + PID int `json:"pid"` + PGID int `json:"pgid,omitempty"` + Status string `json:"status"` + ExitCode *int `json:"exit_code,omitempty"` + LogFile string `json:"log_file"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Error string `json:"error,omitempty"` + ExecutionContext string `json:"execution_context,omitempty"` } type Manager struct { @@ -56,12 +57,19 @@ func New(stateDir string) (*Manager, error) { } func (m *Manager) Start(command, cwd, shell string, env map[string]string) (*Job, error) { + return m.StartWithContext(command, cwd, shell, env, "service") +} + +func (m *Manager) StartWithContext(command, cwd, shell string, env map[string]string, executionContext string) (*Job, error) { if strings.TrimSpace(command) == "" { return nil, fmt.Errorf("command is required") } if shell == "" { shell = platform.DefaultShell() } + if strings.TrimSpace(executionContext) == "" { + executionContext = "service" + } id := fmt.Sprintf("job-%d-%d", time.Now().UnixMilli(), m.seq.Add(1)) logFile := filepath.Join(m.dir, id+".log") logf, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) @@ -81,46 +89,122 @@ func (m *Manager) Start(command, cwd, shell string, env map[string]string) (*Job return nil, err } pgid, _ := syscall.Getpgid(cmd.Process.Pid) + j := m.recordStartedJob(id, command, cwd, shell, env, executionContext, logFile, cmd.Process.Pid, pgid) + + go func() { + err := cmd.Wait() + _ = logf.Close() + m.finishJob(id, err) + }() + return clone(j), nil +} + +// StartExternal registers a process launched by another execution domain (for +// example the graphical session bridge). The durable job store still owns the +// metadata, logs and signalling surface even though the runtime is not the +// process parent and therefore cannot recover an exact exit status. +func (m *Manager) StartExternal(command, cwd, shell string, env map[string]string, executionContext string, launch func(logFile string) (pid, pgid int, err error)) (*Job, error) { + if strings.TrimSpace(command) == "" { + return nil, fmt.Errorf("command is required") + } + if launch == nil { + return nil, fmt.Errorf("external job launcher is required") + } + if shell == "" { + shell = platform.DefaultShell() + } + if strings.TrimSpace(executionContext) == "" { + executionContext = "user" + } + id := fmt.Sprintf("job-%d-%d", time.Now().UnixMilli(), m.seq.Add(1)) + logFile := filepath.Join(m.dir, id+".log") + // Create the log with the same private mode before handing the path to the + // session bridge. This also fails early if the state directory is unusable. + f, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return nil, err + } + if err := f.Close(); err != nil { + return nil, err + } + pid, pgid, err := launch(logFile) + if err != nil { + _ = os.Remove(logFile) + return nil, err + } + if pid <= 0 { + _ = os.Remove(logFile) + return nil, fmt.Errorf("external launcher returned invalid pid %d", pid) + } + j := m.recordStartedJob(id, command, cwd, shell, env, executionContext, logFile, pid, pgid) + go m.monitorExternal(id, pid) + return clone(j), nil +} + +func (m *Manager) recordStartedJob(id, command, cwd, shell string, env map[string]string, executionContext, logFile string, pid, pgid int) *Job { now := time.Now().UTC() j := &Job{ ID: id, Command: command, Cwd: cwd, Env: env, Shell: shell, - PID: cmd.Process.Pid, PGID: pgid, Status: "running", LogFile: logFile, - CreatedAt: now, UpdatedAt: now, + PID: pid, PGID: pgid, Status: "running", LogFile: logFile, + CreatedAt: now, UpdatedAt: now, ExecutionContext: executionContext, } m.mu.Lock() m.jobs[id] = j _ = m.persistLocked(j) m.mu.Unlock() + return j +} - go func() { - err := cmd.Wait() - _ = logf.Close() - m.mu.Lock() - defer m.mu.Unlock() - current := m.jobs[id] - if current == nil { - return +func (m *Manager) finishJob(id string, err error) { + m.mu.Lock() + defer m.mu.Unlock() + current := m.jobs[id] + if current == nil { + return + } + current.UpdatedAt = time.Now().UTC() + code := 0 + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + code = ee.ExitCode() + } else { + code = -1 + current.Error = err.Error() } - current.UpdatedAt = time.Now().UTC() - code := 0 - if err != nil { - var ee *exec.ExitError - if errors.As(err, &ee) { - code = ee.ExitCode() + } + current.ExitCode = &code + if current.Status == "terminating" { + current.Status = "terminated" + } else { + current.Status = "exited" + } + _ = m.persistLocked(current) +} + +func (m *Manager) monitorExternal(id string, pid int) { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for range ticker.C { + if processAlive(pid) { + continue + } + m.mu.Lock() + j := m.jobs[id] + if j != nil && (j.Status == "running" || j.Status == "running-recovered" || j.Status == "terminating") { + code := -1 + j.ExitCode = &code + if j.Status == "terminating" { + j.Status = "terminated" } else { - code = -1 - current.Error = err.Error() + j.Status = "exited-external" } + j.UpdatedAt = time.Now().UTC() + _ = m.persistLocked(j) } - current.ExitCode = &code - if current.Status == "terminating" { - current.Status = "terminated" - } else { - current.Status = "exited" - } - _ = m.persistLocked(current) - }() - return clone(j), nil + m.mu.Unlock() + return + } } func (m *Manager) List() []*Job { diff --git a/internal/jobs/manager_test.go b/internal/jobs/manager_test.go index 9100d8c..752e50c 100644 --- a/internal/jobs/manager_test.go +++ b/internal/jobs/manager_test.go @@ -1,10 +1,76 @@ package jobs import ( + "os" + "os/exec" + "syscall" "testing" "time" ) +func TestExternalJobPersistsAndCompletes(t *testing.T) { + dir := t.TempDir() + m, err := New(dir) + if err != nil { + t.Fatal(err) + } + j, err := m.StartExternal("printf external-ok", "", "/bin/sh", nil, "desktop", func(logFile string) (int, int, error) { + f, err := os.OpenFile(logFile, os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return 0, 0, err + } + cmd := exec.Command("/bin/sh", "-lc", "printf external-ok") + cmd.Stdout, cmd.Stderr = f, f + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + _ = f.Close() + return 0, 0, err + } + pgid, _ := syscall.Getpgid(cmd.Process.Pid) + go func() { + _ = cmd.Wait() + _ = f.Close() + }() + return cmd.Process.Pid, pgid, nil + }) + if err != nil { + t.Fatal(err) + } + if j.ExecutionContext != "desktop" { + t.Fatalf("execution_context=%q", j.ExecutionContext) + } + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + cur, err := m.Get(j.ID) + if err != nil { + t.Fatal(err) + } + if cur.Status == "exited-external" { + text, _, err := m.Tail(j.ID, 20, 4096) + if err != nil { + t.Fatal(err) + } + if text != "external-ok" { + t.Fatalf("log=%q", text) + } + m2, err := New(dir) + if err != nil { + t.Fatal(err) + } + recovered, err := m2.Get(j.ID) + if err != nil { + t.Fatal(err) + } + if recovered.ExecutionContext != "desktop" { + t.Fatalf("recovered execution_context=%q", recovered.ExecutionContext) + } + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("external job did not complete") +} + func TestJobPersistsAndCompletes(t *testing.T) { dir := t.TempDir() m, err := New(dir) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index b86254e..c0198f6 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -19,6 +19,7 @@ import ( "github.com/Cytech-Team/CyComAgent-MCP/internal/plugins" "github.com/Cytech-Team/CyComAgent-MCP/internal/policy" "github.com/Cytech-Team/CyComAgent-MCP/internal/registry" + "github.com/Cytech-Team/CyComAgent-MCP/internal/sessionbridge" "github.com/Cytech-Team/CyComAgent-MCP/internal/state" "github.com/Cytech-Team/CyComAgent-MCP/internal/targets" "github.com/Cytech-Team/CyComAgent-MCP/internal/tools" @@ -93,7 +94,8 @@ func New(cfg Config) (*Runtime, error) { reg.AddInterceptor(pe) reg.AddInterceptor(al) root := broker.Client{Socket: cfg.RootSocket} - tools.Register(reg, tools.Dependencies{Jobs: jm, Broker: root, Plugins: pm, State: st, Targets: tm, Audit: al, Policy: pe, StateDir: cfg.StateDir, Version: cfg.Version}) + session := sessionbridge.NewClient("") + tools.Register(reg, tools.Dependencies{Jobs: jm, Broker: root, Plugins: pm, State: st, Targets: tm, Audit: al, Policy: pe, StateDir: cfg.StateDir, Version: cfg.Version, Session: session}) if pe.Snapshot().AllowExternalPlugins { loaded, err := pm.LoadInto(reg) if err != nil { diff --git a/internal/sessionbridge/bridge_linux.go b/internal/sessionbridge/bridge_linux.go new file mode 100644 index 0000000..8153102 --- /dev/null +++ b/internal/sessionbridge/bridge_linux.go @@ -0,0 +1,493 @@ +//go:build linux + +package sessionbridge + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +const ( + defaultExecTimeout = 120 * time.Second + defaultOutputLimit = 2 << 20 + maxOutputLimit = 10 << 20 +) + +type Client struct { + Socket string +} + +func DefaultSocketPath() string { + if path := strings.TrimSpace(os.Getenv("CYCOM_SESSION_SOCKET")); path != "" { + return path + } + if runtimeDir := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); runtimeDir != "" { + return filepath.Join(runtimeDir, "cycomagent-session.sock") + } + return filepath.Join("/run/user", strconv.Itoa(os.Getuid()), "cycomagent-session.sock") +} + +func NewClient(socket string) Client { + if strings.TrimSpace(socket) == "" { + socket = DefaultSocketPath() + } + return Client{Socket: socket} +} + +func (c Client) Available() bool { + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + _, err := c.Status(ctx) + return err == nil +} + +func (c Client) Status(ctx context.Context) (Status, error) { + var out response + if err := c.call(ctx, request{Action: "status"}, &out); err != nil { + return Status{Available: false, Socket: c.socketPath()}, err + } + if out.Status == nil { + return Status{Available: false, Socket: c.socketPath()}, errors.New("session bridge returned no status") + } + out.Status.Available = true + return *out.Status, nil +} + +func (c Client) Exec(ctx context.Context, in ExecRequest) (ExecResult, error) { + var out response + if err := c.call(ctx, request{Action: "exec", Exec: &in}, &out); err != nil { + return ExecResult{}, err + } + if out.Exec == nil { + return ExecResult{}, errors.New("session bridge returned no exec result") + } + return *out.Exec, nil +} + +func (c Client) Spawn(ctx context.Context, in SpawnRequest) (SpawnResult, error) { + var out response + if err := c.call(ctx, request{Action: "spawn", Spawn: &in}, &out); err != nil { + return SpawnResult{}, err + } + if out.Spawn == nil { + return SpawnResult{}, errors.New("session bridge returned no spawn result") + } + return *out.Spawn, nil +} + +func (c Client) socketPath() string { + if strings.TrimSpace(c.Socket) != "" { + return c.Socket + } + return DefaultSocketPath() +} + +func (c Client) call(ctx context.Context, in request, out *response) error { + path := c.socketPath() + d := net.Dialer{} + conn, err := d.DialContext(ctx, "unix", path) + if err != nil { + return fmt.Errorf("desktop session bridge unavailable at %s: %w", path, err) + } + defer conn.Close() + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + if err := json.NewEncoder(conn).Encode(in); err != nil { + return fmt.Errorf("write desktop session bridge request: %w", err) + } + if err := json.NewDecoder(io.LimitReader(conn, 24<<20)).Decode(out); err != nil { + return fmt.Errorf("read desktop session bridge response: %w", err) + } + if !out.OK { + if out.Error == "" { + out.Error = "desktop session bridge request failed" + } + return errors.New(out.Error) + } + return nil +} + +// Run serves execution requests from inside the graphical login session. The +// bridge itself must be started by the desktop session (XDG autostart, +// compositor autostart, etc.) so children inherit the real logind session +// cgroup. Copying DISPLAY/DBUS variables alone is intentionally not enough. +func Run(ctx context.Context, socket string) error { + if strings.TrimSpace(socket) == "" { + socket = DefaultSocketPath() + } + cgroup := processCgroup(os.Getpid()) + if sessionFromCgroup(cgroup) == "" && os.Getenv("CYCOM_SESSION_BRIDGE_ALLOW_NONLOGIN") != "1" { + return fmt.Errorf("desktop session bridge must be launched by a real graphical login session; current cgroup %q has no session-*.scope (use compositor/session autostart, not the CyComAgent or systemd user service)", cgroup) + } + if err := os.MkdirAll(filepath.Dir(socket), 0o700); err != nil { + return fmt.Errorf("create session bridge socket directory: %w", err) + } + if err := removeStaleSocket(socket); err != nil { + return err + } + listener, err := net.Listen("unix", socket) + if err != nil { + return fmt.Errorf("listen desktop session bridge %s: %w", socket, err) + } + defer listener.Close() + defer os.Remove(socket) + if err := os.Chmod(socket, 0o600); err != nil { + return fmt.Errorf("chmod desktop session bridge socket: %w", err) + } + + go func() { + <-ctx.Done() + _ = listener.Close() + }() + + for { + conn, err := listener.Accept() + if err != nil { + if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { + return nil + } + return fmt.Errorf("accept desktop session bridge request: %w", err) + } + go handleConnection(conn, socket) + } +} + +func removeStaleSocket(path string) error { + st, err := os.Lstat(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if st.Mode()&os.ModeSocket == 0 { + return fmt.Errorf("refusing to replace non-socket session bridge path %s", path) + } + conn, dialErr := net.DialTimeout("unix", path, 100*time.Millisecond) + if dialErr == nil { + _ = conn.Close() + return fmt.Errorf("desktop session bridge already running at %s", path) + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove stale desktop session bridge socket: %w", err) + } + return nil +} + +func handleConnection(conn net.Conn, socket string) { + defer conn.Close() + if !peerIsAuthorized(conn) { + _ = json.NewEncoder(conn).Encode(response{OK: false, Error: "desktop session bridge rejected peer identity"}) + return + } + var in request + if err := json.NewDecoder(io.LimitReader(conn, 4<<20)).Decode(&in); err != nil { + _ = json.NewEncoder(conn).Encode(response{OK: false, Error: "decode request: " + err.Error()}) + return + } + out := response{OK: true} + switch in.Action { + case "status": + status := bridgeStatus(socket) + out.Status = &status + case "exec": + if in.Exec == nil { + out.OK, out.Error = false, "exec request is required" + break + } + result, err := runExec(*in.Exec) + if err != nil { + out.OK, out.Error = false, err.Error() + break + } + out.Exec = &result + case "spawn": + if in.Spawn == nil { + out.OK, out.Error = false, "spawn request is required" + break + } + result, err := runSpawn(*in.Spawn) + if err != nil { + out.OK, out.Error = false, err.Error() + break + } + out.Spawn = &result + default: + out.OK, out.Error = false, fmt.Sprintf("unsupported session bridge action %q", in.Action) + } + _ = json.NewEncoder(conn).Encode(out) +} + +func peerIsAuthorized(conn net.Conn) bool { + unixConn, ok := conn.(*net.UnixConn) + if !ok { + return false + } + raw, err := unixConn.SyscallConn() + if err != nil { + return false + } + var cred *syscall.Ucred + var sockErr error + if err := raw.Control(func(fd uintptr) { + cred, sockErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED) + }); err != nil || sockErr != nil || cred == nil { + return false + } + if int(cred.Uid) != os.Getuid() { + return false + } + peerExe, err := os.Readlink(filepath.Join("/proc", strconv.Itoa(int(cred.Pid)), "exe")) + if err != nil { + return false + } + selfExe, err := os.Executable() + if err != nil { + return false + } + peerExe = resolvedExecutablePath(peerExe) + selfExe = resolvedExecutablePath(selfExe) + return peerExe != "" && peerExe == selfExe +} + +func resolvedExecutablePath(path string) string { + path = strings.TrimSuffix(path, " (deleted)") + resolved, err := filepath.EvalSymlinks(path) + if err == nil { + return resolved + } + return filepath.Clean(path) +} + +func bridgeStatus(socket string) Status { + cgroup := processCgroup(os.Getpid()) + return Status{ + Available: true, + PID: os.Getpid(), + UID: os.Getuid(), + Socket: socket, + Cgroup: cgroup, + Session: sessionFromCgroup(cgroup), + Desktop: firstNonEmpty(os.Getenv("XDG_CURRENT_DESKTOP"), os.Getenv("XDG_SESSION_DESKTOP"), os.Getenv("DESKTOP_SESSION")), + SessionType: os.Getenv("XDG_SESSION_TYPE"), + WaylandDisplay: os.Getenv("WAYLAND_DISPLAY"), + Display: os.Getenv("DISPLAY"), + EnvironmentHint: map[string]string{ + "XDG_RUNTIME_DIR": os.Getenv("XDG_RUNTIME_DIR"), + "DBUS_SESSION_BUS_ADDRESS": os.Getenv("DBUS_SESSION_BUS_ADDRESS"), + }, + } +} + +func runExec(in ExecRequest) (ExecResult, error) { + if strings.TrimSpace(in.Command) == "" { + return ExecResult{}, errors.New("command is required") + } + timeout := defaultExecTimeout + if in.TimeoutSeconds > 0 { + timeout = time.Duration(in.TimeoutSeconds) * time.Second + } + max := in.MaxOutputBytes + if max <= 0 || max > maxOutputLimit { + max = defaultOutputLimit + } + shell := strings.TrimSpace(in.Shell) + if shell == "" { + shell = defaultShell() + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.Command(shell, "-lc", in.Command) + if in.Cwd != "" { + cmd.Dir = in.Cwd + } + cmd.Env = mergeEnv(in.Env) + if in.Stdin != "" { + cmd.Stdin = strings.NewReader(in.Stdin) + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + var stdout, stderr limitBuffer + stdout.limit, stderr.limit = max, max + cmd.Stdout, cmd.Stderr = &stdout, &stderr + start := time.Now() + if err := cmd.Start(); err != nil { + return ExecResult{}, err + } + pid := cmd.Process.Pid + cgroup := processCgroup(pid) + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + var waitErr error + timedOut := false + select { + case waitErr = <-done: + case <-ctx.Done(): + timedOut = errors.Is(ctx.Err(), context.DeadlineExceeded) + _ = syscall.Kill(-pid, syscall.SIGKILL) + waitErr = <-done + } + result := ExecResult{ + ExitCode: 0, + Stdout: stdout.String(), + Stderr: stderr.String(), + DurationMS: time.Since(start).Milliseconds(), + TimedOut: timedOut, + Truncated: stdout.truncated || stderr.truncated, + PID: pid, + Cgroup: cgroup, + Session: sessionFromCgroup(cgroup), + } + if timedOut { + result.ExitCode = -1 + return result, nil + } + if waitErr == nil { + return result, nil + } + var exitErr *exec.ExitError + if errors.As(waitErr, &exitErr) { + result.ExitCode = exitErr.ExitCode() + return result, nil + } + return ExecResult{}, waitErr +} + +func runSpawn(in SpawnRequest) (SpawnResult, error) { + if strings.TrimSpace(in.Command) == "" { + return SpawnResult{}, errors.New("command is required") + } + if strings.TrimSpace(in.LogFile) == "" { + return SpawnResult{}, errors.New("log_file is required") + } + if err := os.MkdirAll(filepath.Dir(in.LogFile), 0o700); err != nil { + return SpawnResult{}, err + } + logFile, err := os.OpenFile(in.LogFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return SpawnResult{}, err + } + shell := strings.TrimSpace(in.Shell) + if shell == "" { + shell = defaultShell() + } + cmd := exec.Command(shell, "-lc", in.Command) + if in.Cwd != "" { + cmd.Dir = in.Cwd + } + cmd.Env = mergeEnv(in.Env) + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + _ = logFile.Close() + return SpawnResult{}, err + } + pid := cmd.Process.Pid + pgid, _ := syscall.Getpgid(pid) + cgroup := processCgroup(pid) + go func() { + _ = cmd.Wait() + _ = logFile.Close() + }() + return SpawnResult{PID: pid, PGID: pgid, Cgroup: cgroup, Session: sessionFromCgroup(cgroup)}, nil +} + +func mergeEnv(extra map[string]string) []string { + values := map[string]string{} + for _, entry := range os.Environ() { + if i := strings.IndexByte(entry, '='); i > 0 { + values[entry[:i]] = entry[i+1:] + } + } + for key, value := range extra { + values[key] = value + } + out := make([]string, 0, len(values)) + for key, value := range values { + out = append(out, key+"="+value) + } + return out +} + +func defaultShell() string { + if shell := strings.TrimSpace(os.Getenv("SHELL")); shell != "" { + return shell + } + if _, err := os.Stat("/bin/sh"); err == nil { + return "/bin/sh" + } + if shell, err := exec.LookPath("sh"); err == nil { + return shell + } + return "sh" +} + +func processCgroup(pid int) string { + data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cgroup")) + if err != nil { + return "" + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + for _, line := range lines { + parts := strings.SplitN(line, ":", 3) + if len(parts) == 3 && parts[2] != "" { + return parts[2] + } + } + return "" +} + +func sessionFromCgroup(cgroup string) string { + for _, part := range strings.Split(cgroup, "/") { + if strings.HasPrefix(part, "session-") && strings.HasSuffix(part, ".scope") { + return strings.TrimSuffix(strings.TrimPrefix(part, "session-"), ".scope") + } + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +type limitBuffer struct { + buf bytes.Buffer + limit int + truncated bool +} + +func (b *limitBuffer) Write(p []byte) (int, error) { + original := len(p) + remaining := b.limit - b.buf.Len() + if remaining <= 0 { + b.truncated = true + return original, nil + } + if len(p) > remaining { + _, _ = b.buf.Write(p[:remaining]) + b.truncated = true + return original, nil + } + _, _ = b.buf.Write(p) + return original, nil +} + +func (b *limitBuffer) String() string { return b.buf.String() } diff --git a/internal/sessionbridge/bridge_linux_test.go b/internal/sessionbridge/bridge_linux_test.go new file mode 100644 index 0000000..2215774 --- /dev/null +++ b/internal/sessionbridge/bridge_linux_test.go @@ -0,0 +1,141 @@ +//go:build linux + +package sessionbridge + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +func TestBridgeRejectsNonLoginCgroup(t *testing.T) { + if sessionFromCgroup(processCgroup(os.Getpid())) != "" { + t.Skip("test process already belongs to a real login session") + } + t.Setenv("CYCOM_SESSION_BRIDGE_ALLOW_NONLOGIN", "") + err := Run(context.Background(), filepath.Join(t.TempDir(), "rejected.sock")) + if err == nil || !strings.Contains(err.Error(), "session-*.scope") { + t.Fatalf("expected non-login cgroup rejection, got %v", err) + } +} + +func startTestBridge(t *testing.T) (Client, context.CancelFunc) { + t.Helper() + t.Setenv("CYCOM_SESSION_BRIDGE_ALLOW_NONLOGIN", "1") + socket := filepath.Join(t.TempDir(), "session.sock") + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- Run(ctx, socket) }() + client := NewClient(socket) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + probeCtx, probeCancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + _, err := client.Status(probeCtx) + probeCancel() + if err == nil { + t.Cleanup(func() { + cancel() + select { + case err := <-done: + if err != nil { + t.Errorf("bridge shutdown: %v", err) + } + case <-time.After(2 * time.Second): + t.Error("bridge did not stop") + } + }) + return client, cancel + } + time.Sleep(20 * time.Millisecond) + } + cancel() + t.Fatalf("session bridge did not become ready") + return Client{}, func() {} +} + +func TestBridgeExecRoundTrip(t *testing.T) { + client, _ := startTestBridge(t) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + res, err := client.Exec(ctx, ExecRequest{ + Command: "printf '%s:' \"$CYCOM_BRIDGE_TEST\"; cat", + Stdin: "stdin-ok", + Env: map[string]string{"CYCOM_BRIDGE_TEST": "env-ok"}, + }) + if err != nil { + t.Fatal(err) + } + if res.ExitCode != 0 || res.Stdout != "env-ok:stdin-ok" || res.TimedOut { + t.Fatalf("unexpected exec result: %#v", res) + } + if res.PID <= 0 || strings.TrimSpace(res.Cgroup) == "" { + t.Fatalf("missing process metadata: %#v", res) + } +} + +func TestBridgeExecTimeoutKillsProcessGroup(t *testing.T) { + client, _ := startTestBridge(t) + pidFile := filepath.Join(t.TempDir(), "child.pid") + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + res, err := client.Exec(ctx, ExecRequest{ + Command: "sleep 30 & echo $! > '" + pidFile + "'; wait", + TimeoutSeconds: 1, + }) + if err != nil { + t.Fatal(err) + } + if !res.TimedOut || res.ExitCode != -1 { + t.Fatalf("unexpected timeout result: %#v", res) + } + data, err := os.ReadFile(pidFile) + if err != nil { + t.Fatal(err) + } + var child int + if _, err := fmtSscanf(strings.TrimSpace(string(data)), &child); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if err := syscall.Kill(child, 0); err == syscall.ESRCH { + return + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("child %d survived bridge timeout", child) +} + +func TestBridgeSpawnWritesLog(t *testing.T) { + client, _ := startTestBridge(t) + logFile := filepath.Join(t.TempDir(), "spawn.log") + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + res, err := client.Spawn(ctx, SpawnRequest{Command: "printf spawn-ok", LogFile: logFile}) + if err != nil { + t.Fatal(err) + } + if res.PID <= 0 || res.PGID <= 0 { + t.Fatalf("invalid spawn metadata: %#v", res) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + data, _ := os.ReadFile(logFile) + if string(data) == "spawn-ok" { + return + } + time.Sleep(20 * time.Millisecond) + } + data, _ := os.ReadFile(logFile) + t.Fatalf("spawn log=%q", data) +} + +// tiny scanner helper keeps this test independent of strconv error wording. +func fmtSscanf(value string, out *int) (int, error) { + return fmt.Sscanf(value, "%d", out) +} diff --git a/internal/sessionbridge/bridge_other.go b/internal/sessionbridge/bridge_other.go new file mode 100644 index 0000000..651be30 --- /dev/null +++ b/internal/sessionbridge/bridge_other.go @@ -0,0 +1,37 @@ +//go:build !linux + +package sessionbridge + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +type Client struct { + Socket string +} + +func DefaultSocketPath() string { + if path := strings.TrimSpace(os.Getenv("CYCOM_SESSION_SOCKET")); path != "" { + return path + } + return filepath.Join(os.TempDir(), "cycomagent-session.sock") +} + +func NewClient(socket string) Client { return Client{Socket: socket} } +func (Client) Available() bool { return false } +func (Client) Status(context.Context) (Status, error) { + return Status{Available: false}, fmt.Errorf("desktop session bridge is currently Linux-only") +} +func (Client) Exec(context.Context, ExecRequest) (ExecResult, error) { + return ExecResult{}, fmt.Errorf("desktop session bridge is currently Linux-only") +} +func (Client) Spawn(context.Context, SpawnRequest) (SpawnResult, error) { + return SpawnResult{}, fmt.Errorf("desktop session bridge is currently Linux-only") +} +func Run(context.Context, string) error { + return fmt.Errorf("desktop session bridge is currently Linux-only") +} diff --git a/internal/sessionbridge/types.go b/internal/sessionbridge/types.go new file mode 100644 index 0000000..915fd6b --- /dev/null +++ b/internal/sessionbridge/types.go @@ -0,0 +1,72 @@ +package sessionbridge + +// ExecRequest describes a synchronous command that must execute from inside +// the active graphical login session rather than from the CyComAgent service +// cgroup. The session bridge merges Env on top of the environment it inherited +// from the desktop session. +type ExecRequest struct { + Command string `json:"command"` + Stdin string `json:"stdin,omitempty"` + Cwd string `json:"cwd,omitempty"` + Env map[string]string `json:"env,omitempty"` + Shell string `json:"shell,omitempty"` + TimeoutSeconds int `json:"timeout_seconds,omitempty"` + MaxOutputBytes int `json:"max_output_bytes,omitempty"` +} + +type ExecResult struct { + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + DurationMS int64 `json:"duration_ms"` + TimedOut bool `json:"timed_out"` + Truncated bool `json:"truncated"` + PID int `json:"pid,omitempty"` + Cgroup string `json:"cgroup,omitempty"` + Session string `json:"session,omitempty"` +} + +// SpawnRequest starts a detached command in the graphical login session. The +// caller owns LogFile and can monitor/signal the returned PID/PGID directly. +type SpawnRequest struct { + Command string `json:"command"` + Cwd string `json:"cwd,omitempty"` + Env map[string]string `json:"env,omitempty"` + Shell string `json:"shell,omitempty"` + LogFile string `json:"log_file"` +} + +type SpawnResult struct { + PID int `json:"pid"` + PGID int `json:"pgid,omitempty"` + Cgroup string `json:"cgroup,omitempty"` + Session string `json:"session,omitempty"` +} + +type Status struct { + Available bool `json:"available"` + PID int `json:"pid,omitempty"` + UID int `json:"uid,omitempty"` + Socket string `json:"socket,omitempty"` + Cgroup string `json:"cgroup,omitempty"` + Session string `json:"session,omitempty"` + Desktop string `json:"desktop,omitempty"` + SessionType string `json:"session_type,omitempty"` + WaylandDisplay string `json:"wayland_display,omitempty"` + Display string `json:"display,omitempty"` + EnvironmentHint map[string]string `json:"environment,omitempty"` +} + +type request struct { + Action string `json:"action"` + Exec *ExecRequest `json:"exec,omitempty"` + Spawn *SpawnRequest `json:"spawn,omitempty"` +} + +type response struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Exec *ExecResult `json:"exec,omitempty"` + Spawn *SpawnResult `json:"spawn,omitempty"` + Status *Status `json:"status,omitempty"` +} diff --git a/internal/tools/process.go b/internal/tools/process.go index 600d597..34baca7 100644 --- a/internal/tools/process.go +++ b/internal/tools/process.go @@ -15,40 +15,49 @@ import ( "github.com/Cytech-Team/CyComAgent-MCP/internal/jobs" "github.com/Cytech-Team/CyComAgent-MCP/internal/platform" "github.com/Cytech-Team/CyComAgent-MCP/internal/registry" + "github.com/Cytech-Team/CyComAgent-MCP/internal/sessionbridge" ) const defaultExecTimeout = 120 * time.Second const maxProcessOutput = 10 << 20 type processDeps struct { - Jobs *jobs.Manager - Broker broker.Client + Jobs *jobs.Manager + Broker broker.Client + Session sessionbridge.Client } func registerProcess(r *registry.Registry, deps processDeps) { + executionContext := map[string]any{ + "type": "string", + "enum": []string{"auto", "service", "user", "desktop", "system"}, + "description": "execution domain; auto conservatively routes recognized desktop/session commands through the active desktop bridge, service stays in the CyComAgent service, user/desktop use the active login-session bridge, and system requires privileged=true/root broker", + } props := map[string]any{ - "command": registry.String("shell command to execute"), - "stdin": registry.String("optional UTF-8 stdin for the command"), - "cwd": registry.String("working directory"), - "env": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}}, - "timeout_seconds": registry.Integer("timeout; default 120 seconds"), - "shell": registry.String("shell executable; defaults to the platform shell"), - "max_output_bytes": registry.Integer("cap for stdout and stderr"), - "privileged": registry.Boolean("execute through the optional root broker"), - } - must(r.Add(registry.Tool{Name: "process_exec", Description: "Execute a shell command synchronously and return structured stdout/stderr, exit code, timeout, and duration.", InputSchema: registry.ObjectSchema(props, []string{"command"}), Source: "core", Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - return processExec(ctx, raw, deps.Broker) + "command": registry.String("shell command to execute"), + "stdin": registry.String("optional UTF-8 stdin for the command"), + "cwd": registry.String("working directory"), + "env": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}}, + "timeout_seconds": registry.Integer("timeout; default 120 seconds"), + "shell": registry.String("shell executable; defaults to the platform shell"), + "max_output_bytes": registry.Integer("cap for stdout and stderr"), + "privileged": registry.Boolean("execute through the optional root broker"), + "execution_context": executionContext, + } + must(r.Add(registry.Tool{Name: "process_exec", Description: "Execute a shell command synchronously. Use execution_context=desktop for GUI apps, Polkit-authorized desktop actions, notifications, keyrings, portals, clipboard/compositor commands, or anything that must belong to the active graphical login session.", InputSchema: registry.ObjectSchema(props, []string{"command"}), Source: "core", Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { + return processExec(ctx, raw, deps.Broker, deps.Session) }})) - must(r.Add(registry.Tool{Name: "process_spawn", Description: "Start a persistent background job. Job metadata and logs survive MCP/tunnel reconnects and runtime restarts.", InputSchema: registry.ObjectSchema(map[string]any{ + must(r.Add(registry.Tool{Name: "process_spawn", Description: "Start a persistent background job. Use execution_context=desktop for GUI/session applications so they are born in the active graphical login session rather than the CyComAgent service cgroup.", InputSchema: registry.ObjectSchema(map[string]any{ "command": registry.String("shell command to start"), "cwd": registry.String("working directory"), - "env": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}}, - "shell": registry.String("shell executable; defaults to the platform shell"), - }, []string{"command"}), Source: "core", Handler: func(_ context.Context, raw json.RawMessage) (any, error) { + "env": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}}, + "shell": registry.String("shell executable; defaults to the platform shell"), + "execution_context": executionContext, + }, []string{"command"}), Source: "core", Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { var in processSpawnInput if err := decode(raw, &in); err != nil { return nil, err } - return deps.Jobs.Start(in.Command, in.Cwd, in.Shell, in.Env) + return processSpawn(ctx, in, deps) }})) must(r.Add(registry.Tool{Name: "process_inspect", Description: "Inspect an OS process by PID.", InputSchema: registry.ObjectSchema(map[string]any{ "pid": registry.Integer("process id"), @@ -59,20 +68,22 @@ func registerProcess(r *registry.Registry, deps processDeps) { } type processExecInput struct { - Command string `json:"command"` - Stdin string `json:"stdin"` - Cwd string `json:"cwd"` - Env map[string]string `json:"env"` - TimeoutSeconds int `json:"timeout_seconds"` - Shell string `json:"shell"` - MaxOutputBytes int `json:"max_output_bytes"` - Privileged bool `json:"privileged"` + Command string `json:"command"` + Stdin string `json:"stdin"` + Cwd string `json:"cwd"` + Env map[string]string `json:"env"` + TimeoutSeconds int `json:"timeout_seconds"` + Shell string `json:"shell"` + MaxOutputBytes int `json:"max_output_bytes"` + Privileged bool `json:"privileged"` + ExecutionContext string `json:"execution_context"` } type processSpawnInput struct { - Command string `json:"command"` - Cwd string `json:"cwd"` - Env map[string]string `json:"env"` - Shell string `json:"shell"` + Command string `json:"command"` + Cwd string `json:"cwd"` + Env map[string]string `json:"env"` + Shell string `json:"shell"` + ExecutionContext string `json:"execution_context"` } type processPIDInput struct { PID int `json:"pid"` @@ -82,7 +93,7 @@ type processSignalInput struct { Signal string `json:"signal"` } -func processExec(ctx context.Context, raw json.RawMessage, root broker.Client) (any, error) { +func processExec(ctx context.Context, raw json.RawMessage, root broker.Client, session sessionbridge.Client) (any, error) { var in processExecInput if err := decode(raw, &in); err != nil { return nil, err @@ -102,6 +113,37 @@ func processExec(ctx context.Context, raw json.RawMessage, root broker.Client) ( if shell == "" { shell = platform.DefaultShell() } + + desktopAvailable := false + if executionContextNeedsBridgeProbe(in.ExecutionContext, in.Command, in.Privileged) { + desktopAvailable = session.Available() + } + executionContext, err := resolveExecutionContext(in.ExecutionContext, in.Command, in.Privileged, desktopAvailable) + if err != nil { + return nil, err + } + if executionContext == "desktop" || executionContext == "user" { + if in.Privileged { + return nil, fmt.Errorf("privileged execution cannot use execution_context=%s; use execution_context=system", executionContext) + } + res, err := session.Exec(ctx, sessionbridge.ExecRequest{ + Command: in.Command, Stdin: in.Stdin, Cwd: in.Cwd, Env: in.Env, Shell: shell, + TimeoutSeconds: int(timeout / time.Second), MaxOutputBytes: max, + }) + if err != nil { + return nil, err + } + return map[string]any{ + "exit_code": res.ExitCode, "stdout": res.Stdout, "stderr": res.Stderr, + "duration_ms": res.DurationMS, "timed_out": res.TimedOut, "truncated": res.Truncated, + "privileged": false, "execution_context": executionContext, "session_bridge": true, + "pid": res.PID, "cgroup": res.Cgroup, "session": res.Session, + }, nil + } + if executionContext == "system" && !in.Privileged { + return nil, fmt.Errorf("execution_context=system requires privileged=true so policy and the root broker remain authoritative") + } + if in.Privileged && !platform.SupportsLocalPrivilege() { if platform.IsTermux() { return nil, fmt.Errorf("Android device-root escalation is not supported on Termux and is not planned; ordinary user-space commands (including sudo inside a non-root container/proot when available) remain allowed") @@ -116,8 +158,13 @@ func processExec(ctx context.Context, raw json.RawMessage, root broker.Client) ( if err != nil { return nil, err } - return res, nil + return map[string]any{ + "exit_code": res.ExitCode, "stdout": res.Stdout, "stderr": res.Stderr, + "duration_ms": res.DurationMS, "timed_out": res.TimedOut, "truncated": res.Truncated, + "privileged": true, "execution_context": executionContext, + }, nil } + cctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() cmd := exec.Command(shell, "-lc", in.Command) @@ -138,30 +185,136 @@ func processExec(ctx context.Context, raw json.RawMessage, root broker.Client) ( } done := make(chan error, 1) go func() { done <- cmd.Wait() }() - var err error + var waitErr error timedOut := false select { - case err = <-done: + case waitErr = <-done: case <-cctx.Done(): timedOut = errors.Is(cctx.Err(), context.DeadlineExceeded) _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) - err = <-done + waitErr = <-done } duration := time.Since(start) - out := map[string]any{"exit_code": 0, "stdout": stdout.String(), "stderr": stderr.String(), "duration_ms": duration.Milliseconds(), "timed_out": timedOut, "truncated": stdout.truncated || stderr.truncated, "privileged": false} + out := map[string]any{ + "exit_code": 0, "stdout": stdout.String(), "stderr": stderr.String(), + "duration_ms": duration.Milliseconds(), "timed_out": timedOut, + "truncated": stdout.truncated || stderr.truncated, "privileged": false, + "execution_context": executionContext, + } if timedOut { out["exit_code"] = -1 return out, nil } - if err == nil { + if waitErr == nil { return out, nil } var ee *exec.ExitError - if errors.As(err, &ee) { + if errors.As(waitErr, &ee) { out["exit_code"] = ee.ExitCode() return out, nil } - return nil, err + return nil, waitErr +} + +func processSpawn(ctx context.Context, in processSpawnInput, deps processDeps) (any, error) { + if strings.TrimSpace(in.Command) == "" { + return nil, fmt.Errorf("command is required") + } + desktopAvailable := false + if executionContextNeedsBridgeProbe(in.ExecutionContext, in.Command, false) { + desktopAvailable = deps.Session.Available() + } + executionContext, err := resolveExecutionContext(in.ExecutionContext, in.Command, false, desktopAvailable) + if err != nil { + return nil, err + } + if executionContext == "system" { + return nil, fmt.Errorf("process_spawn does not support execution_context=system; use process_exec with privileged=true or a managed service") + } + if executionContext == "desktop" || executionContext == "user" { + shell := in.Shell + if shell == "" { + shell = platform.DefaultShell() + } + return deps.Jobs.StartExternal(in.Command, in.Cwd, shell, in.Env, executionContext, func(logFile string) (int, int, error) { + res, err := deps.Session.Spawn(ctx, sessionbridge.SpawnRequest{ + Command: in.Command, Cwd: in.Cwd, Env: in.Env, Shell: shell, LogFile: logFile, + }) + if err != nil { + return 0, 0, err + } + return res.PID, res.PGID, nil + }) + } + return deps.Jobs.StartWithContext(in.Command, in.Cwd, in.Shell, in.Env, executionContext) +} + +func executionContextNeedsBridgeProbe(requested, command string, privileged bool) bool { + requested = strings.ToLower(strings.TrimSpace(requested)) + if privileged || requested == "service" || requested == "system" { + return false + } + if requested == "desktop" || requested == "user" { + return true + } + return (requested == "" || requested == "auto") && looksLikeDesktopCommand(command) +} + +func resolveExecutionContext(requested, command string, privileged, desktopAvailable bool) (string, error) { + requested = strings.ToLower(strings.TrimSpace(requested)) + if requested == "" { + requested = "auto" + } + switch requested { + case "auto": + if privileged { + return "system", nil + } + if desktopAvailable && looksLikeDesktopCommand(command) { + return "desktop", nil + } + return "service", nil + case "service": + if privileged { + return "system", nil + } + return "service", nil + case "user", "desktop": + if privileged { + return "", fmt.Errorf("execution_context=%s cannot be privileged", requested) + } + if !desktopAvailable { + return "", fmt.Errorf("execution_context=%s requires an active CyCom desktop session bridge", requested) + } + return requested, nil + case "system": + if !privileged { + return "", fmt.Errorf("execution_context=system requires privileged=true") + } + return "system", nil + default: + return "", fmt.Errorf("unsupported execution_context %q", requested) + } +} + +// Auto routing is deliberately conservative. Unknown commands stay in the +// service context; callers can always request desktop explicitly. These are +// commands whose semantics depend on the active graphical/login session rather +// than merely on DISPLAY being present. +func looksLikeDesktopCommand(command string) bool { + value := strings.ToLower(command) + markers := []string{ + "noctalia", "powerprofilesctl", "notify-send", "xdg-open", "gtk-launch", + "wl-copy", "wl-paste", "grim", "slurp", "wtype", "ydotool", + "secret-tool", "kwallet", "kdialog", "zenity", "dbus-send --session", + "gdbus call --session", "busctl --user", "systemctl --user import-environment", + } + for _, marker := range markers { + if strings.Contains(value, marker) { + return true + } + } + return false } func processInspect(ctx context.Context, raw json.RawMessage) (any, error) { diff --git a/internal/tools/process_test.go b/internal/tools/process_test.go index e20ae27..8469280 100644 --- a/internal/tools/process_test.go +++ b/internal/tools/process_test.go @@ -12,12 +12,13 @@ import ( "time" "github.com/Cytech-Team/CyComAgent-MCP/internal/broker" + "github.com/Cytech-Team/CyComAgent-MCP/internal/sessionbridge" ) func TestProcessExecTimeoutKillsProcessGroup(t *testing.T) { pidfile := filepath.Join(t.TempDir(), "pid") raw, _ := json.Marshal(map[string]any{"command": "sleep 30 & echo $! > '" + pidfile + "'; wait", "timeout_seconds": 1}) - out, err := processExec(context.Background(), raw, broker.Client{}) + out, err := processExec(context.Background(), raw, broker.Client{}, sessionbridge.NewClient(filepath.Join(t.TempDir(), "missing-session.sock"))) if err != nil { t.Fatal(err) } @@ -45,7 +46,7 @@ func TestProcessExecTimeoutKillsProcessGroup(t *testing.T) { func TestProcessExecStdin(t *testing.T) { raw, _ := json.Marshal(map[string]any{"command": "cat", "stdin": "hello-stdin"}) - out, err := processExec(context.Background(), raw, broker.Client{}) + out, err := processExec(context.Background(), raw, broker.Client{}, sessionbridge.NewClient(filepath.Join(t.TempDir(), "missing-session.sock"))) if err != nil { t.Fatal(err) } @@ -54,3 +55,73 @@ func TestProcessExecStdin(t *testing.T) { t.Fatalf("out=%#v", m) } } + +func TestResolveExecutionContext(t *testing.T) { + tests := []struct { + name string + requested string + command string + privileged bool + desktopAvailable bool + want string + wantErr bool + }{ + {name: "auto service", command: "printf ok", want: "service"}, + {name: "auto desktop", command: "powerprofilesctl set balanced", desktopAvailable: true, want: "desktop"}, + {name: "explicit desktop", requested: "desktop", command: "my-gui", desktopAvailable: true, want: "desktop"}, + {name: "explicit user", requested: "user", command: "id", desktopAvailable: true, want: "user"}, + {name: "desktop without bridge", requested: "desktop", command: "my-gui", wantErr: true}, + {name: "auto privileged", command: "id", privileged: true, want: "system"}, + {name: "system requires privileged", requested: "system", command: "id", wantErr: true}, + {name: "desktop cannot be privileged", requested: "desktop", command: "id", privileged: true, desktopAvailable: true, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveExecutionContext(tt.requested, tt.command, tt.privileged, tt.desktopAvailable) + if tt.wantErr { + if err == nil { + t.Fatalf("got context %q; expected error", got) + } + return + } + if err != nil || got != tt.want { + t.Fatalf("got %q, %v; want %q", got, err, tt.want) + } + }) + } +} + +func TestExecutionContextNeedsBridgeProbe(t *testing.T) { + if executionContextNeedsBridgeProbe("service", "powerprofilesctl get", false) { + t.Fatal("explicit service should not probe desktop bridge") + } + if executionContextNeedsBridgeProbe("", "git status", false) { + t.Fatal("ordinary auto service command should not probe desktop bridge") + } + if !executionContextNeedsBridgeProbe("", "powerprofilesctl get", false) { + t.Fatal("recognized auto desktop command should probe desktop bridge") + } + if !executionContextNeedsBridgeProbe("desktop", "custom-gui", false) { + t.Fatal("explicit desktop should probe desktop bridge") + } + if executionContextNeedsBridgeProbe("desktop", "custom-gui", true) { + t.Fatal("privileged request should be rejected/routed before desktop probing") + } +} + +func TestLooksLikeDesktopCommand(t *testing.T) { + for _, command := range []string{ + "noctalia --daemon", + "powerprofilesctl set balanced", + "notify-send hello", + "wl-copy hello", + "gdbus call --session --dest org.example", + } { + if !looksLikeDesktopCommand(command) { + t.Fatalf("expected desktop routing for %q", command) + } + } + if looksLikeDesktopCommand("git status") { + t.Fatal("headless command was classified as desktop") + } +} diff --git a/internal/tools/register.go b/internal/tools/register.go index 7d346f4..9c09ece 100644 --- a/internal/tools/register.go +++ b/internal/tools/register.go @@ -7,6 +7,7 @@ import ( "github.com/Cytech-Team/CyComAgent-MCP/internal/plugins" "github.com/Cytech-Team/CyComAgent-MCP/internal/policy" "github.com/Cytech-Team/CyComAgent-MCP/internal/registry" + "github.com/Cytech-Team/CyComAgent-MCP/internal/sessionbridge" "github.com/Cytech-Team/CyComAgent-MCP/internal/state" "github.com/Cytech-Team/CyComAgent-MCP/internal/targets" ) @@ -21,13 +22,14 @@ type Dependencies struct { Policy *policy.Engine StateDir string Version string + Session sessionbridge.Client } func Register(r *registry.Registry, deps Dependencies) { registerFilesystem(r) - registerProcess(r, processDeps{Jobs: deps.Jobs, Broker: deps.Broker}) + registerProcess(r, processDeps{Jobs: deps.Jobs, Broker: deps.Broker, Session: deps.Session}) registerJobs(r, deps.Jobs) - registerSystem(r, systemDeps{Broker: deps.Broker, Plugins: deps.Plugins, Policy: deps.Policy, Targets: deps.Targets, StateDir: deps.StateDir, Version: deps.Version}) + registerSystem(r, systemDeps{Broker: deps.Broker, Plugins: deps.Plugins, Policy: deps.Policy, Targets: deps.Targets, StateDir: deps.StateDir, Version: deps.Version, Session: deps.Session}) registerTermuxAPI(r, deps.Policy) registerTermuxAssistant(r) registerDesktop(r, deps.StateDir) diff --git a/internal/tools/system.go b/internal/tools/system.go index 250233d..a962f54 100644 --- a/internal/tools/system.go +++ b/internal/tools/system.go @@ -20,6 +20,7 @@ import ( "github.com/Cytech-Team/CyComAgent-MCP/internal/plugins" "github.com/Cytech-Team/CyComAgent-MCP/internal/policy" "github.com/Cytech-Team/CyComAgent-MCP/internal/registry" + "github.com/Cytech-Team/CyComAgent-MCP/internal/sessionbridge" "github.com/Cytech-Team/CyComAgent-MCP/internal/targets" ) @@ -30,6 +31,7 @@ type systemDeps struct { Targets *targets.Manager StateDir string Version string + Session sessionbridge.Client } func registerSystem(r *registry.Registry, deps systemDeps) { @@ -74,6 +76,7 @@ func systemInfo(deps systemDeps) map[string]any { wd, _ := os.Getwd() user := os.Getenv("USER") out := map[string]any{"hostname": hostname, "user": user, "cwd": wd, "goos": runtime.GOOS, "goarch": runtime.GOARCH, "cpus": runtime.NumCPU(), "pid": os.Getpid(), "version": deps.Version, "state_dir": deps.StateDir, "root_broker": deps.Broker.Available(), "termux": platform.IsTermux()} + out["desktop_session_bridge"] = desktopSessionBridgeSnapshot(deps.Session) if platform.IsTermux() { out["runtime_profile"] = "mobile_assistant" out["android_device_root_support"] = "not_supported" @@ -392,7 +395,10 @@ func capabilities(r *registry.Registry, deps systemDeps) map[string]any { serviceAdapter = map[string]any{"capability": "service.manage", "adapter": "launchd-user", "score": score(found["launchctl"], 100), "available": found["launchctl"]} } anyAppPath, anyAppAvailable := anyAppInstalledBackend() + sessionStatus := desktopSessionBridgeSnapshot(deps.Session) + sessionAvailable, _ := sessionStatus["available"].(bool) adapters := []map[string]any{ + {"capability": "session.exec", "adapter": "desktop-session-bridge", "score": score(sessionAvailable, 120), "available": sessionAvailable, "status": sessionStatus}, {"capability": "desktop.semantic", "adapter": "codex-computer-use-linux", "score": score(anyAppAvailable, 110), "available": anyAppAvailable, "path": anyAppBackendBase(anyAppPath), "tools": anyAppToolPrefixCount(r)}, {"capability": "local.exec", "adapter": "shell", "score": 100, "available": true}, {"capability": "privileged.exec", "adapter": "root-broker", "score": score(platform.SupportsLocalPrivilege() && deps.Broker.Available(), 100), "available": platform.SupportsLocalPrivilege() && deps.Broker.Available()}, @@ -412,7 +418,7 @@ func capabilities(r *registry.Registry, deps systemDeps) map[string]any { {"capability": "android.api", "adapter": "termux-api", "score": score(platform.IsTermux() && found["termux-battery-status"], 90), "available": platform.IsTermux() && found["termux-battery-status"]}, } sort.Strings(names) - return map[string]any{"tools": names, "binaries": found, "adapters": adapters, "plugins": deps.Plugins.Names(), "targets": deps.Targets.List(), "root_broker": deps.Broker.Available(), "local_privilege_supported": platform.SupportsLocalPrivilege(), "android_device_root_support": func() string { + return map[string]any{"tools": names, "binaries": found, "adapters": adapters, "plugins": deps.Plugins.Names(), "targets": deps.Targets.List(), "root_broker": deps.Broker.Available(), "desktop_session_bridge": sessionStatus, "local_privilege_supported": platform.SupportsLocalPrivilege(), "android_device_root_support": func() string { if runtime.GOOS == "android" || platform.IsTermux() { return "not_supported" } @@ -425,6 +431,24 @@ func capabilities(r *registry.Registry, deps systemDeps) map[string]any { }(), "policy": deps.Policy.Snapshot(), "platform": runtime.GOOS + "/" + runtime.GOARCH} } +func desktopSessionBridgeSnapshot(client sessionbridge.Client) map[string]any { + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + status, err := client.Status(ctx) + if err != nil { + return map[string]any{"available": false, "socket": sessionbridge.DefaultSocketPath()} + } + data, err := json.Marshal(status) + if err != nil { + return map[string]any{"available": true} + } + var out map[string]any + if json.Unmarshal(data, &out) != nil { + return map[string]any{"available": true} + } + return out +} + func parseKeyValue(s string) map[string]string { out := map[string]string{} for _, line := range strings.Split(s, "\n") { diff --git a/packaging/xdg/cycomagent-session-bridge.desktop b/packaging/xdg/cycomagent-session-bridge.desktop new file mode 100644 index 0000000..1ad17ac --- /dev/null +++ b/packaging/xdg/cycomagent-session-bridge.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=CyComAgent Desktop Session Bridge +Comment=Runs desktop-session commands outside the CyComAgent service cgroup +Exec=/usr/local/bin/cycomagent --session-bridge +TryExec=/usr/local/bin/cycomagent +NoDisplay=true +Terminal=false +X-GNOME-Autostart-enabled=true +X-KDE-autostart-after=panel diff --git a/scripts/install-systemd.sh b/scripts/install-systemd.sh index ca42fa2..e8246a8 100755 --- a/scripts/install-systemd.sh +++ b/scripts/install-systemd.sh @@ -8,7 +8,7 @@ if [[ $EUID -ne 0 ]]; then fi id "$USER_NAME" >/dev/null -install -d -m 0755 /usr/local/lib/cycomagent /etc/cycomagent +install -d -m 0755 /usr/local/lib/cycomagent /etc/cycomagent /etc/xdg/autostart install -m 0755 "$ROOT/dist/cycomagent" /usr/local/bin/cycomagent install -m 0755 "$ROOT/dist/cycomagent-root" /usr/local/bin/cycomagent-root install -m 0755 "$ROOT/scripts/watchdog.sh" /usr/local/lib/cycomagent/watchdog.sh @@ -21,6 +21,7 @@ install -m 0644 "$ROOT/packaging/systemd/cycomagent-root@.service" /etc/systemd/ install -m 0644 "$ROOT/packaging/systemd/cycomagent-tunnel@.service" /etc/systemd/system/ install -m 0644 "$ROOT/packaging/systemd/cycomagent-watchdog@.service" /etc/systemd/system/ install -m 0644 "$ROOT/packaging/systemd/cycomagent-watchdog@.timer" /etc/systemd/system/ +install -m 0644 "$ROOT/packaging/xdg/cycomagent-session-bridge.desktop" /etc/xdg/autostart/ # Per-user runtime config is optional; create an empty private file if absent. if [[ ! -e "/etc/cycomagent/${USER_NAME}.env" ]]; then