Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cmd/enowx/icon.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package main

import _ "embed"

// MyGo uses the same source for the executable's fallback notification icon.
//
//go:embed resources/icon.png
var applicationIcon []byte
1 change: 1 addition & 0 deletions cmd/enowx/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ func main() {
}
}
app := enowxui.New(enowxui.Options{
TrayIcon: applicationIcon,
Hosts: store,
Start: start,
DaemonCommand: []string{daemonExe(exe, dir), "daemon"},
Expand Down
123 changes: 123 additions & 0 deletions core/agent/goa_process_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package agent

import (
"maps"
"net/url"
"path/filepath"
"strings"

"github.com/enowx/enowx/core/procstat"
)

// WindowsGOAProcesses recognizes only direct helpers of a tagged Codex and
// their console hosts. Shells and arbitrary helper descendants stay watched.
func WindowsGOAProcesses(agentID, sessionID string, list []procstat.Proc, args func(int) []string,
image func(int) string, tagged func(int) bool, localAppData, systemRoot string) map[int]bool {
out := map[int]bool{}
byPID := map[int]procstat.Proc{}
for _, p := range list {
byPID[p.PID] = p
}
if agentID != "codex" || sessionID == "" {
return out
}
parentOf := func(p procstat.Proc) (procstat.Proc, bool) {
parent, ok := byPID[p.PPID]
// Windows start times are decimal FILETIMEs. Compare numeric length
// first so a wrapped/reused PID cannot supply a younger parent.
older := len(parent.Start) < len(p.Start) || len(parent.Start) == len(p.Start) && parent.Start <= p.Start
return parent, ok && p.Start != "" && parent.Start != "" && older && parent.PID != p.PID
}
for _, p := range list {
if !strings.EqualFold(p.Name, "codex.exe") || !tagged(p.PID) {
continue
}
parent, ok := parentOf(p)
if !ok || !strings.EqualFold(filepath.Base(image(parent.PID)), "node.exe") || !tagged(parent.PID) {
continue
}
argv := args(parent.PID)
if len(argv) < 2 {
continue
}
script := filepath.Clean(filepath.FromSlash(argv[1]))
if !strings.EqualFold(filepath.Base(script), "codex.js") || !strings.EqualFold(filepath.Base(filepath.Dir(script)), "bin") {
continue
}
pkg := filepath.Dir(filepath.Dir(script))
if !strings.EqualFold(filepath.Base(pkg), "codex") || !strings.EqualFold(filepath.Base(filepath.Dir(pkg)), "@openai") {
continue
}
// Resolve an npm junction before comparing the native binary's path.
if real, err := filepath.EvalSymlinks(pkg); err == nil {
pkg = real
}
rel, err := filepath.Rel(pkg, image(p.PID))
out[parent.PID] = err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
}
for _, p := range list {
name := strings.ToLower(p.Name)
if name != "codex-code-mode-host.exe" && name != "node.exe" && name != "node_repl.exe" {
continue
}
if !tagged(p.PID) {
continue
}
parent, ok := parentOf(p)
if !ok || !strings.EqualFold(filepath.Base(image(parent.PID)), "codex.exe") || !tagged(parent.PID) {
continue
}
path, argv := image(p.PID), args(p.PID)
if len(argv) == 0 {
continue
}
name = strings.ToLower(filepath.Base(path))
switch name {
case "codex-code-mode-host.exe":
out[p.PID] = len(argv) == 1 && strings.EqualFold(filepath.Dir(path), filepath.Dir(image(parent.PID)))
case "node.exe", "node_repl.exe":
root := filepath.Join(localAppData, "OpenAI", "Codex", "runtimes", "cua_node")
rel, err := filepath.Rel(root, path)
parts := strings.Split(rel, string(filepath.Separator))
if localAppData == "" || err != nil || len(parts) != 3 || parts[0] == ".." || !strings.EqualFold(parts[1], "bin") {
continue
}
if name == "node_repl.exe" {
out[p.PID] = len(argv) == 1
} else {
// The CUA entry point is fixed; node -e and arbitrary scripts
// from the same runtime are still activity worth flagging.
entry := filepath.Join(filepath.Dir(path), "node_modules", "@oai", "cua-repl", "bin", "cua-repl.mjs")
out[p.PID] = (len(argv) == 2 || len(argv) == 4 && argv[1] == "--import" && cuaAccessHook(argv[2], localAppData)) &&
strings.EqualFold(argv[len(argv)-1], entry)
}
}
}
helperParents := maps.Clone(out)
for _, p := range list {
if !strings.EqualFold(p.Name, "conhost.exe") {
continue
}
parent, ok := parentOf(p)
if !ok || !helperParents[parent.PID] || !tagged(p.PID) || systemRoot == "" ||
!strings.EqualFold(image(p.PID), filepath.Join(systemRoot, "System32", "conhost.exe")) {
continue
}
argv := args(p.PID)
out[p.PID] = len(argv) == 2 && strings.EqualFold(argv[1], "0x4")
}
return out
}

func cuaAccessHook(rawURL, localAppData string) bool {
u, err := url.Parse(rawURL)
if err != nil || u.Scheme != "file" || u.Host != "" || u.RawQuery != "" || u.Fragment != "" {
return false
}
path := filepath.FromSlash(strings.TrimPrefix(u.Path, "/"))
root := filepath.Join(filepath.Dir(filepath.Dir(localAppData)), ".codex", "plugins", "cache", "openai-bundled", "unified-computer-use")
rel, err := filepath.Rel(root, path)
parts := strings.Split(rel, string(filepath.Separator))
return err == nil && len(parts) == 2 && parts[0] != ".." && parts[0] != "." &&
strings.EqualFold(parts[1], "require-browser-access.mjs")
}
120 changes: 120 additions & 0 deletions core/agent/goa_process_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package agent

import (
"path/filepath"
"strings"
"testing"

"github.com/enowx/enowx/core/procstat"
)

func TestGOACodexConsoleAncestry(t *testing.T) {
cli := `C:\tools\codex\bin`
runtime := `C:\fixture\AppData\Local\OpenAI\Codex\runtimes\cua_node\abc\bin`
images := map[int]string{
1: cli + `\codex.exe`, 2: cli + `\codex-code-mode-host.exe`,
3: `C:\Windows\System32\conhost.exe`, 4: runtime + `\node_repl.exe`,
5: runtime + `\node.exe`, 6: `C:\Windows\System32\conhost.exe`,
}
argv := map[int][]string{}
for pid, path := range images {
argv[pid] = []string{path}
}
argv[3] = []string{`\??\C:\Windows\System32\conhost.exe`, "0x4"}
argv[6] = argv[3]
argv[5] = []string{images[5], "--import", "file:///C:/fixture/.codex/plugins/cache/openai-bundled/unified-computer-use/26/require-browser-access.mjs", filepath.Join(runtime, "node_modules", "@oai", "cua-repl", "bin", "cua-repl.mjs")}
list := []procstat.Proc{
{PID: 1, Start: "100"}, {PID: 2, PPID: 1, Start: "101"},
{PID: 3, PPID: 2, Start: "102"}, {PID: 4, PPID: 1, Start: "103"},
{PID: 5, PPID: 1, Start: "104"}, {PID: 6, PPID: 5, Start: "105"},
}
for i := range list {
list[i].Name = filepath.Base(images[list[i].PID])
}
classify := func(list []procstat.Proc, tagged func(int) bool) map[int]bool {
return WindowsGOAProcesses("codex", "goa", list, func(pid int) []string { return argv[pid] },
func(pid int) string { return images[pid] }, tagged, `C:\fixture\AppData\Local`, `C:\Windows`)
}
allTagged := func(int) bool { return true }
for _, pid := range []int{2, 3, 4, 5, 6} {
if !classify(list, allTagged)[pid] {
t.Fatalf("known helper %d was flagged", pid)
}
}
for _, tc := range []struct {
name string
change func([]procstat.Proc)
}{
{"unrelated console", func(ps []procstat.Proc) { ps[2].PPID = 1 }},
{"missing parent", func(ps []procstat.Proc) { ps[2].PPID = 999 }},
{"reused parent PID", func(ps []procstat.Proc) { ps[1].Start = "999" }},
{"unknown start", func(ps []procstat.Proc) { ps[1].Start = "" }},
{"missing console start", func(ps []procstat.Proc) { ps[2].Start = "" }},
} {
t.Run(tc.name, func(t *testing.T) {
ps := append([]procstat.Proc(nil), list...)
tc.change(ps)
if classify(ps, allTagged)[3] {
t.Fatal("unproven console ancestry was exempted")
}
})
}
for _, tc := range []struct {
name string
pid int
path string
args []string
}{
{"copied console", 3, `C:\temp\codex\conhost.exe`, argv[3]},
{"console with other command", 3, images[3], []string{images[3], "arbitrary"}},
{"copied helper", 2, `C:\temp\codex-code-mode-host.exe`, []string{`C:\temp\codex-code-mode-host.exe`}},
{"helper with command", 2, images[2], []string{images[2], "run", "powershell"}},
{"arbitrary node script", 5, images[5], []string{images[5], `C:\work\task.js`}},
{"arbitrary node import", 5, images[5], []string{images[5], "--import", "file:///C:/work/task.mjs", argv[5][3]}},
{"lookalike runtime", 5, strings.Replace(images[5], "cua_node", "cua_node_evil", 1), argv[5]},
} {
t.Run(tc.name, func(t *testing.T) {
path, a := images[tc.pid], argv[tc.pid]
defer func() { images[tc.pid], argv[tc.pid] = path, a }()
images[tc.pid], argv[tc.pid] = tc.path, tc.args
got := classify(list, allTagged)
if got[tc.pid] {
t.Fatal("unknown process exempted")
}
if tc.pid == 2 && got[3] || tc.pid == 5 && got[6] {
t.Fatal("unknown parent's console exempted")
}
})
}
if classify(list, func(pid int) bool { return pid != 1 })[3] {
t.Fatal("another session's Codex accepted")
}
images[7], argv[7] = images[3], argv[3]
ps := append(append([]procstat.Proc(nil), list...), procstat.Proc{PID: 7, PPID: 3, Name: "conhost.exe", Start: "106"})
if classify(ps, allTagged)[7] {
t.Fatal("console exemption propagated through descendants")
}

}

func TestGOACodexNpmLauncherAncestry(t *testing.T) {
pkg := `C:\npm\node_modules\@openai\codex`
images := map[int]string{1: `C:\npm\node.exe`, 2: pkg + `\vendor\bin\codex.exe`, 3: `C:\Windows\System32\conhost.exe`}
argv := map[int][]string{1: {images[1], pkg + `\bin\codex.js`, "--sandbox", "workspace-write"}, 2: {images[2]}, 3: {images[3], "0x4"}}
ps := []procstat.Proc{{PID: 1, Name: "node.exe", Start: "10"}, {PID: 2, PPID: 1, Name: "codex.exe", Start: "11"}, {PID: 3, PPID: 1, Name: "conhost.exe", Start: "12"}}
run := func() map[int]bool {
return WindowsGOAProcesses("codex", "goa", ps, func(pid int) []string { return argv[pid] }, func(pid int) string { return images[pid] }, func(int) bool { return true }, `C:\fixture\AppData\Local`, `C:\Windows`)
}
if got := run(); !got[1] || !got[3] {
t.Fatal("npm launcher or its console was flagged")
}
argv[1][1] = `C:\work\codex.js`
if got := run(); got[1] || got[3] {
t.Fatal("an unrelated Node script or its console was exempted")
}
argv[1][1] = pkg + `\bin\codex.js`
images[2] = `C:\other\codex.exe`
if run()[1] {
t.Fatal("an unrelated native binary vouched for the launcher")
}
}
2 changes: 1 addition & 1 deletion core/agent/goaguide.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ How you work:
Talk to the agent that knows the project: with --project and no --session or --branch, the project's own session takes it, even busy, asking, hibernated or started with other settings. A busy one has it queued (the board says queued), delivered when its turn ends; a stopped one is brought back first. A new session starts only when the project has none, or when the user asks: for a fork, or --branch, for parallel work they agreed to, or a --config they named. delegate says when one started, and why. If a session can't take it, delegate fails: don't work around it with a new one.
Never your own session: it is refused. With no other session, start a new one.
The judge picks each prompt's model and effort (cheaper near a budget): delegate prints them and why; tell the user in a line. When the user names a model, pass --model M --effort E (--config C for another provider); --no-route keeps the agent's own.
3. Don't poll. When a worker finishes, asks something or fails, enowX types a line starting with "[enowX GOA]" into this conversation. Then read what it did ("$ENOWX_BIN" chat ID --host H, changes ID --host H), check it against the task, and go on: the next task, a fix sent back, a review and merge (the user confirms), or a word to the user.
3. Don't poll. When a worker finishes, asks something or fails, enowX delivers a line starting with "[enowX GOA]" through a supported hook; without one, the report waits in Tasks under Waiting for GOA (goa board shows it). It never pastes reports into the terminal composer. Then read what it did ("$ENOWX_BIN" chat ID --host H, changes ID --host H), check it against the task, and go on: the next task, a fix sent back, a review and merge (the user confirms), or a word to the user.
4. Workers' permission prompts: the judge answers what it is sure of. The rest you answer after reading what is asked ("$ENOWX_BIN" answer ID --host H, then answer ID allow|deny --host H). Urgent ones (what can't be undone, money, publishing, secrets, the judge unsure) are the user's: they get a sound and a banner; don't answer those, say they wait on the user.
5. A turn's end isn't a task's: "turn-ended" on the board means the worker stopped, maybe mid-work. Only you settle it (goa task ID --status done|failed --result "its report"), and that report is what's remembered. Keep the board true: "$ENOWX_BIN" goa board shows it to you as the user sees it; goa task ID --status done|failed|stopped --result "…" settles one.
A line saying a task "isn't moving" (never started, idle, stuck; "stalled" on the board) means nothing is happening: read its screen before anything else, restart or move the task, and tell the user. Never report a stalled task as under way, nor repeat a step that already failed.
Expand Down
14 changes: 0 additions & 14 deletions core/goa/goa_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,20 +84,6 @@ func TestUrgent(t *testing.T) {
}
}

// Claude Code and Codex hold GOA to enowX's tools with their hooks;
// Claude Code also starts with them only; an agent with neither is watched.
func TestLevels(t *testing.T) {
if l, _ := LevelOf("claude"); l != proto.GOAEnforced {
t.Errorf("claude: %s", l)
}
if l, _ := LevelOf("codex"); l != proto.GOAEnforced {
t.Errorf("codex: %s", l)
}
if l, _ := LevelOf("aider"); l != proto.GOAWatched {
t.Errorf("aider: %s", l)
}
}

// Tasks keep their sessions and statuses on disk; a session's unsettled
// tasks change together, settled ones don't.
func TestBoard(t *testing.T) {
Expand Down
17 changes: 17 additions & 0 deletions core/goa/level_other_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//go:build !windows

package goa

import (
"testing"

"github.com/enowx/enowx/proto"
)

func TestLevels(t *testing.T) {
for id, want := range map[string]string{"claude": proto.GOAEnforced, "codex": proto.GOAEnforced, "aider": proto.GOAWatched} {
if got, _ := LevelOf(id); got != want {
t.Errorf("%s: got %s want %s", id, got, want)
}
}
}
17 changes: 17 additions & 0 deletions core/goa/level_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package goa

import (
"testing"

"github.com/enowx/enowx/proto"
)

func TestLevels(t *testing.T) {
// POSIX hook commands aren't installed on Windows. Tool restrictions
// alone limit Claude; Codex and agents without either remain watched.
for id, want := range map[string]string{"claude": proto.GOALimited, "codex": proto.GOAWatched, "aider": proto.GOAWatched} {
if got, _ := LevelOf(id); got != want {
t.Errorf("%s: got %s want %s", id, got, want)
}
}
}
17 changes: 11 additions & 6 deletions daemon/goa.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,15 @@ import (
// allows (its hooks refuse the rest here; goa_watch.go flags what an agent
// without either does). Its board follows its workers on every host: this
// host's through their updates, others' through the app (goa.worker); what
// changes is typed into GOA once it is idle, so it never polls.
// changes reaches GOA through hooks, or waits in Tasks without touching its draft.

// goaState is what the daemon keeps of GOA. The board is safe on its own;
// mu guards the rest.
type goaState struct {
board *goa.Board
// delivery serializes queue checkout, hook handoff and return. Never
// hold mu while waiting for it or while saving the queue.
delivery sync.Mutex

mu sync.Mutex
tell []goaLine // lines for GOA until it takes them, kept in tellFile
Expand Down Expand Up @@ -228,7 +231,7 @@ func (s *Server) goaRanOn(info proto.SessionInfo) {
}
}

// goaTell has line typed into GOA once it is idle.
// goaTell queues a line for GOA's safe delivery channels.
func (s *Server) goaTell(line string) { s.goaTellLine(goaLine{Text: line}) }

// goaLine is a line waiting for GOA. One about a task under way (Task set: its report,
Expand All @@ -255,14 +258,16 @@ func (s *Server) goaTellLine(line goaLine) {
if !ok {
return
}
s.goa.delivery.Lock()
s.goa.mu.Lock()
s.goa.tell = append(s.goa.tell, line)
s.goa.tell = uniqueGOALines(append(s.goa.tell, line))
if over := len(s.goa.tell) - goaTellMax; over > 0 {
s.goa.tell = slices.Delete(s.goa.tell, 0, over)
s.logger().Warn("goa: too much waits for GOA, the oldest goes", "lines", over)
}
s.goa.mu.Unlock()
s.saveGOATell()
s.goa.delivery.Unlock()
if !g.Exited && g.State != proto.StateWorking {
s.goaFlush(g.ID)
}
Expand Down Expand Up @@ -300,12 +305,12 @@ func (s *Server) loadGOATell() {
return
}
s.goa.mu.Lock()
s.goa.tell = lines
s.goa.tell = uniqueGOALines(lines)
s.goa.mu.Unlock()
}

// goaFlushIdle has an idle GOA get what still waits for it: what a failed typing
// put back, or what waited over a restart with no change of its session to come.
// goaFlushIdle offers an idle GOA's waiting lines to a documented hook.
// Without one, they stay in Tasks, including across a daemon restart.
func (s *Server) goaFlushIdle() {
s.goa.mu.Lock()
waiting := len(s.goa.tell) > 0
Expand Down
Loading
Loading