From 99955c9edec61601ab02a5e6e8cde32f48a77a0e Mon Sep 17 00:00:00 2001 From: "335923591@qq.com" <335923591@qq.com> Date: Thu, 25 Jun 2026 14:20:27 +0800 Subject: [PATCH 1/6] feat(seatassign): WRH seat-assignment shared library (byte-identical master/proxy) Co-Authored-By: Claude Opus 4.8 --- seatassign/go.mod | 3 + seatassign/seatassign.go | 121 +++++++++++++++++++++++++++ seatassign/seatassign_test.go | 149 ++++++++++++++++++++++++++++++++++ 3 files changed, 273 insertions(+) create mode 100644 seatassign/go.mod create mode 100644 seatassign/seatassign.go create mode 100644 seatassign/seatassign_test.go diff --git a/seatassign/go.mod b/seatassign/go.mod new file mode 100644 index 0000000..f935acc --- /dev/null +++ b/seatassign/go.mod @@ -0,0 +1,3 @@ +module github.com/AiKeyLabs/pkg/seatassign + +go 1.26.1 diff --git a/seatassign/seatassign.go b/seatassign/seatassign.go new file mode 100644 index 0000000..36c31ad --- /dev/null +++ b/seatassign/seatassign.go @@ -0,0 +1,121 @@ +// 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 +// / HRW hashing). +// +// Why a shared module (not duplicated in master + proxy): the master computes a +// seat's account assignment (the hash default, before any scheduler override) +// and the proxy computes the SAME hash default locally so it can route while +// offline or before the routing-override poll returns. If the two sides ranked +// accounts even slightly differently, the proxy's offline default would disagree +// with the master's assignment → inconsistent routing (a request could land on +// an account the master accounts to a different seat, breaking the per-account +// user caps and prompt-cache stickiness). Both repos import THIS package via +// go.mod replace (like pkg/usagehash), so the ordering is identical by +// construction. Any change to the algorithm flips the pinned-vector test on +// purpose — that is the signal to re-validate BOTH consumers. +// +// Algorithm: weighted rendezvous hashing (WRH). For each candidate account a, +// +// score(seat, a) = -weight / ln( h01(seat, a) ) +// +// (the standard WRH form: P(account is the max) ∝ weight) where h01 ∈ (0,1) is a +// uniform hash of (seatID, accountID). Highest score = +// the seat's primary; the full descending order is its fallback chain. WRH gives +// (1) even spread of seats across accounts, (2) adding/removing one account +// re-homes only ~1/N of seats — no mass reshuffle (unlike `hash mod N`), (3) +// per-account weights for capacity differences. Ties break deterministically by +// (priority, accountID) so the order is a total order with no hidden randomness. +package seatassign + +import ( + "crypto/sha256" + "encoding/binary" + "math" + "sort" +) + +// 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. + 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). + Weight float64 + // Priority is a deterministic tie-break when two accounts score equal + // (astronomically rare). Lower wins. + Priority int +} + +// Rank returns accounts ordered by the seat's preference, primary first. +// Deterministic and byte-identical across master & proxy (cross-repo contract). +// The input slice is not mutated. +func Rank(seatID string, accounts []Account) []Account { + out := make([]Account, len(accounts)) + copy(out, accounts) + scores := make(map[string]float64, len(out)) + for _, a := range out { + scores[a.AccountID] = score(seatID, a) + } + sort.SliceStable(out, func(i, j int) bool { + si, sj := scores[out[i].AccountID], scores[out[j].AccountID] + if si != sj { + return si > sj // higher score = preferred + } + if out[i].Priority != out[j].Priority { + return out[i].Priority < out[j].Priority + } + return out[i].AccountID < out[j].AccountID + }) + return out +} + +// Primary returns the seat's top-ranked account ID, or "" if accounts is empty. +func Primary(seatID string, accounts []Account) string { + if len(accounts) == 0 { + return "" + } + best := accounts[0] + bestScore := score(seatID, best) + for _, a := range accounts[1:] { + s := score(seatID, a) + if s > bestScore || (s == bestScore && less(a, best)) { + best, bestScore = a, s + } + } + return best.AccountID +} + +func less(a, b Account) bool { + if a.Priority != b.Priority { + return a.Priority < b.Priority + } + return a.AccountID < b.AccountID +} + +func score(seatID string, a Account) float64 { + w := a.Weight + if w <= 0 { + w = 1 + } + // Standard weighted rendezvous: -w/ln(h01). h01∈(0,1) ⇒ ln<0 ⇒ score>0; + // higher weight ⇒ higher score ⇒ proportionally more seats. + return -w / math.Log(h01(seatID, a.AccountID)) +} + +// h01 hashes (seatID, accountID) to a uniform float in (0,1). The seatID length +// prefix makes (seatID, accountID) unambiguous so ("ab","c") and ("a","bc") +// never collide. +func h01(seatID, accountID string) float64 { + h := sha256.New() + var n [8]byte + binary.BigEndian.PutUint64(n[:], uint64(len(seatID))) + _, _ = h.Write(n[:]) + _, _ = h.Write([]byte(seatID)) + _, _ = h.Write([]byte(accountID)) + sum := h.Sum(nil) + u := binary.BigEndian.Uint64(sum[:8]) + // Map to (0,1): +1 / +2 keeps the result strictly inside (0,1) so -ln is + // finite and positive. + return (float64(u) + 1) / (float64(math.MaxUint64) + 2) +} diff --git a/seatassign/seatassign_test.go b/seatassign/seatassign_test.go new file mode 100644 index 0000000..fb89e45 --- /dev/null +++ b/seatassign/seatassign_test.go @@ -0,0 +1,149 @@ +package seatassign + +import ( + "fmt" + "math" + "testing" +) + +func mkAccounts(n int) []Account { + a := make([]Account, n) + for i := 0; i < n; i++ { + a[i] = Account{AccountID: fmt.Sprintf("acc-%02d", i)} + } + return a +} + +func seats(n int) []string { + s := make([]string, n) + for i := 0; i < n; i++ { + s[i] = fmt.Sprintf("seat-%05d", i) + } + return s +} + +// TestDeterministic: Rank is a pure function — same inputs, same order, every +// call. This is the floor of the cross-repo byte-identical contract. +func TestDeterministic(t *testing.T) { + accs := mkAccounts(8) + for _, seat := range seats(50) { + first := Rank(seat, accs) + for r := 0; r < 5; r++ { + again := Rank(seat, accs) + for i := range first { + if first[i].AccountID != again[i].AccountID { + t.Fatalf("seat %s non-deterministic at %d: %s vs %s", + seat, i, first[i].AccountID, again[i].AccountID) + } + } + } + if Primary(seat, accs) != first[0].AccountID { + t.Fatalf("Primary != Rank[0] for %s", seat) + } + } +} + +// TestGoldenOrder freezes the ranking for a fixed input — the cross-repo +// contract. If WRH math/hashing changes, this fails on purpose: re-validate both +// master & proxy consumers, don't just paste new strings. +func TestGoldenOrder(t *testing.T) { + accs := mkAccounts(6) + got := Rank("seat-00042", accs) + order := make([]string, len(got)) + for i, a := range got { + order[i] = a.AccountID + } + want := []string{"acc-01", "acc-05", "acc-00", "acc-03", "acc-04", "acc-02"} + for i := range want { + if order[i] != want[i] { + t.Fatalf("golden order changed: got %v want %v", order, want) + } + } +} + +// TestEvenSpread: WRH spreads seats ~uniformly across accounts. +func TestEvenSpread(t *testing.T) { + const M, N = 10000, 10 + accs := mkAccounts(N) + counts := map[string]int{} + for _, seat := range seats(M) { + counts[Primary(seat, accs)]++ + } + exp := float64(M) / float64(N) + for id, c := range counts { + if dev := math.Abs(float64(c)-exp) / exp; dev > 0.15 { + t.Errorf("account %s got %d primaries, expected ~%.0f (dev %.1f%% > 15%%)", id, c, exp, dev*100) + } + } + if len(counts) != N { + t.Errorf("expected all %d accounts to receive seats, got %d", N, len(counts)) + } +} + +// TestAddAccountRehomesAboutOneOverN: adding one account re-homes only ~1/(N+1) +// of seats, and ONLY to the new account — existing accounts never trade seats +// among themselves (the core WRH property that avoids mass reshuffle). +func TestAddAccountRehomesAboutOneOverN(t *testing.T) { + const M, N = 10000, 10 + before := mkAccounts(N) + after := append(mkAccounts(N), Account{AccountID: "acc-NEW"}) + + moved, movedToNew := 0, 0 + for _, seat := range seats(M) { + b := Primary(seat, before) + a := Primary(seat, after) + if a != b { + moved++ + if a == "acc-NEW" { + movedToNew++ + } else { + t.Fatalf("seat %s moved between OLD accounts %s→%s (should never happen)", seat, b, a) + } + } + } + if moved != movedToNew { + t.Fatalf("all moves must go to the new account: moved=%d toNew=%d", moved, movedToNew) + } + exp := float64(M) / float64(N+1) + if dev := math.Abs(float64(moved)-exp) / exp; dev > 0.20 { + t.Errorf("re-homed %d seats, expected ~%.0f (1/(N+1)); dev %.1f%% > 20%%", moved, exp, dev*100) + } +} + +// TestWeightedFavorsHigh: a heavier-weight account attracts proportionally more +// seats. +func TestWeightedFavorsHigh(t *testing.T) { + const M = 10000 + accs := []Account{ + {AccountID: "w1-a"}, {AccountID: "w1-b"}, {AccountID: "w1-c"}, {AccountID: "w1-d"}, + {AccountID: "w3-heavy", Weight: 3}, + } + counts := map[string]int{} + for _, seat := range seats(M) { + counts[Primary(seat, accs)]++ + } + // Total weight 7; heavy share ≈ 3/7. Each w1 ≈ 1/7. Heavy should clearly + // dominate (> 2x any single light account). + heavy := counts["w3-heavy"] + for _, id := range []string{"w1-a", "w1-b", "w1-c", "w1-d"} { + if heavy < 2*counts[id] { + t.Errorf("heavy(%d) should be > 2x light %s(%d)", heavy, id, counts[id]) + } + } + if frac := float64(heavy) / M; frac < 0.33 || frac > 0.52 { + t.Errorf("heavy share %.2f outside expected ~3/7≈0.43", frac) + } +} + +func TestEmptyAndSingle(t *testing.T) { + if Primary("seat-x", nil) != "" { + t.Error("empty accounts must yield empty primary") + } + if len(Rank("seat-x", nil)) != 0 { + t.Error("empty accounts must yield empty rank") + } + one := []Account{{AccountID: "solo"}} + if Primary("seat-x", one) != "solo" { + t.Error("single account must be primary") + } +} From 7a7c6a2fa5c68800525e1995a74285243d40b7ea Mon Sep 17 00:00:00 2001 From: "335923591@qq.com" <335923591@qq.com> Date: Fri, 26 Jun 2026 21:00:54 +0800 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20rename=20seat=5Fgroup=20?= =?UTF-8?q?=E2=86=92=20oauth=5Fgroup=20across=20all=20repos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [pkg] comment-only oauth_group terminology touch-up (seatassign package name kept — its API is generic). Refs: - oauth_group rename plan (why seatassign kept): https://github.com/aikeylabs/roadmap20260320/blob/b8f2ab45258cc310f3b90eb03b6b931dcf8e66c6/技术实现/阶段7-企业生产版/20260626-oauth_group-重命名重构-分阶段实施计划.md --- seatassign/seatassign.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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). From 140ec4123fe99f0612369d4c50541af617436219 Mon Sep 17 00:00:00 2001 From: merge-bot Date: Fri, 26 Jun 2026 21:42:37 +0800 Subject: [PATCH 3/6] chore(pkg): rename seat_group -> oauth_group (align to latest naming) --- seatassign/seatassign.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/seatassign/seatassign.go b/seatassign/seatassign.go index 36c31ad..9e89ac6 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 a 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). From 54175e8da63abb241e798c2aaee9ffaec3cab08d Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 3 Jul 2026 02:08:40 -0700 Subject: [PATCH 4/6] fix: routingwire contract + member login prompt, cross-app language handoff, install entry unification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new routingwire shared module — single wire contract for GET /accounts/me/routing (control-master emitter ↔ proxy consumer), replacing comment-enforced "seat|group" string keys that drifted silently Co-Authored-By: Claude Fable 5 --- routingwire/go.mod | 3 +++ routingwire/routingwire.go | 46 +++++++++++++++++++++++++++++++++ routingwire/routingwire_test.go | 42 ++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 routingwire/go.mod create mode 100644 routingwire/routingwire.go create mode 100644 routingwire/routingwire_test.go 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) + } +} From d90da9751fd2d439048fd92c5dc01924685b957c Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 01:15:55 -0700 Subject: [PATCH 5/6] =?UTF-8?q?feat(aikeycompat):=20HideSpawnConsole=20?= =?UTF-8?q?=E2=80=94=20CREATE=5FNO=5FWINDOW=20for=20service-spawned=20CLI?= =?UTF-8?q?=20children?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows a console-subsystem child inherits the parent's console when one exists but CREATES a visible cmd / Windows Terminal window when the parent is console-less. Our Go services run console-less on the main user path (`aikey web start` uses DETACHED_PROCESS), so every web-bridge `aikey.exe _internal ...` spawn flashed a terminal window over the SPA — one per vault-page request (reported live 2026-07-07). Standard build-tag file pair + per-platform tests, mirroring the perm_/signals_ pattern. Unix is a strict no-op (asserted: must not allocate SysProcAttr, so callers' own setsid attrs can't silently merge). Co-Authored-By: Claude Fable 5 --- aikeycompat/spawn_unix.go | 14 +++++++++ aikeycompat/spawn_unix_test.go | 20 +++++++++++++ aikeycompat/spawn_windows.go | 48 +++++++++++++++++++++++++++++++ aikeycompat/spawn_windows_test.go | 38 ++++++++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 aikeycompat/spawn_unix.go create mode 100644 aikeycompat/spawn_unix_test.go create mode 100644 aikeycompat/spawn_windows.go create mode 100644 aikeycompat/spawn_windows_test.go 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") + } +} From e10c717b8d55aaac707868ceacad822e58bf91f3 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 01:34:22 -0700 Subject: [PATCH 6/6] fix(aikeycompat): hide icacls console window (EnforceOwnerOnly choke point) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit icacls is a console app; every EnforceOwnerOnly call from a console-less parent (aikey-proxy startup / WAL init — 5+ call sites across observability and events) flashed a terminal window on the user's desktop. Fixing the single runIcacls choke point covers all callers. Same class as the 2026-07-07 web-bridge window-flash bug; found by the lateral sweep. Co-Authored-By: Claude Fable 5 --- aikeycompat/perm_windows.go | 5 +++++ 1 file changed, 5 insertions(+) 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))