319 lines
12 KiB
Go
319 lines
12 KiB
Go
package main
|
|
|
|
// computer:* — the OBSERVE rung of governed computer use.
|
|
//
|
|
// The host plans from a MACHINE MAP, not a screenshot. "Get into Fusion 360
|
|
// and build me a flowerpot" starts with the host
|
|
// reading what this box has installed and, for each app, the deepest way in:
|
|
// the app's own scripting door first, the accessibility tree second, a cropped
|
|
// screenshot last. These two ops are that map. They READ; they never inject.
|
|
//
|
|
// computer:inventory — installed applications with version, publisher, path,
|
|
// where the fact came from, and `ways_in` (deepest first).
|
|
// computer:windows — the top-level windows open right now: title, owning
|
|
// process, app, which one has focus.
|
|
//
|
|
// Both ride the management lane like any other op — signed grant on the pod
|
|
// side (default-deny; a deny beats an allow), the op vocabulary in the
|
|
// enforcing report — and are dedicated no-exec handlers: nothing here takes
|
|
// caller arguments into a shell. Where an OS needs a helper (system_profiler,
|
|
// osascript, wmctrl) it is a fixed argv, and the parser is a pure function
|
|
// unit-tested from any OS with captured fixtures ("leech, don't adopt": no new
|
|
// Go module).
|
|
//
|
|
// Honesty over coverage: a platform or a box that cannot enumerate says so
|
|
// (supported:false + reason) instead of answering an empty list that would be
|
|
// read as "nothing installed" or "no windows open".
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// computerApp is one installed application on this box. Path is the program,
|
|
// never a user file. WaysIn is ordered deepest first; the host picks the first
|
|
// rung it can drive.
|
|
type computerApp struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version,omitempty"`
|
|
Publisher string `json:"publisher,omitempty"`
|
|
Path string `json:"path,omitempty"`
|
|
Source string `json:"source"` // registry | applications | desktop | flatpak | snap
|
|
WaysIn []string `json:"ways_in"`
|
|
}
|
|
|
|
// computerWindow is one top-level window open now. PID/App are best effort;
|
|
// Title is what a person sees, which is how a recorded step addresses it.
|
|
type computerWindow struct {
|
|
Title string `json:"title"`
|
|
PID int `json:"pid,omitempty"`
|
|
App string `json:"app,omitempty"`
|
|
Focused bool `json:"focused,omitempty"`
|
|
}
|
|
|
|
// computerReport is the wire shape of both ops. Supported=false carries the
|
|
// reason in the user's terms (a missing tool, a permission not granted, a
|
|
// headless box) — the host relays it, never guesses at it.
|
|
type computerReport struct {
|
|
Platform string `json:"platform"`
|
|
Scan string `json:"scan"` // which enumerator answered
|
|
Supported bool `json:"supported"`
|
|
Reason string `json:"reason,omitempty"`
|
|
ScannedAt string `json:"scanned_at"`
|
|
Apps []computerApp `json:"apps,omitempty"`
|
|
Windows []computerWindow `json:"windows,omitempty"`
|
|
}
|
|
|
|
const computerCmdTimeout = 20 * time.Second
|
|
|
|
// ── ways in: the judgement, platform-neutral, table-driven ───────────────────
|
|
|
|
// The rung names the host reads. Ordered deepest first when several apply.
|
|
const (
|
|
wayCLI = "cli" // a command-line entry point (drive it without a window)
|
|
wayGUI = "gui" // the accessibility tree, then a cropped screenshot — every app has this rung
|
|
)
|
|
|
|
// scriptDoor is one app family with a known scripting door: a way to hand the
|
|
// program a script and have it act, with no clicking. `match` are lowercase
|
|
// substrings tested against the app name and its executable's basename;
|
|
// `doors` are per-GOOS ("" = every OS), deepest first. This is a table of
|
|
// well-known, documented interfaces, not a guess — an entry is added when the
|
|
// vendor documents the door.
|
|
type scriptDoor struct {
|
|
match []string
|
|
exclude []string // a name carrying one of these is a sibling, not the app (an updater, a runtime, a theme)
|
|
doors map[string][]string
|
|
cli bool
|
|
}
|
|
|
|
var scriptDoors = []scriptDoor{
|
|
{match: []string{"fusion 360", "fusion360", "autodesk fusion"},
|
|
doors: map[string][]string{"": {"script:python (Fusion 360 API — scripts run inside the app)"}}},
|
|
{match: []string{"blender"},
|
|
doors: map[string][]string{"": {"script:python (bpy)"}}, cli: true},
|
|
{match: []string{"freecad"},
|
|
doors: map[string][]string{"": {"script:python (FreeCAD API)"}}, cli: true},
|
|
{match: []string{"kicad"},
|
|
doors: map[string][]string{"": {"script:python (pcbnew)"}}},
|
|
{match: []string{"gimp"},
|
|
doors: map[string][]string{"": {"script:python-fu / script-fu"}}, cli: true},
|
|
{match: []string{"inkscape"},
|
|
doors: map[string][]string{"": {"script:actions (inkscape --actions)"}}, cli: true},
|
|
{match: []string{"libreoffice", "openoffice", "soffice"},
|
|
doors: map[string][]string{"": {"script:python-uno"}}, cli: true},
|
|
// Outlook is deliberately absent: the new Outlook has no COM object model.
|
|
{match: []string{"microsoft excel", "excel.exe", "microsoft word", "winword", "microsoft powerpoint", "powerpnt"},
|
|
doors: map[string][]string{"windows": {"script:com-automation (Office object model)"}, "darwin": {"script:applescript (Office dictionary)"}}},
|
|
{match: []string{"adobe photoshop", "adobe illustrator", "adobe indesign", "adobe after effects", "adobe premiere"},
|
|
doors: map[string][]string{"": {"script:extendscript / uxp"}}},
|
|
{match: []string{"autocad"},
|
|
doors: map[string][]string{"windows": {"script:autolisp", "script:com-automation"}, "darwin": {"script:autolisp"}}},
|
|
{match: []string{"solidworks"},
|
|
doors: map[string][]string{"windows": {"script:com-automation (SolidWorks API)"}}},
|
|
{match: []string{"visual studio code", "code.exe", "vscodium", "cursor"}, exclude: []string{"theme", "cursors"},
|
|
doors: map[string][]string{}, cli: true},
|
|
{match: []string{"google chrome", "chromium", "microsoft edge", "brave"}, exclude: []string{"webview2", "update", "updater"},
|
|
doors: map[string][]string{"": {"script:chrome-devtools-protocol (launch with --remote-debugging-port)"}}, cli: true},
|
|
{match: []string{"firefox"},
|
|
doors: map[string][]string{"": {"script:webdriver-bidi / marionette"}}, cli: true},
|
|
{match: []string{"obsidian"},
|
|
doors: map[string][]string{"": {"script:uri (obsidian://)"}}},
|
|
{match: []string{"notepad++"},
|
|
doors: map[string][]string{}, cli: true},
|
|
{match: []string{"vlc"},
|
|
doors: map[string][]string{"": {"script:lua / http interface"}}, cli: true},
|
|
{match: []string{"audacity"},
|
|
doors: map[string][]string{"": {"script:mod-script-pipe"}}},
|
|
{match: []string{"matlab"}, exclude: []string{"runtime"},
|
|
doors: map[string][]string{"": {"script:matlab (-batch)"}}, cli: true},
|
|
{match: []string{"docker desktop"},
|
|
doors: map[string][]string{}, cli: true},
|
|
{match: []string{"terminal", "iterm", "windows terminal", "powershell", "gnome-terminal", "konsole"},
|
|
doors: map[string][]string{}, cli: true},
|
|
}
|
|
|
|
func (d scriptDoor) matches(key string) bool {
|
|
for _, x := range d.exclude {
|
|
if strings.Contains(key, x) {
|
|
return false
|
|
}
|
|
}
|
|
for _, m := range d.match {
|
|
if strings.Contains(key, m) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// waysIn is the judgement: given an app's name and program path, the rungs the
|
|
// host can drive, deepest first. goos is a parameter so the table is testable
|
|
// for every platform from one CI. onPath says whether the program resolves on
|
|
// PATH — a CLI rung the table did not know about.
|
|
func waysIn(name, path, goos string, onPath bool) []string {
|
|
key := strings.ToLower(name) + " " + strings.ToLower(binBase(path)) + " " + strings.ToLower(filepath.Base(path))
|
|
var out []string
|
|
cli := onPath
|
|
for _, d := range scriptDoors {
|
|
if !d.matches(key) {
|
|
continue
|
|
}
|
|
out = append(out, d.doors[goos]...)
|
|
if goos != "" {
|
|
out = append(out, d.doors[""]...)
|
|
}
|
|
if d.cli {
|
|
cli = true
|
|
}
|
|
}
|
|
out = dedupeStrings(out)
|
|
if cli {
|
|
out = append(out, wayCLI)
|
|
}
|
|
return append(out, wayGUI)
|
|
}
|
|
|
|
func dedupeStrings(in []string) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, s := range in {
|
|
if s == "" || seen[s] {
|
|
continue
|
|
}
|
|
seen[s] = true
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// execOnPath reports whether a program name (or an absolute path that exists)
|
|
// resolves to something runnable — the honest test for a CLI rung.
|
|
func execOnPath(p string) bool {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
return false
|
|
}
|
|
if filepath.IsAbs(p) {
|
|
st, err := os.Stat(p)
|
|
return err == nil && !st.IsDir()
|
|
}
|
|
_, err := exec.LookPath(p)
|
|
return err == nil
|
|
}
|
|
|
|
// finishApps dedupes by name (first fact wins — the more authoritative source
|
|
// enumerates first on every platform), fills ways_in, and sorts by name so two
|
|
// scans of an unchanged box compare equal.
|
|
func finishApps(apps []computerApp) []computerApp {
|
|
seen := map[string]bool{}
|
|
out := make([]computerApp, 0, len(apps))
|
|
for _, a := range apps {
|
|
a.Name = strings.TrimSpace(a.Name)
|
|
if a.Name == "" {
|
|
continue
|
|
}
|
|
k := strings.ToLower(a.Name)
|
|
if seen[k] {
|
|
continue
|
|
}
|
|
seen[k] = true
|
|
a.WaysIn = waysIn(a.Name, a.Path, runtime.GOOS, execOnPath(a.Path))
|
|
out = append(out, a)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) })
|
|
return out
|
|
}
|
|
|
|
// ── the two ops ──────────────────────────────────────────────────────────────
|
|
|
|
func computerInventoryReport() computerReport {
|
|
rep := computerReport{Platform: runtime.GOOS, Scan: computerInventoryKind,
|
|
ScannedAt: time.Now().UTC().Format(time.RFC3339)}
|
|
if !computerObserveSupported {
|
|
rep.Reason = "no inventory enumerator for this platform"
|
|
return rep
|
|
}
|
|
apps, err := listInstalledApps()
|
|
if err != nil {
|
|
rep.Reason = err.Error()
|
|
return rep
|
|
}
|
|
rep.Supported = true
|
|
rep.Apps = finishApps(apps)
|
|
return rep
|
|
}
|
|
|
|
func computerWindowsReport() computerReport {
|
|
rep := computerReport{Platform: runtime.GOOS, Scan: computerWindowsKind,
|
|
ScannedAt: time.Now().UTC().Format(time.RFC3339)}
|
|
if !computerObserveSupported {
|
|
rep.Reason = "no window enumerator for this platform"
|
|
return rep
|
|
}
|
|
wins, err := listWindows()
|
|
if err != nil {
|
|
rep.Reason = err.Error()
|
|
return rep
|
|
}
|
|
rep.Supported = true
|
|
if wins == nil {
|
|
wins = []computerWindow{}
|
|
}
|
|
rep.Windows = wins
|
|
return rep
|
|
}
|
|
|
|
// The handlers answer with the JSON report on stdout and exit 0 — an
|
|
// unsupported box is a true answer ("I cannot see"), not a refusal (126 is
|
|
// reserved for "not permitted"). Caller args are ignored: there are none.
|
|
func handleComputerInventory(ws *websocket.Conn, _ string, _ []string) {
|
|
writeComputerReport(ws, computerInventoryReport())
|
|
}
|
|
|
|
func handleComputerWindows(ws *websocket.Conn, _ string, _ []string) {
|
|
writeComputerReport(ws, computerWindowsReport())
|
|
}
|
|
|
|
func writeComputerReport(ws *websocket.Conn, rep computerReport) {
|
|
b, err := json.Marshal(rep)
|
|
if err != nil {
|
|
sendMgmt(ws, 'R', []byte("report: "+err.Error()+"\n"))
|
|
sendMgmtExit(ws, 1)
|
|
return
|
|
}
|
|
sendMgmt(ws, 'O', append(b, '\n'))
|
|
sendMgmtExit(ws, 0)
|
|
}
|
|
|
|
// runFixed runs a fixed argv with the op's timeout and returns stdout. Used by
|
|
// the drivers that lean on an OS tool; the argv never carries caller input.
|
|
// On failure the *exec.ExitError carries stderr (Output() folds it in); the
|
|
// drivers read it from there, never from err.Error(), which says only
|
|
// "exit status N".
|
|
func runFixed(name string, args ...string) ([]byte, error) {
|
|
return runFixedEnv(nil, name, args...)
|
|
}
|
|
|
|
// runFixedEnv is runFixed with extra environment entries (DISPLAY=:0 for a
|
|
// service that has no display of its own).
|
|
func runFixedEnv(extraEnv []string, name string, args ...string) ([]byte, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), computerCmdTimeout)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
hideConsole(cmd) // the Windows UIAutomation helper is PowerShell — console_windows.go
|
|
if len(extraEnv) > 0 {
|
|
cmd.Env = append(os.Environ(), extraEnv...)
|
|
}
|
|
return cmd.Output()
|
|
}
|