返回 DeepSeek-Reasonix
fake_test.go
根目录 / internal / extension / sidecar / fake_test.go
1 package sidecar
2
3 import (
4 "bufio"
5 "context"
6 "crypto/sha256"
7 "encoding/base64"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "io"
13 "os"
14 "strings"
15 "testing"
16 "time"
17
18 "reasonix/internal/extension/protocol"
19 "reasonix/internal/extension/rpcwire"
20 "reasonix/internal/pluginpkg"
21 )
22
23 // Re-exec fake sidecar (the standard Go helper-process pattern): the test
24 // binary re-executes itself with REASONIX_FAKE_SIDECAR=1 and speaks the real
25 // Extension Protocol v1 over stdin/stdout. Behavior is steered through env:
26 //
27 // REASONIX_FAKE_SIDECAR=1 enable the helper
28 // REASONIX_FAKE_INIT_RESULT raw JSON InitializeResult to answer with
29 // REASONIX_FAKE_MODE comma-separated behavior flags:
30 // early_request | early_notify |
31 // ignore_shutdown | stall_intercept |
32 // block_intercept | stderr_flood |
33 // content_roundtrip | content_echo_ref |
34 // provider_stream | crash_after_init |
35 // wedge_after_init
36 const (
37 fakeEnvEnable = "REASONIX_FAKE_SIDECAR"
38 fakeEnvInitResult = "REASONIX_FAKE_INIT_RESULT"
39 fakeEnvMode = "REASONIX_FAKE_MODE"
40 )
41
42 // TestFakeSidecarHelperProcess is the re-exec entry point. It skips in the
43 // parent test run and only acts as the sidecar when the env marker is set.
44 func TestFakeSidecarHelperProcess(t *testing.T) {
45 if os.Getenv(fakeEnvEnable) != "1" {
46 t.Skip("fake sidecar helper process")
47 }
48 runFakeSidecar(os.Stdin, os.Stdout)
49 os.Exit(0)
50 }
51
52 type fakeFrame struct {
53 ID json.RawMessage `json:"id"`
54 Method string `json:"method"`
55 Params json.RawMessage `json:"params"`
56 Result json.RawMessage `json:"result"`
57 }
58
59 // fakeExternalizedField mirrors the wire envelope descriptor the fake parses
60 // from intercept params (and quotes back in content_echo_ref mode).
61 type fakeExternalizedField struct {
62 JSONPointer string `json:"jsonPointer"`
63 ContentRef string `json:"contentRef"`
64 TotalBytes int64 `json:"totalBytes"`
65 SHA256 string `json:"sha256"`
66 }
67
68 func fakeModes() map[string]bool {
69 out := map[string]bool{}
70 for _, mode := range strings.Split(os.Getenv(fakeEnvMode), ",") {
71 if mode = strings.TrimSpace(mode); mode != "" {
72 out[mode] = true
73 }
74 }
75 return out
76 }
77
78 func fakeInitResult() json.RawMessage {
79 if raw := strings.TrimSpace(os.Getenv(fakeEnvInitResult)); raw != "" {
80 return json.RawMessage(raw)
81 }
82 return json.RawMessage(`{"protocolVersion":"1","name":"fake-sidecar","version":"1.0.0","stateSchemaVersion":0}`)
83 }
84
85 func runFakeSidecar(stdin io.Reader, stdout io.Writer) {
86 modes := fakeModes()
87 out := bufio.NewWriter(stdout)
88 write := func(format string, args ...any) {
89 fmt.Fprintf(out, format+"\n", args...)
90 _ = out.Flush()
91 }
92 if modes["stderr_flood"] {
93 // Push well past the 16 KiB tail so only the end survives, then leave
94 // a credential-looking line at the very end — it must be retained by
95 // the ring and redacted before surfacing.
96 for i := 0; i < 32*1024; i++ {
97 fmt.Fprintf(os.Stderr, "flood line %d padding padding padding\n", i)
98 }
99 fmt.Fprintln(os.Stderr, "boot failed: api_key=sk-abcdef1234567890SECRETKEY is invalid")
100 }
101
102 respond := func(id json.RawMessage, result json.RawMessage) {
103 write(`{"jsonrpc":"2.0","id":%s,"result":%s}`, string(id), string(result))
104 }
105
106 // readContentRef pages one host-side content ref through host/content/read
107 // the way a real extension would, answering any interleaved host requests
108 // with the default ruling while it waits for each chunk.
109 readSeq := 88000
110 readContentRef := func(in *bufio.Reader, ref string) []byte {
111 var out []byte
112 var offset int64
113 for {
114 readSeq++
115 id := readSeq
116 params, _ := json.Marshal(map[string]any{"contentRef": ref, "offset": offset})
117 write(`{"jsonrpc":"2.0","id":%d,"method":"host/content/read","params":%s}`, id, string(params))
118 for {
119 line, err := in.ReadBytes('\n')
120 if err != nil {
121 return out
122 }
123 if len(line) == 0 {
124 continue
125 }
126 var frame fakeFrame
127 if json.Unmarshal(line, &frame) != nil {
128 continue
129 }
130 if frame.Method != "" {
131 // Interleaved host request while the read pages: answer with
132 // the defaults so the host never wedges waiting on us.
133 switch frame.Method {
134 case string(protocol.MethodExtensionShutdown):
135 respond(frame.ID, json.RawMessage(`{"accepted":true}`))
136 case string(protocol.MethodExtensionIntercept):
137 respond(frame.ID, json.RawMessage(`{"decision":"continue"}`))
138 default:
139 respond(frame.ID, json.RawMessage(`{}`))
140 }
141 continue
142 }
143 if string(frame.ID) != fmt.Sprintf("%d", id) {
144 continue
145 }
146 var chunk struct {
147 DataBase64 string `json:"dataBase64"`
148 NextOffset *int64 `json:"nextOffset"`
149 }
150 if json.Unmarshal(frame.Result, &chunk) != nil {
151 return out
152 }
153 data, err := base64.StdEncoding.DecodeString(chunk.DataBase64)
154 if err != nil {
155 return out
156 }
157 out = append(out, data...)
158 if chunk.NextOffset == nil {
159 return out
160 }
161 offset = *chunk.NextOffset
162 break
163 }
164 }
165 }
166
167 in := bufio.NewReader(stdin)
168 for {
169 line, err := in.ReadBytes('\n')
170 if len(line) > 0 {
171 var frame fakeFrame
172 if json.Unmarshal(line, &frame) == nil && frame.Method != "" {
173 switch frame.Method {
174 case string(protocol.MethodExtensionInitialize):
175 if modes["early_request"] {
176 write(`{"jsonrpc":"2.0","id":77001,"method":"host/content/read","params":{"contentRef":"content_nope","offset":0}}`)
177 }
178 if modes["early_notify"] {
179 write(`{"jsonrpc":"2.0","method":"extension/provider/stream/chunk","params":{"streamId":"s1","seq":1,"chunk":{"type":"text","text":"hi"}}}`)
180 }
181 respond(frame.ID, fakeInitResult())
182 case string(protocol.MethodExtensionInitialized):
183 // notification; nothing to do — except in crash_after_init
184 // mode, where exiting here ends the connection while the
185 // host sees a ready handshake: an unexpected EOF (crash).
186 if modes["crash_after_init"] {
187 return
188 }
189 if modes["wedge_after_init"] {
190 // Stay alive but never read stdin again: the host's
191 // writes fill the pipe and must hit the write-stall
192 // bound, not hang. The sleep keeps a timer pending so
193 // the runtime's deadlock detector leaves us alive.
194 for {
195 time.Sleep(time.Hour)
196 }
197 }
198 case string(protocol.MethodExtensionIntercept):
199 switch {
200 case modes["stall_intercept"]:
201 fmt.Fprintln(os.Stderr, "intercept-stalled")
202 // never answer: the host-side timeout or a crash ends it
203 case modes["block_intercept"]:
204 respond(frame.ID, json.RawMessage(`{"decision":"block","reason":"fake block"}`))
205 case modes["content_roundtrip"] || modes["content_echo_ref"]:
206 var params struct {
207 Payload json.RawMessage `json:"payload"`
208 Externalized []fakeExternalizedField `json:"externalized"`
209 }
210 _ = json.Unmarshal(frame.Params, &params)
211 var payload []byte
212 if len(params.Externalized) > 0 {
213 payload = readContentRef(in, params.Externalized[0].ContentRef)
214 } else {
215 payload = append([]byte(nil), params.Payload...)
216 }
217 if modes["content_echo_ref"] && len(params.Externalized) > 0 {
218 // Hand the same host-held content back as the ruling:
219 // the replacement stays a ref the host must resolve.
220 descriptor := params.Externalized[0]
221 descriptor.JSONPointer = "/replacement"
222 raw, _ := json.Marshal(descriptor)
223 respond(frame.ID, json.RawMessage(fmt.Sprintf(`{"decision":"replace","replacement":null,"externalized":[%s]}`, string(raw))))
224 break
225 }
226 // Prove the read: the replacement text carries the
227 // reassembled payload's digest, padded past the 64 KiB
228 // threshold so it exercises a large inline replacement.
229 sum := sha256.Sum256(payload)
230 text := fmt.Sprintf("read %d bytes sha256:%s ", len(payload), hex.EncodeToString(sum[:]))
231 text += strings.Repeat("y", protocol.ExternalizeFieldBytes+4096)
232 replacement, _ := json.Marshal(map[string]string{"text": text})
233 respond(frame.ID, json.RawMessage(fmt.Sprintf(`{"decision":"replace","replacement":%s}`, string(replacement))))
234 default:
235 respond(frame.ID, json.RawMessage(`{"decision":"continue"}`))
236 }
237 case string(protocol.MethodExtensionShutdown):
238 if modes["ignore_shutdown"] {
239 // Stay alive without answering; the host must kill us
240 // inside its bounded close.
241 continue
242 }
243 respond(frame.ID, json.RawMessage(`{"accepted":true}`))
244 return
245 case string(protocol.MethodExtensionProviderCatalog):
246 respond(frame.ID, json.RawMessage(`{"providers":[]}`))
247 case string(protocol.MethodExtensionProviderStreamOpen):
248 respond(frame.ID, json.RawMessage(`{"accepted":true}`))
249 if modes["provider_stream"] {
250 var params struct {
251 StreamID string `json:"streamId"`
252 }
253 _ = json.Unmarshal(frame.Params, &params)
254 write(`{"jsonrpc":"2.0","method":"extension/provider/stream/chunk","params":{"streamId":%q,"seq":1,"chunk":{"type":"text","text":"wired"}}}`, params.StreamID)
255 write(`{"jsonrpc":"2.0","method":"extension/provider/stream/end","params":{"streamId":%q,"lastSeq":1}}`, params.StreamID)
256 }
257 case string(protocol.MethodExtensionProviderStreamCancel):
258 respond(frame.ID, json.RawMessage(`{"cancelled":true}`))
259 case string(protocol.MethodExtensionUIAction):
260 // Echo the invoked action id so the host-side test proves
261 // the routing; the message carries a credential-looking
262 // token to exercise hub redaction end to end.
263 var params struct {
264 ActionID string `json:"actionId"`
265 }
266 _ = json.Unmarshal(frame.Params, &params)
267 message, _ := json.Marshal("ran " + params.ActionID + " with api_key=sk-abcdef1234567890SECRETKEY")
268 respond(frame.ID, json.RawMessage(`{"accepted":true,"message":`+string(message)+`}`))
269 case string(protocol.MethodExtensionUISubmit):
270 respond(frame.ID, json.RawMessage(`{"accepted":true}`))
271 default:
272 respond(frame.ID, json.RawMessage(`{}`))
273 }
274 }
275 }
276 if err != nil {
277 return
278 }
279 }
280 }
281
282 // fakeSidecarRuntime builds the exec-form runtime spec pointing at the
283 // re-executed test binary.
284 func fakeSidecarRuntime(t testing.TB, configure func(rt *pluginpkg.RuntimeSpec)) *pluginpkg.RuntimeSpec {
285 t.Helper()
286 exe, err := os.Executable()
287 if err != nil {
288 t.Fatalf("os.Executable: %v", err)
289 }
290 rt := &pluginpkg.RuntimeSpec{
291 Command: exe,
292 Args: []string{"-test.run=^TestFakeSidecarHelperProcess$"},
293 Env: map[string]string{fakeEnvEnable: "1"},
294 }
295 if configure != nil {
296 configure(rt)
297 }
298 return rt
299 }
300
301 // fakeSidecarPackage builds the installed-state entry + package for one fake
302 // sidecar. The package root is an empty temp dir: the runtime command is the
303 // test binary itself, so nothing needs to exist on disk.
304 func fakeSidecarPackage(t testing.TB, name string, configure func(rt *pluginpkg.RuntimeSpec)) (pluginpkg.Package, pluginpkg.InstalledPlugin) {
305 t.Helper()
306 rt := fakeSidecarRuntime(t, configure)
307 pkg := pluginpkg.Package{
308 Root: t.TempDir(),
309 ManifestKind: "reasonix",
310 Manifest: pluginpkg.Manifest{
311 Name: name,
312 Version: "1.0.0",
313 Runtime: rt,
314 },
315 }
316 installed := pluginpkg.InstalledPlugin{Name: name, Version: "1.0.0", Enabled: true, Root: pkg.Root}
317 return pkg, installed
318 }
319
320 func testSessionContext() protocol.SessionContext {
321 return protocol.SessionContext{SessionID: "sess-test", WorkspaceRoot: "/ws", Generation: 1}
322 }
323
324 // startFakeClient starts a fake sidecar client and registers its bounded
325 // shutdown.
326 func startFakeClient(t testing.TB, configure func(rt *pluginpkg.RuntimeSpec), opts func(*ClientOptions)) *Client {
327 t.Helper()
328 pkg, installed := fakeSidecarPackage(t, "fakeplugin", configure)
329 clientOpts := ClientOptions{Package: pkg, Installed: installed, Session: testSessionContext()}
330 if opts != nil {
331 opts(&clientOpts)
332 }
333 client, err := StartClient(context.Background(), clientOpts)
334 if err != nil {
335 t.Fatalf("StartClient: %v", err)
336 }
337 t.Cleanup(func() { _ = client.Close() })
338 return client
339 }
340
341 // waitFor polls cond until it holds or the deadline expires.
342 func waitFor(t *testing.T, what string, timeout time.Duration, cond func() bool) {
343 t.Helper()
344 deadline := time.Now().Add(timeout)
345 for time.Now().Before(deadline) {
346 if cond() {
347 return
348 }
349 time.Sleep(10 * time.Millisecond)
350 }
351 t.Fatalf("timed out waiting for %s", what)
352 }
353
354 // protocolReason extracts the frozen protocol error reason from err, whether
355 // it travels as a local *protocol.ProtocolError or as the wire-shaped
356 // *rpcwire.RPCError handlers return.
357 func protocolReason(t *testing.T, err error) protocol.ErrorReason {
358 t.Helper()
359 var protocolErr *protocol.ProtocolError
360 if errors.As(err, &protocolErr) {
361 return protocolErr.Reason
362 }
363 var rpcErr *rpcwire.RPCError
364 if errors.As(err, &rpcErr) {
365 var data protocol.ProtocolErrorData
366 raw, _ := json.Marshal(rpcErr.Data)
367 if json.Unmarshal(raw, &data) == nil && data.Reason != "" {
368 return data.Reason
369 }
370 }
371 t.Fatalf("error %v carries no protocol reason", err)
372 return ""
373 }
374
374 lines GO