Skip to content
Merged
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
16 changes: 8 additions & 8 deletions cmd_receipt.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func handleReceiptCommand(args []string) {
receiptVerify(argAt(args, 1))
default:
fmt.Fprintln(os.Stderr, "Usage: qmax-code receipt <list|show|verify> [id|latest]")
os.Exit(1)
exitWithReceipt(1)
}
}

Expand All @@ -44,7 +44,7 @@ func receiptList() {
paths, err := receipt.List()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
exitWithReceipt(1)
}
if len(paths) == 0 {
fmt.Println("No exposure receipts found.")
Expand All @@ -65,7 +65,7 @@ func receiptShow(idOrLatest string) {
data, err := json.MarshalIndent(r, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: cannot marshal receipt: %v\n", err)
os.Exit(1)
exitWithReceipt(1)
}
fmt.Println(string(data))
}
Expand All @@ -74,7 +74,7 @@ func receiptVerify(idOrLatest string) {
r := mustResolveReceipt(idOrLatest)
if err := receipt.Verify(r); err != nil {
fmt.Fprintf(os.Stderr, "✗ INVALID: %v\n", err)
os.Exit(1)
exitWithReceipt(1)
}
fmt.Printf("✓ VALID — run %s (%s), %d request(s) across %v\n",
r.RunID, r.RunKind, r.Summary.TotalRequests, r.Destinations)
Expand All @@ -86,25 +86,25 @@ func mustResolveReceipt(idOrLatest string) *receipt.Receipt {
dir, err := receipt.ReceiptsDir()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
exitWithReceipt(1)
}
if idOrLatest == "" || idOrLatest == "latest" {
paths, err := receipt.List()
if err != nil || len(paths) == 0 {
fmt.Fprintln(os.Stderr, "No exposure receipts found.")
os.Exit(1)
exitWithReceipt(1)
}
r, err := receipt.Load(paths[len(paths)-1])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
exitWithReceipt(1)
}
return r
}
r, err := receipt.Load(filepath.Join(dir, idOrLatest+".json"))
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
exitWithReceipt(1)
}
return r
}
12 changes: 6 additions & 6 deletions config_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,37 +44,37 @@ func handleConfigCommand(args []string) {
case "set":
if len(args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: qmax-code config set KEY VALUE")
os.Exit(2)
exitWithReceipt(2)
}
if err := setConfigField(args[1], args[2]); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
exitWithReceipt(1)
}
fmt.Printf(" Set %s = %s\n", args[1], configSetDisplayValue(args[1], args[2]))

case "unset":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "Usage: qmax-code config unset KEY")
os.Exit(2)
exitWithReceipt(2)
}
if err := setConfigField(args[1], ""); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
exitWithReceipt(1)
}
fmt.Printf(" Cleared %s\n", args[1])

case "reset":
cfg := api.DefaultConfig()
if err := cfg.Save(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
exitWithReceipt(1)
}
fmt.Println(" api.Config reset to defaults")

default:
fmt.Fprintf(os.Stderr, "Unknown config command: %s\n", args[0])
fmt.Fprintln(os.Stderr, "Try: qmax-code config [show|set|unset|reset]")
os.Exit(2)
exitWithReceipt(2)
}
}

Expand Down
31 changes: 31 additions & 0 deletions exit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package main

import (
"os"
"sync"
)

// exitWithReceipt is the only process-exit path used after main has installed
// the session receipt. os.Exit skips deferred functions, so finalize must run
// explicitly for command failures that occur after an outbound request.
var (
exitMu sync.RWMutex
finalizeBeforeExit func()
processExit = os.Exit
)

func configureExitFinalizer(finalize func()) {
Comment thread
Desperado marked this conversation as resolved.
exitMu.Lock()
finalizeBeforeExit = finalize
exitMu.Unlock()
}

func exitWithReceipt(code int) {
exitMu.RLock()
finalize := finalizeBeforeExit
exitMu.RUnlock()
if finalize != nil {
finalize()
}
processExit(code)
}
74 changes: 74 additions & 0 deletions exit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package main

import (
"sync"
"sync/atomic"
"testing"
)

func TestExitWithReceiptFinalizesBeforeProcessExit(t *testing.T) {
exitMu.Lock()
oldFinalize := finalizeBeforeExit
oldProcessExit := processExit
exitMu.Unlock()
t.Cleanup(func() {
exitMu.Lock()
finalizeBeforeExit = oldFinalize
processExit = oldProcessExit
exitMu.Unlock()
})

var order []string
configureExitFinalizer(func() { order = append(order, "finalize") })
processExit = func(code int) {
if code != 7 {
t.Errorf("exit code = %d, want 7", code)
}
order = append(order, "exit")
}

exitWithReceipt(7)
if len(order) != 2 || order[0] != "finalize" || order[1] != "exit" {
t.Fatalf("order = %v, want [finalize exit]", order)
}
}

func TestExitWithReceiptConcurrentFinalizerReconfiguration(t *testing.T) {
exitMu.Lock()
oldFinalize := finalizeBeforeExit
oldProcessExit := processExit
processExit = func(int) {}
exitMu.Unlock()
t.Cleanup(func() {
exitMu.Lock()
finalizeBeforeExit = oldFinalize
processExit = oldProcessExit
exitMu.Unlock()
})

var finalizations atomic.Int32
configureExitFinalizer(func() { finalizations.Add(1) })

const calls = 100
start := make(chan struct{})
var wg sync.WaitGroup
for range calls {
wg.Add(2)
go func() {
defer wg.Done()
<-start
configureExitFinalizer(func() { finalizations.Add(1) })
}()
go func() {
defer wg.Done()
<-start
exitWithReceipt(0)
}()
}
close(start)
wg.Wait()

if got := finalizations.Load(); got != calls {
t.Errorf("finalizations = %d, want %d", got, calls)
}
}
6 changes: 5 additions & 1 deletion internal/agent/opencode_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ func userOpenCodeConfigDir() string {
// literal is a plaintext key sitting on disk.
var literalAPIKeyRe = regexp.MustCompile(`"apiKey"\s*:\s*"([^"]*)"`)

// loadProviderKey is replaceable in tests so provider-env construction does
// not depend on a developer's real OS keychain entries.
var loadProviderKey = api.LoadProviderKey

// PlaintextKeyInUserOpenCodeConfig reports whether the user's OWN opencode
// config files contain a plaintext apiKey (not an {env:}/{file:} reference),
// and the path where one was found. Used to warn a user — when they move a
Expand Down Expand Up @@ -211,7 +215,7 @@ func WriteOpenCodeConfig(cfg *api.Config, sctx *api.SessionContext, permissionMo
func OpenCodeProviderEnv(cfg *api.Config) map[string]string {
env := map[string]string{}
for _, p := range cfg.ActiveProviders() {
key := api.LoadProviderKey(p.ID)
key := loadProviderKey(p.ID)
if key == "" {
continue
}
Expand Down
22 changes: 16 additions & 6 deletions internal/agent/opencode_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,25 @@ func TestOpenCodeModelsPassesProviderEnv(t *testing.T) {
}

func TestOpenCodeProviderEnvInjectsKeys(t *testing.T) {
t.Setenv("QMAX_PC_ZAI_CODING_PLAN", "zai-secret")
t.Setenv("GROQ_API_KEY", "gsk_secret")
oldLoadProviderKey := loadProviderKey
t.Cleanup(func() { loadProviderKey = oldLoadProviderKey })
loadProviderKey = func(providerID string) string {
switch providerID {
case "zai-coding-plan":
return "test-zai-key"
case "groq":
return "test-groq-key"
default:
return ""
}
}
cfg := &api.Config{EnabledProviders: []string{"zai-coding-plan", "groq"}}
env := OpenCodeProviderEnv(cfg)
if env["QMAX_PC_ZAI_CODING_PLAN"] != "zai-secret" {
t.Errorf("zai env not injected: %v", env)
if env["QMAX_PC_ZAI_CODING_PLAN"] != "test-zai-key" {
t.Error("zai env not injected")
}
if env["GROQ_API_KEY"] != "gsk_secret" {
t.Errorf("groq env not injected: %v", env)
if env["GROQ_API_KEY"] != "test-groq-key" {
t.Error("groq env not injected")
}
}

Expand Down
7 changes: 7 additions & 0 deletions internal/exposure/exposure.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const (
CatLLMCompletion = "llm-completion" // reserved: response-side LLM accounting
CatCloudAPI = "cloud-api" // QualityMax cloud REST API (projects, scripts, integrations)
CatMCPTraffic = "mcp-traffic" // reserved: outbound MCP-over-HTTP egress
CatTelemetry = "telemetry" // opt-in error-reporting envelope
CatVNCControl = "vnc-control" // noVNC WebSocket handshake/control channel
CatControl = "control" // metadata only: auth check, login poll, health/reachability probes
CatUncategorized = "uncategorized" // unknown host/path — flagged so it can't hide
)
Expand Down Expand Up @@ -62,6 +64,11 @@ func Classify(host, path string) string {
strings.Contains(p, "/api/job-health/"):
return CatControl

// Sentry-compatible telemetry is opt-in. The request body is still hashed
// by httpx, but it is categorized separately from QualityMax cloud REST.
case strings.Contains(p, "/envelope"):
return CatTelemetry

// QualityMax cloud REST API — projects, scripts, crawl, integrations, etc.
case strings.Contains(p, "/api/"):
return CatCloudAPI
Expand Down
1 change: 1 addition & 0 deletions internal/exposure/exposure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func TestClassify(t *testing.T) {
{"cli poll", "app.qualitymax.io", "/api/auth/cli-poll", CatControl},
{"ollama tags probe", "localhost:11434", "/api/tags", CatControl},
{"job health", "app.qualitymax.io", "/api/job-health/background/abc", CatControl},
{"telemetry envelope", "sentry.example", "/api/42/envelope/", CatTelemetry},

{"projects list", "app.qualitymax.io", "/api/projects", CatCloudAPI},
{"script fetch", "app.qualitymax.io", "/api/automation/scripts/1234", CatCloudAPI},
Expand Down
Loading
Loading