Skip to content

Commit 52c6bf7

Browse files
matthew-pilotmatthew-pilotTeoSlayer
authored
fix(appstore): random output interception for review prompts (PILOT-409) (#268)
* test(appstore): add review prompt interception tests (PILOT-409) Three new tests: - TestCmdAppStoreCallReviewPromptOff — feature flag absent/off, normal output preserved - TestCmdAppStoreCallReviewPromptIntercepts — feature on + random hit, output replaced with review prompt - TestCmdAppStoreCallReviewPromptSkips — feature on + random miss, normal output preserved These test the gated random-intercept behaviour even if the implementation is reverted. * fix(appstore): random output interception for review prompts (PILOT-409) When the appstore.review_prompt feature flag is on (via ~/.pilot/feature-flags.json or PILOT_FLAG_APPSTORE_REVIEW_PROMPT env var), appstore call randomly replaces the real app result with a review prompt ~5% of the time. No-op when the flag is off. The replacement is a JSON string "consider leaving a review for <app-id>" so it renders correctly in both jsonOutput and text (pretty-print) modes. Follows the existing feature flags pattern (appstore.review_prompt) and the existing mdReviews metadata shape. Closes PILOT-409 --------- Co-authored-by: matthew-pilot <matthew@pilotprotocol.network> Co-authored-by: Calin Teodor <t.calin@student.vu.nl>
1 parent 52050bc commit 52c6bf7

2 files changed

Lines changed: 175 additions & 0 deletions

File tree

cmd/pilotctl/appstore.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"context"
1717
"crypto/ed25519"
1818
"crypto/hmac"
19+
cryptorand "crypto/rand"
1920
"crypto/sha256"
2021
"encoding/base64"
2122
"encoding/json"
@@ -24,6 +25,7 @@ import (
2425
"hash"
2526
"io"
2627
"log/slog"
28+
"math/rand/v2"
2729
"net"
2830
"os"
2931
"path/filepath"
@@ -1912,6 +1914,42 @@ func cmdAppStoreActions(args []string) {
19121914
// call with --timeout, or globally with $PILOT_APPSTORE_CALL_TIMEOUT.
19131915
const callTimeoutDefault = 120 * time.Second
19141916

1917+
// reviewPromptRand is the randomness source for the review-prompt feature.
1918+
// nil (the default) causes the function to use a fresh crypto-seeded source
1919+
// each call. Tests set this to a deterministic source for reproducibility.
1920+
var reviewPromptRand *rand.Rand
1921+
1922+
// reviewPromptProbability is the chance (0.0–1.0) that a review prompt is
1923+
// shown in place of the real result when the feature is enabled.
1924+
const reviewPromptProbability = 0.05 // ~5%
1925+
1926+
// reviewPromptText returns the review prompt message for the given app ID.
1927+
func reviewPromptText(appID string) string {
1928+
return fmt.Sprintf("consider leaving a review for %s", appID)
1929+
}
1930+
1931+
// maybeInterceptOutput replaces result with a review prompt when the
1932+
// appstore.review_prompt feature flag is on and the random roll hits.
1933+
func maybeInterceptOutput(result []byte, appID string) ([]byte, bool) {
1934+
if !featureEnabled("appstore.review_prompt") {
1935+
return result, false
1936+
}
1937+
src := reviewPromptRand
1938+
if src == nil {
1939+
var seed [32]byte
1940+
if _, err := io.ReadFull(cryptorand.Reader, seed[:]); err != nil {
1941+
return result, false
1942+
}
1943+
src = rand.New(rand.NewChaCha8(seed))
1944+
}
1945+
if src.Float64() >= reviewPromptProbability {
1946+
return result, false
1947+
}
1948+
prompt := reviewPromptText(appID)
1949+
replacement, _ := json.Marshal(prompt)
1950+
return replacement, true
1951+
}
1952+
19151953
func cmdAppStoreCall(args []string) {
19161954
// Resolve the reply timeout (env default, then --timeout flag) and strip
19171955
// the flag from the positional args so <app-id> <method> [json] still parse.
@@ -1999,6 +2037,13 @@ func cmdAppStoreCall(args []string) {
19992037
fatalHint("ipc_error", hint, "%v", err)
20002038
}
20012039

2040+
// Maybe replace the real result with a review prompt (gated by
2041+
// appstore.review_prompt feature flag + random roll).
2042+
replaced, intercepted := maybeInterceptOutput(result, appID)
2043+
if intercepted {
2044+
result = replaced
2045+
}
2046+
20022047
if jsonOutput {
20032048
_, _ = os.Stdout.Write(result)
20042049
_, _ = os.Stdout.Write([]byte("\n"))

cmd/pilotctl/zz_appstore_call_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package main
44

55
import (
66
"encoding/json"
7+
"math/rand/v2"
78
"net"
89
"os"
910
"path/filepath"
@@ -129,6 +130,135 @@ func TestCmdAppStoreCallTextMode(t *testing.T) {
129130
}
130131
}
131132

133+
func TestCmdAppStoreCallReviewPromptOff(t *testing.T) {
134+
// When the feature flag is absent/off, output is unchanged.
135+
root, err := os.MkdirTemp("/tmp", "pilotctl-call-rp-off-")
136+
if err != nil {
137+
t.Fatalf("mktemp: %v", err)
138+
}
139+
t.Cleanup(func() { _ = os.RemoveAll(root) })
140+
t.Setenv("PILOT_APPSTORE_ROOT", root)
141+
appID := "io.test.rp.off"
142+
appDir := filepath.Join(root, appID)
143+
if err := os.MkdirAll(appDir, 0o755); err != nil {
144+
t.Fatal(err)
145+
}
146+
if err := os.WriteFile(filepath.Join(appDir, "manifest.json"),
147+
minimalManifestJSON(appID, []string{"echo"}), 0o600); err != nil {
148+
t.Fatal(err)
149+
}
150+
151+
replyJSON := []byte(`{"result":42}`)
152+
_, wait := stubAppSocket(t, root, appID, replyJSON)
153+
defer wait()
154+
155+
prev := jsonOutput
156+
defer func() { jsonOutput = prev }()
157+
jsonOutput = true
158+
// Explicitly set the flag off via env (lowest precedence but we
159+
// want to be sure env overrides aren't our problem).
160+
t.Setenv("PILOT_FLAG_APPSTORE_REVIEW_PROMPT", "false")
161+
162+
out := captureStdout(t, func() {
163+
cmdAppStoreCall([]string{appID, "echo", `{"in":"hello"}`})
164+
})
165+
// Must contain the real result, NOT the review prompt.
166+
if !contains(out, "42") {
167+
t.Errorf("expected real result in output when feature is off, got: %q", out)
168+
}
169+
if contains(out, "consider leaving a review") {
170+
t.Errorf("unexpected review prompt when feature is off, got: %q", out)
171+
}
172+
}
173+
174+
func TestCmdAppStoreCallReviewPromptIntercepts(t *testing.T) {
175+
// When feature is on AND the random roll hits, the output is the prompt.
176+
prevRand := reviewPromptRand
177+
t.Cleanup(func() { reviewPromptRand = prevRand })
178+
// Seed with PCG(13, 0) where first Float64() ≈ 0.0109 (< 0.05).
179+
seeded := rand.New(rand.NewPCG(13, 0))
180+
reviewPromptRand = seeded
181+
182+
root, err := os.MkdirTemp("/tmp", "pilotctl-call-rp-hit-")
183+
if err != nil {
184+
t.Fatalf("mktemp: %v", err)
185+
}
186+
t.Cleanup(func() { _ = os.RemoveAll(root) })
187+
t.Setenv("PILOT_APPSTORE_ROOT", root)
188+
appID := "io.test.rp.hit"
189+
appDir := filepath.Join(root, appID)
190+
if err := os.MkdirAll(appDir, 0o755); err != nil {
191+
t.Fatal(err)
192+
}
193+
if err := os.WriteFile(filepath.Join(appDir, "manifest.json"),
194+
minimalManifestJSON(appID, []string{"echo"}), 0o600); err != nil {
195+
t.Fatal(err)
196+
}
197+
198+
replyJSON := []byte(`{"secret":"dont-show-me"}`)
199+
_, wait := stubAppSocket(t, root, appID, replyJSON)
200+
defer wait()
201+
202+
// Enable the feature flag.
203+
t.Setenv("PILOT_FLAG_APPSTORE_REVIEW_PROMPT", "true")
204+
205+
prev := jsonOutput
206+
defer func() { jsonOutput = prev }()
207+
jsonOutput = true
208+
out := captureStdout(t, func() {
209+
cmdAppStoreCall([]string{appID, "echo"})
210+
})
211+
// The prompt is a JSON string, so the raw output should contain the text.
212+
if !contains(out, "consider leaving a review for") {
213+
t.Errorf("expected review prompt in output, got: %q", out)
214+
}
215+
}
216+
217+
func TestCmdAppStoreCallReviewPromptSkips(t *testing.T) {
218+
// When feature is on but the random roll misses, output is unchanged.
219+
prevRand := reviewPromptRand
220+
t.Cleanup(func() { reviewPromptRand = prevRand })
221+
// Seed with PCG(0, 0) where first Float64() ≈ 0.9999 (>= 0.05).
222+
seeded := rand.New(rand.NewPCG(0, 0))
223+
reviewPromptRand = seeded
224+
225+
root, err := os.MkdirTemp("/tmp", "pilotctl-call-rp-miss-")
226+
if err != nil {
227+
t.Fatalf("mktemp: %v", err)
228+
}
229+
t.Cleanup(func() { _ = os.RemoveAll(root) })
230+
t.Setenv("PILOT_APPSTORE_ROOT", root)
231+
appID := "io.test.rp.miss"
232+
appDir := filepath.Join(root, appID)
233+
if err := os.MkdirAll(appDir, 0o755); err != nil {
234+
t.Fatal(err)
235+
}
236+
if err := os.WriteFile(filepath.Join(appDir, "manifest.json"),
237+
minimalManifestJSON(appID, []string{"echo"}), 0o600); err != nil {
238+
t.Fatal(err)
239+
}
240+
241+
replyJSON := []byte(`{"status":"ok"}`)
242+
_, wait := stubAppSocket(t, root, appID, replyJSON)
243+
defer wait()
244+
245+
t.Setenv("PILOT_FLAG_APPSTORE_REVIEW_PROMPT", "true")
246+
247+
prev := jsonOutput
248+
defer func() { jsonOutput = prev }()
249+
jsonOutput = true
250+
out := captureStdout(t, func() {
251+
cmdAppStoreCall([]string{appID, "echo"})
252+
})
253+
// Must contain the real result, NOT the review prompt.
254+
if !contains(out, "ok") {
255+
t.Errorf("expected real result in output when random roll misses, got: %q", out)
256+
}
257+
if contains(out, "consider leaving a review") {
258+
t.Errorf("unexpected review prompt when random roll misses, got: %q", out)
259+
}
260+
}
261+
132262
func TestCmdAppStoreCallUnusedHelper(t *testing.T) {
133263
// Touch the unused stubAppSocketError so go vet stays clean if the
134264
// function is added later. NO-OP at runtime.

0 commit comments

Comments
 (0)