Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
c0d9f1f
feat(proxy): seat_group resolve/select, persona normalization, group …
Jakegogo Jun 25, 2026
9d05522
feat: seat-group (OAuth account pool) end-to-end + live-E2E fixes
Jakegogo Jun 26, 2026
4ef99a9
spike(proxy): I5 emit — self-contained fault-isolated signal reporter
Jun 26, 2026
d09ac0e
spike(proxy): wire I5 signal emit end-to-end (hook + team-JWT bearer)
Jun 26, 2026
fd0b4de
feat: seat-group invite-time group join + master OAuth egress proxy +…
Jakegogo Jun 26, 2026
b48a280
Merge remote-tracking branch 'origin/feature/seat-group' into spike/a…
Jun 26, 2026
6d83cab
test(proxy): add I5 signal-reporter unit tests
Jun 26, 2026
cfe8491
feat(proxy): emit hard-revoke signal on 401 token-revoked (revoked-feed)
Jun 26, 2026
638cc9e
fix: group-VK no-local-material handling + reject OAuth direct-bind +…
Jakegogo Jun 26, 2026
f0f5fa8
Merge remote-tracking branch 'origin/feature/seat-group' into spike/a…
Jun 26, 2026
d08efbe
feat(proxy): count 429/403 per credential and report frequency (429-f…
Jun 26, 2026
1e8d580
feat(proxy): emit util_7d + per-credential concurrency peak
Jun 26, 2026
b8459a4
test(proxy): chokepoint围栏 — every pool oauthInject site applies perso…
Jun 26, 2026
9eb9bc7
feat(proxy): consume allocation-engine routing overrides (the keystone)
Jun 26, 2026
e60b01c
refactor: rename seat_group → oauth_group across all repos
Jakegogo Jun 26, 2026
7f1aa94
refactor: complete seat_group → oauth_group rename (comments / string…
Jakegogo Jun 26, 2026
736da29
chore(proxy): rename seat_group -> oauth_group (align to latest naming)
Jun 26, 2026
d7ea631
Merge branch 'rename/alloc-engine-signal-emit-oauth' into merge/alloc…
Jakegogo Jun 26, 2026
26d64b1
fix(proxy): 2 HIGH review findings (signal-reporter leak + routing v0…
Jun 27, 2026
dd8ce91
Merge pull request #3 from aikeylabs/fix/alloc-engine-high-findings
raysonmeng Jun 27, 2026
ed4bc98
Merge pull request #2 from aikeylabs/merge/alloc-engine-signal-emit-i…
raysonmeng Jun 27, 2026
0898b76
fix: capture oauth group conversation audit
Jun 28, 2026
bcea29a
fix: capture oauth group conversation audit
Jun 28, 2026
46545a3
Remove account-sharing fingerprint forgery (类a)
Jun 29, 2026
c66c27a
OAuth pool: proxy enforces the ledger binding + 429s pool-full seats …
Jun 29, 2026
18d20df
feat: OAuth account pool per-member token model + seat-group integration
Jun 30, 2026
6fdfbe4
Merge merge/alloc-engine-signal-emit-into-seat-group into feature/sea…
Jun 30, 2026
75cc81a
Merge pull request #4 from aikeylabs/feature/oauth-pool-segment-identity
raysonmeng Jun 30, 2026
4917b50
feat: member-plane + reclaim repository for OAuth seat groups; settin…
Jun 30, 2026
f7ebee5
fix(proxy): serve group VK on follow-active path; add E9 429-classifi…
Jun 30, 2026
df96907
fix(proxy): thread credential_id through group route for I5 signal up…
Jun 30, 2026
009e73d
Merge pull request #5 from aikeylabs/merge/session-fixes-into-seat-group
raysonmeng Jun 30, 2026
00100bc
fix(proxy): bypass env HTTP_PROXY for control-plane requests (httpx.D…
Jun 30, 2026
b699ec2
fix(proxy): route ImpersonateChrome OAuth client through configured e…
Jun 30, 2026
299cca0
feat: OAuth account-pool per-member token model + hot-swappable egres…
Jul 1, 2026
c31e321
fix: group-route degrade returns fail-fast status + buildBaseEvent ni…
Jul 1, 2026
bdc94e0
feat: employee OAuth self-contribute (email+password) + multi-group V…
Jul 1, 2026
0b65a49
fix: control-plane self-heal on network change + allocation-engine de…
Jul 2, 2026
d6bad83
fix: unify seat→pool-account routed pick behind poolroute single sour…
Jul 2, 2026
97ad785
fix: proxy reboot PID-reuse lockout + surface routed OAuth account in…
Jul 2, 2026
d7cdfdf
fix: routingwire contract + member login prompt, cross-app language h…
Jul 3, 2026
d4155f3
fix(vault): apply busy_timeout DSN guard to all raw vault connections
Jul 3, 2026
5676902
fix: SyncRail framework — control-plane rails re-evaluate per cycle, …
Jul 3, 2026
ccf2c43
feat: macOS notarization + aikey service/web status; fix delivery-sha…
Jul 4, 2026
3ccbc1c
fix(vault): route quota + events sqlite opens through exported busy_t…
Jul 4, 2026
e9a10a4
Merge pull request #6 from aikeylabs/fix/vault-busy-timeout
raysonmeng Jul 4, 2026
33570e0
feat: OAuth pool codex provider support (R34) + gateway-unreachable d…
Jul 4, 2026
22a7903
merge: fold oauth_group_bugfix into feature/seat-group (codex pool R3…
Jul 4, 2026
3ca0f3c
feat(codex): data-plane originator -> codex_cli_rs (official codex CL…
Jul 6, 2026
a30b23b
feat(codex): feed Codex utilization to the allocation engine (parseCo…
Jul 6, 2026
0cbfde2
fix(codex): cool for the LONGEST exhausted-window reset, not the prim…
Jul 6, 2026
f4e56a6
feat(codex): proactive window pre-cut + Path-Z reset for Codex (anti-…
Jul 6, 2026
b678bb2
fix(proxy): price codex usage locally — backfill empty model in accru…
Jul 6, 2026
7478d33
Merge pull request #7 from aikeylabs/group-support-codex
raysonmeng Jul 6, 2026
19ffd0f
fix(vault): guard WriteAssignmentOverride with WithBusyTimeoutDSN + s…
Jul 7, 2026
8289694
fix(apphook): hide app-hook child console window on Windows
Jul 7, 2026
1b71c22
fix(audit): capture Codex conversations — OpenAI Responses API wire f…
Jul 7, 2026
d8fb67c
feat(audit): stamp seat_id on conversation records (usage-aligned att…
Jul 7, 2026
5b17415
fix(filter): scan codex OpenAI Responses API bodies (compliance was b…
Jul 8, 2026
eb394bd
feat(filter): stamp seat_id on compliance events (audit-page alias re…
Jul 8, 2026
ffaf17f
feat(filter): stamp session_id on compliance events (cross-audit deep…
Jul 8, 2026
ca02518
feat: egress auto-follows system proxy (new sysproxy pkg) + moonshot-…
Jul 8, 2026
3d69394
fix: proxy startup/lifecycle hardening (early-SIGTERM, port-drift pre…
Jul 8, 2026
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
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ build: sync-fingerprint
@go build $(LDFLAGS) -o bin/aikey-proxy ./cmd/aikey-proxy
@cp $(CONFIG) bin/$(CONFIG)

# ./cmd/aikey-proxy added 2026-07-08: the egress system-proxy-switch
# integration tests (egress_integration_test.go) live in package main because
# they drive the REAL buildTransport — internal/... alone would skip them.
test:
go test -race -v ./internal/...
go test -race -v ./internal/... ./cmd/aikey-proxy/

# Chaos experiments (缺口7/8) — build-tagged so they stay OUT of the normal
# `test` suite. They drive the real newStreamDrainer / http.Server code paths
Expand Down
7 changes: 7 additions & 0 deletions aikey-proxy.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ listen:
vault:
path: "~/.aikey/data/vault.db"

# Local AiKey console base (aikey-local-server web, 8090 on a default personal
# install). Used to assemble the member-login URL shown inside claude/codex
# when a shared pool account requires sign-in
# (OAUTH_GROUP_MEMBER_LOGIN_REQUIRED → <console>/user/team-oauth).
# Empty ⇒ the error message falls back to URL-less wording.
console_url: "http://127.0.0.1:8090"

# Virtual keys are delivered via vault (managed_virtual_keys_cache or
# personal_route_token columns). Add keys with `aikey add <alias>
# --provider <name>`, then `aikey list` / `aikey route` for the bearer
Expand Down
261 changes: 261 additions & 0 deletions cmd/aikey-proxy/egress_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
package main

// Egress integration test for the 2026-07-08 system-proxy auto-refresh
// requirement: a RUNNING transport must egress through the NEW system proxy
// on the very next request after a change — no daemon restart.
//
// WHY this test exists (the gap unit tests can't cover): sysproxy unit tests
// prove the resolver returns the right URL; this test proves the REAL
// buildTransport + Watcher chain — the exact objects production wires in
// main() — actually routes live HTTP traffic through the switched proxy.
//
// Hermetic by construction: both "system proxies" are local httptest servers
// acting as plain-HTTP forward proxies (they receive absolute-form request
// URIs and answer themselves — no DNS, no real egress, no Anthropic). The
// destination host is a fake non-loopback name because ProxyFunc deliberately
// sends loopback destinations direct.

import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"

"github.com/AiKeyLabs/aikey-proxy/internal/sysproxy"
)

// fakeForwardProxy is a recording plain-HTTP forward proxy: it asserts it was
// reached AS a proxy (absolute-form URI) and answers with its own tag.
func fakeForwardProxy(t *testing.T, tag string) (*httptest.Server, *atomic.Int64) {
t.Helper()
var hits atomic.Int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.RequestURI, "http://") {
t.Errorf("%s: expected absolute-form proxy request, got %q", tag, r.RequestURI)
}
hits.Add(1)
_, _ = io.WriteString(w, tag)
}))
t.Cleanup(srv.Close)
return srv, &hits
}

// clearProxyEnvMain pins the proxy env empty so assertions are hermetic
// against the runner's shell (dev Macs export https_proxy; since the
// 2026-07-08 refinement an empty snapshot falls through to inherited env).
func clearProxyEnvMain(t *testing.T) {
t.Helper()
for _, k := range []string{
"HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy",
"ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy", "AIKEY_PROXYENV_KEYS",
} {
t.Setenv(k, "")
}
}

func TestEgress_FollowsSystemProxySwitch_NoRestart(t *testing.T) {
clearProxyEnvMain(t)
proxyA, hitsA := fakeForwardProxy(t, "via-A")
proxyB, hitsB := fakeForwardProxy(t, "via-B")

var mu sync.Mutex
cur := sysproxy.Snapshot{HTTP: proxyA.URL, HTTPS: proxyA.URL}
setSystemProxy := func(s sysproxy.Snapshot) { mu.Lock(); cur = s; mu.Unlock() }
watcher := sysproxy.NewWatcherWithReader(func() (sysproxy.Snapshot, error) {
mu.Lock()
defer mu.Unlock()
return cur, nil
})

// The REAL production transport for direct mode (upstream_proxy.url empty).
transport := buildTransport("", watcher.ProxyFunc())
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}

egress := func() string {
t.Helper()
resp, err := client.Get("http://provider.test/v1/ping")
if err != nil {
t.Fatalf("egress request failed: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return string(body)
}

// 1. Primed at construction: request #1 already goes through system proxy A.
if got := egress(); got != "via-A" {
t.Fatalf("initial egress: want via-A, got %q", got)
}

// 2. "Clash 换端口": system proxy flips A→B; after ONE poll the very next
// request must egress through B — the core acceptance of the requirement.
setSystemProxy(sysproxy.Snapshot{HTTP: proxyB.URL, HTTPS: proxyB.URL})
if !watcher.PollOnce() {
t.Fatal("watcher must observe the A→B switch")
}
if got := egress(); got != "via-B" {
t.Fatalf("post-switch egress: want via-B, got %q", got)
}
if hitsA.Load() != 1 || hitsB.Load() != 1 {
t.Fatalf("want exactly 1 hit per proxy (A then B), got A=%d B=%d", hitsA.Load(), hitsB.Load())
}

// 3. "系统代理关闭": toggle off refreshes to direct — the resolver must
// return nil (we assert the resolver, not a live dial: provider.test has
// no DNS on purpose).
setSystemProxy(sysproxy.Snapshot{})
if !watcher.PollOnce() {
t.Fatal("watcher must observe the toggle-off")
}
req, _ := http.NewRequest(http.MethodGet, "http://provider.test/v1/ping", nil)
if u, err := transport.Proxy(req); err != nil || u != nil {
t.Fatalf("after toggle-off egress must be direct, got proxy=%v err=%v", u, err)
}
}

// Explicit upstream_proxy.url must OUTRANK the system proxy (approved
// precedence): with the system proxy pointing at A, an explicit URL of B must
// carry the traffic.
func TestEgress_ExplicitURLOutranksSystemProxy(t *testing.T) {
proxyA, hitsA := fakeForwardProxy(t, "via-A")
proxyB, _ := fakeForwardProxy(t, "via-B")

watcher := sysproxy.NewWatcherWithReader(func() (sysproxy.Snapshot, error) {
return sysproxy.Snapshot{HTTP: proxyA.URL, HTTPS: proxyA.URL}, nil
})
transport := buildTransport(proxyB.URL, watcher.ProxyFunc())
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}

resp, err := client.Get("http://provider.test/v1/ping")
if err != nil {
t.Fatalf("egress request failed: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if string(body) != "via-B" {
t.Fatalf("explicit URL must win: want via-B, got %q", body)
}
if hitsA.Load() != 0 {
t.Fatalf("system proxy A must not be touched when explicit URL is set, got %d hits", hitsA.Load())
}
}

// Fence for egressState (the /admin/upstream-proxy "egress" block that
// `aikey env` renders): each layer must be reported, and the effective value
// must match what the transport's resolver would do.
func TestEgressState_LayeredReporting(t *testing.T) {
clearProxyEnvMain(t)
sys := sysproxy.Snapshot{HTTP: "http://127.0.0.1:7890", HTTPS: "http://127.0.0.1:7890", SOCKS: "socks5://127.0.0.1:7891"}
watcher := sysproxy.NewWatcherWithReader(func() (sysproxy.Snapshot, error) { return sys, nil })

// No explicit URL → system proxy wins and all layer fields are visible.
st := egressState("", watcher)
if st.EffectiveSource != "system" || st.EffectiveURL != "http://127.0.0.1:7890" {
t.Fatalf("want system/http://127.0.0.1:7890, got %s/%s", st.EffectiveSource, st.EffectiveURL)
}
if !st.SystemSupported || st.SystemHTTPS != sys.HTTPS || st.SystemSOCKS != sys.SOCKS {
t.Fatalf("system layer misreported: %+v", st)
}

// Explicit URL outranks the same system snapshot.
st = egressState("socks5://10.0.0.1:1080", watcher)
if st.EffectiveSource != "explicit" || st.EffectiveURL != "socks5://10.0.0.1:1080" {
t.Fatalf("want explicit win, got %s/%s", st.EffectiveSource, st.EffectiveURL)
}
if st.SystemHTTPS != sys.HTTPS {
t.Fatal("lower layers must stay visible even when outranked (逐级显示)")
}

// System proxy toggled off → direct.
empty := sysproxy.NewWatcherWithReader(func() (sysproxy.Snapshot, error) { return sysproxy.Snapshot{}, nil })
st = egressState("", empty)
if st.EffectiveSource != "direct" || st.EffectiveURL != "" {
t.Fatalf("want direct, got %s/%s", st.EffectiveSource, st.EffectiveURL)
}
}

// Explicit (proxy.env-marked) env: flagged authoritative, listed in the
// explicit map with credentials redacted, and effective through the real
// resolver. Uses the REAL NewWatcher (explicit env keeps it inert, so no OS
// read happens even on macOS).
func TestEgressState_ExplicitEnvReported(t *testing.T) {
clearProxyEnvMain(t)
t.Setenv("HTTPS_PROXY", "http://user:secret@127.0.0.1:7899")
t.Setenv("AIKEY_PROXYENV_KEYS", "HTTPS_PROXY")
watcher := sysproxy.NewWatcher()
st := egressState("", watcher)
if !st.EnvAuthoritative {
t.Fatal("proxy.env-marked HTTPS_PROXY must be authoritative")
}
got, ok := st.EnvVars["HTTPS_PROXY"]
if !ok {
t.Fatalf("explicit vars must list HTTPS_PROXY, got %v", st.EnvVars)
}
if strings.Contains(got, "secret") {
t.Fatalf("credentials must be redacted, got %q", got)
}
if st.EffectiveSource != "env" {
t.Fatalf("effective source must be env, got %s", st.EffectiveSource)
}
if strings.Contains(st.EffectiveURL, "secret") {
t.Fatalf("effective URL must be redacted, got %q", st.EffectiveURL)
}
}

// Inherited (unmarked) env: NOT authoritative, listed as layer 4, and only
// effective when the system snapshot is empty (2026-07-08 refinement — the
// field case from the user's Mac .zshrc and the Windows HKCU\Environment).
func TestEgressState_InheritedEnvDemotedBelowSystem(t *testing.T) {
clearProxyEnvMain(t)
t.Setenv("https_proxy", "http://127.0.0.1:7890") // no marker → inherited

// System proxy present → it wins; inherited env is visible but outranked.
withSys := sysproxy.NewWatcherWithReader(func() (sysproxy.Snapshot, error) {
return sysproxy.Snapshot{HTTPS: "http://127.0.0.1:9999"}, nil
})
st := egressState("", withSys)
if st.EnvAuthoritative {
t.Fatal("inherited env must not be authoritative")
}
if st.EnvInheritedVars["https_proxy"] == "" {
t.Fatalf("inherited vars must list https_proxy, got %v", st.EnvInheritedVars)
}
if st.EffectiveSource != "system" || st.EffectiveURL != "http://127.0.0.1:9999" {
t.Fatalf("system must outrank inherited env, got %s/%s", st.EffectiveSource, st.EffectiveURL)
}

// No system proxy → inherited env is the fallback.
noSys := sysproxy.NewWatcherWithReader(func() (sysproxy.Snapshot, error) {
return sysproxy.Snapshot{}, nil
})
st = egressState("", noSys)
if st.EffectiveSource != "env_inherited" || st.EffectiveURL != "http://127.0.0.1:7890" {
t.Fatalf("want env_inherited fallback, got %s/%s", st.EffectiveSource, st.EffectiveURL)
}
}

// Transport-level layer-4 proof: with no system proxy, live traffic egresses
// through the INHERITED env proxy (old headless/manual behavior preserved).
func TestEgress_InheritedEnvFallbackCarriesTraffic(t *testing.T) {
clearProxyEnvMain(t)
proxyC, hitsC := fakeForwardProxy(t, "via-C")
t.Setenv("http_proxy", proxyC.URL) // inherited: no marker
watcher := sysproxy.NewWatcherWithReader(func() (sysproxy.Snapshot, error) {
return sysproxy.Snapshot{}, nil
})
transport := buildTransport("", watcher.ProxyFunc())
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}
resp, err := client.Get("http://provider.test/v1/ping")
if err != nil {
t.Fatalf("egress request failed: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if string(body) != "via-C" || hitsC.Load() != 1 {
t.Fatalf("want inherited-env egress via-C (1 hit), got %q hits=%d", body, hitsC.Load())
}
}
Loading
Loading