| 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, ¶ms) |
| 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, ¶ms) |
| 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, ¶ms) |
| 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 |