diff --git a/aikeycompat/perm_windows.go b/aikeycompat/perm_windows.go index c864d11..db82093 100644 --- a/aikeycompat/perm_windows.go +++ b/aikeycompat/perm_windows.go @@ -71,6 +71,11 @@ func EnforceOwnerOnly(path string) error { func runIcacls(path string, args ...string) error { full := append([]string{path}, args...) cmd := exec.Command("icacls", full...) + // icacls is a console app; without this every EnforceOwnerOnly call from + // a console-less parent (aikey-proxy startup / WAL init — 5+ call sites) + // flashes a terminal window on the user's desktop. Same class as the + // 2026-07-07 web-bridge window-flash bug; see HideSpawnConsole docs. + HideSpawnConsole(cmd) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("icacls %v: %w (output: %s)", args, err, string(out)) diff --git a/aikeycompat/spawn_unix.go b/aikeycompat/spawn_unix.go new file mode 100644 index 0000000..ecf927b --- /dev/null +++ b/aikeycompat/spawn_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package aikeycompat + +import "os/exec" + +// HideSpawnConsole marks a child process so it never opens a console +// window. No-op on Unix: POSIX terminals attach via file descriptors, +// spawning can't create a new terminal window by itself. +// +// See spawn_windows.go for the Windows semantics and the bug this +// prevents (bridge subprocesses flashing cmd/Windows Terminal windows +// over the Web Console, 2026-07-07). +func HideSpawnConsole(cmd *exec.Cmd) {} diff --git a/aikeycompat/spawn_unix_test.go b/aikeycompat/spawn_unix_test.go new file mode 100644 index 0000000..06f1264 --- /dev/null +++ b/aikeycompat/spawn_unix_test.go @@ -0,0 +1,20 @@ +//go:build !windows + +package aikeycompat + +import ( + "os/exec" + "testing" +) + +// Unix contract: HideSpawnConsole must be a pure no-op — in particular it +// must NOT allocate a SysProcAttr, because callers on the Unix service +// path may later install their own (setsid etc.) and an unexpected +// non-nil struct would silently merge with theirs. +func TestHideSpawnConsoleIsNoOpOnUnix(t *testing.T) { + cmd := exec.Command("true") + HideSpawnConsole(cmd) + if cmd.SysProcAttr != nil { + t.Fatalf("HideSpawnConsole must not touch SysProcAttr on Unix, got %+v", cmd.SysProcAttr) + } +} diff --git a/aikeycompat/spawn_windows.go b/aikeycompat/spawn_windows.go new file mode 100644 index 0000000..e8b3cc1 --- /dev/null +++ b/aikeycompat/spawn_windows.go @@ -0,0 +1,48 @@ +//go:build windows + +package aikeycompat + +import ( + "os/exec" + "syscall" +) + +// CREATE_NO_WINDOW — the child is a console-subsystem process but gets a +// console with no window at all. Defined here because package syscall +// doesn't export it (x/sys/windows does, but this is our only use — not +// worth the extra dependency). +const createNoWindow = 0x08000000 + +// HideSpawnConsole marks a child process so it never opens a console +// window. +// +// Why this exists (2026-07-07, Windows Web Console regression class): on +// Windows a console-subsystem child INHERITS the parent's console when the +// parent has one, but CREATES a fresh one — a visible cmd / Windows +// Terminal window — when the parent has none. Our Go services run +// console-less on the main user path (`aikey web start` spawns +// aikey-local-server with DETACHED_PROCESS, see aikey-cli +// local_server_probe.rs spawn_detached), so every web-bridge +// `aikey.exe _internal ...` invocation flashed a terminal window over the +// SPA — one per page request. The same services launched from the +// ScheduledTask (cmd.exe /c) happened to have a console to inherit, which +// is why the bug only showed on some launch paths. +// +// CREATE_NO_WINDOW is the standard fix: the child still gets a console +// (stdio redirection and Ctrl handlers keep working, and grandchildren +// inherit it — so an `_internal` call that itself starts aikey-proxy stays +// windowless too), it just never materializes a window. HideWindow is set +// as well for defense in depth (covers children that create their own +// windows via STARTUPINFO). +// +// Mirrors the Rust side's established DETACHED_NO_WINDOW pattern +// (aikey-cli local_server_probe.rs, 2026-04-30). We deliberately do NOT +// use DETACHED_PROCESS here: these are short-lived request-scoped children +// whose stdout/stderr the caller captures. +func HideSpawnConsole(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.HideWindow = true + cmd.SysProcAttr.CreationFlags |= createNoWindow +} diff --git a/aikeycompat/spawn_windows_test.go b/aikeycompat/spawn_windows_test.go new file mode 100644 index 0000000..f518ba4 --- /dev/null +++ b/aikeycompat/spawn_windows_test.go @@ -0,0 +1,38 @@ +//go:build windows + +package aikeycompat + +import ( + "os/exec" + "syscall" + "testing" +) + +// Windows contract: the child must get CREATE_NO_WINDOW + HideWindow, and +// pre-existing SysProcAttr contents (e.g. a caller-set flag) must survive. +func TestHideSpawnConsoleSetsNoWindowFlags(t *testing.T) { + cmd := exec.Command("cmd.exe", "/c", "exit 0") + HideSpawnConsole(cmd) + if cmd.SysProcAttr == nil { + t.Fatal("SysProcAttr not allocated") + } + if !cmd.SysProcAttr.HideWindow { + t.Error("HideWindow not set") + } + if cmd.SysProcAttr.CreationFlags&createNoWindow == 0 { + t.Errorf("CREATE_NO_WINDOW missing from CreationFlags: %#x", cmd.SysProcAttr.CreationFlags) + } +} + +func TestHideSpawnConsolePreservesExistingFlags(t *testing.T) { + const createNewProcessGroup = 0x00000200 + cmd := exec.Command("cmd.exe", "/c", "exit 0") + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNewProcessGroup} + HideSpawnConsole(cmd) + if cmd.SysProcAttr.CreationFlags&createNewProcessGroup == 0 { + t.Error("pre-existing CreationFlags were clobbered") + } + if cmd.SysProcAttr.CreationFlags&createNoWindow == 0 { + t.Error("CREATE_NO_WINDOW not OR-ed in") + } +} diff --git a/routingwire/go.mod b/routingwire/go.mod new file mode 100644 index 0000000..84bc46f --- /dev/null +++ b/routingwire/go.mod @@ -0,0 +1,3 @@ +module github.com/AiKeyLabs/pkg/routingwire + +go 1.26.1 diff --git a/routingwire/routingwire.go b/routingwire/routingwire.go new file mode 100644 index 0000000..2c739f5 --- /dev/null +++ b/routingwire/routingwire.go @@ -0,0 +1,46 @@ +// Package routingwire is the SINGLE definition of the GET /accounts/me/routing +// wire contract between aikey-control-master (emitter: the allocation engine's +// per-(seat,group) account bindings) and aikey-proxy (consumer: the +// RoutingOverrideCache the hot-path resolver reads). +// +// Why a shared module (not duplicated structs): this wire previously carried its +// per-(seat,group) semantics inside string-concatenated map keys ("seat|group"), +// held in lockstep across the two repos by nothing but a comment — a format drift +// on either side made every lookup miss SILENTLY (override loss + the ≤3-users +// pool-full 429 vanishing; 2026-07 review finding F1). Both repos import THIS +// package via go.mod replace (like pkg/seatassign), so a field change breaks the +// build on both sides instead of breaking routing at runtime. +// +// Evolution: entries are self-describing objects — add optional fields here +// (additive) instead of inventing new key encodings. The structured shape was +// finalized 2026-07-02 while the enterprise edition is still unpublished (no +// external consumers), per the "design the final schema before first release" +// rule; there is deliberately NO legacy assignments/blocked map emission. +package routingwire + +// RoutingResponse is the GET /accounts/me/routing body: the engine's +// AUTHORITATIVE per-(seat,group) account bindings projected from the +// oauth_group_member_identity ledger, plus a routing_version the proxy compares +// to skip unchanged polls. A (seat,group) pair with NO entry isn't bound yet — +// the proxy local-picks for ≤1 engine tick until the next bind. +type RoutingResponse struct { + RoutingVersion int64 `json:"routing_version"` + Routes []RouteEntry `json:"routes"` +} + +// RouteEntry is one seat's binding within one group. +// +// Exactly one of the two states is expressed: +// - AccountID != "" → the engine's sticky binding (proxy applies it +// when the account is still a valid, usable candidate; else local pick). +// - Blocked == true → the engine left this (seat,group) UNBOUND +// because every pool account is at the per-account user cap — the proxy MUST +// 429 and never fall back to the cap-blind local pick. +// +// AccountID == "" with Blocked == false is not emitted. +type RouteEntry struct { + SeatID string `json:"seat_id"` + GroupID string `json:"group_id"` + AccountID string `json:"account_id,omitempty"` + Blocked bool `json:"blocked,omitempty"` +} diff --git a/routingwire/routingwire_test.go b/routingwire/routingwire_test.go new file mode 100644 index 0000000..7b4e050 --- /dev/null +++ b/routingwire/routingwire_test.go @@ -0,0 +1,42 @@ +package routingwire + +import ( + "encoding/json" + "strings" + "testing" +) + +// The wire shape is a cross-repo contract — lock the exact field names and the +// round-trip so an accidental tag rename shows up here first (both repos import +// this package, so a STRUCT change breaks their builds; this test pins the JSON). +func TestRoutingResponse_RoundTripAndFieldNames(t *testing.T) { + in := RoutingResponse{ + RoutingVersion: 42, + Routes: []RouteEntry{ + {SeatID: "seat-1", GroupID: "g1", AccountID: "acc-1"}, + {SeatID: "seat-1", GroupID: "g2", AccountID: "acc-2"}, + {SeatID: "seat-2", GroupID: "g1", Blocked: true}, + }, + } + raw, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + for _, want := range []string{`"routing_version":42`, `"routes":[`, `"seat_id":"seat-1"`, `"group_id":"g2"`, `"account_id":"acc-2"`, `"blocked":true`} { + if !strings.Contains(string(raw), want) { + t.Fatalf("wire JSON missing %q: %s", want, raw) + } + } + // A blocked entry must not carry an account_id (omitempty on the empty string). + if strings.Contains(string(raw), `"account_id":""`) { + t.Fatalf("empty account_id must be omitted: %s", raw) + } + var out RoutingResponse + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.RoutingVersion != 42 || len(out.Routes) != 3 || + out.Routes[1].AccountID != "acc-2" || !out.Routes[2].Blocked || out.Routes[2].AccountID != "" { + t.Fatalf("round-trip mismatch: %+v", out) + } +} diff --git a/seatassign/seatassign.go b/seatassign/seatassign.go index 36c31ad..39d0a1d 100644 --- a/seatassign/seatassign.go +++ b/seatassign/seatassign.go @@ -1,5 +1,5 @@ // Package seatassign is the SINGLE SOURCE OF TRUTH for mapping a seat to its -// preferred order of provider accounts within a seat_group (weighted rendezvous +// preferred order of provider accounts within an oauth_group (weighted rendezvous // / HRW hashing). // // Why a shared module (not duplicated in master + proxy): the master computes a @@ -37,7 +37,7 @@ import ( // Account is one provider account a seat can be assigned to within a group. type Account struct { // AccountID is the stable identity hashed for ranking - // (seat_group_account.account_id). MUST be stable across master & proxy. + // (oauth_group_account.account_id). MUST be stable across master & proxy. AccountID string // Weight is the WRH capacity weight; <= 0 is treated as 1. A higher weight // proportionally attracts more seats (for accounts with larger quota).