Skip to content
5 changes: 5 additions & 0 deletions aikeycompat/perm_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
14 changes: 14 additions & 0 deletions aikeycompat/spawn_unix.go
Original file line number Diff line number Diff line change
@@ -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) {}
20 changes: 20 additions & 0 deletions aikeycompat/spawn_unix_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
48 changes: 48 additions & 0 deletions aikeycompat/spawn_windows.go
Original file line number Diff line number Diff line change
@@ -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
}
38 changes: 38 additions & 0 deletions aikeycompat/spawn_windows_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
3 changes: 3 additions & 0 deletions routingwire/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/AiKeyLabs/pkg/routingwire

go 1.26.1
46 changes: 46 additions & 0 deletions routingwire/routingwire.go
Original file line number Diff line number Diff line change
@@ -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"`
}
42 changes: 42 additions & 0 deletions routingwire/routingwire_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 2 additions & 2 deletions seatassign/seatassign.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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).
Expand Down
Loading