Skip to content

Commit fe9fae9

Browse files
feat: proxy research boundaries through platform
1 parent bbd22bb commit fe9fae9

5 files changed

Lines changed: 173 additions & 4 deletions

File tree

apps/api-go/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Snapshot-backed read routes:
4848

4949
Optional live control-plane routes:
5050

51+
- `GET /api/v1/research-boundaries`
5152
- `GET /api/v1/audit/jobs`
5253
- `POST /api/v1/audit/jobs`
5354
- `GET /api/v1/audit/jobs/{job_id}`
@@ -61,4 +62,4 @@ This service is a gateway only:
6162
- it does not run research jobs directly;
6263
- it does not shell out to Python during public requests;
6364
- it serves workspace read models from the configured snapshot bundle;
64-
- it forwards only audit control-plane calls to Runtime, and only when that upstream is configured.
65+
- it forwards only audit control-plane calls and read-only research admission boundaries to Runtime, and only when that upstream is configured.

apps/api-go/internal/proxy/server.go

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ type Server struct {
4747
func NewServer(config Config) *Server {
4848
mux := http.NewServeMux()
4949
server := &Server{
50-
config: config,
51-
mux: mux,
52-
client: &http.Client{
50+
config: config,
51+
mux: mux,
52+
client: &http.Client{
5353
Timeout: config.timeout(),
5454
},
5555
cacheDir: config.PublicDataDir,
@@ -60,6 +60,7 @@ func NewServer(config Config) *Server {
6060
mux.HandleFunc("GET /api/v1/catalog", server.handleSnapshotFile("catalog.json"))
6161
mux.HandleFunc("GET /api/v1/evidence/attack-defense-table", server.handleSnapshotFile("attack-defense-table.json"))
6262
mux.HandleFunc("GET /api/v1/models", server.handleSnapshotFile("models.json"))
63+
mux.HandleFunc("GET /api/v1/research-boundaries", server.handleResearchBoundaries)
6364
mux.HandleFunc("GET /api/v1/experiments/recon/best", server.handleBestSummaryByContract)
6465
mux.HandleFunc("GET /api/v1/experiments/best", server.handleBestSummaryByContract)
6566
mux.HandleFunc("GET /api/v1/experiments/{workspace}/summary", server.handleWorkspaceSummary)
@@ -203,6 +204,62 @@ func (s *Server) handleBestSummaryByContract(writer http.ResponseWriter, request
203204
writeJSON(writer, http.StatusServiceUnavailable, map[string]any{"detail": "snapshot unavailable: best summary for contract"})
204205
}
205206

207+
func (s *Server) handleResearchBoundaries(writer http.ResponseWriter, request *http.Request) {
208+
if s.config.DemoMode || s.config.RuntimeBaseURL == "" {
209+
writeJSON(writer, http.StatusOK, researchBoundariesUnavailablePayload(s.config.DemoMode, s.config.RuntimeBaseURL != ""))
210+
return
211+
}
212+
213+
upstreamURL, err := url.JoinPath(s.config.RuntimeBaseURL, request.URL.Path)
214+
if err != nil {
215+
writeJSON(writer, http.StatusOK, researchBoundariesUnavailablePayload(false, true))
216+
return
217+
}
218+
219+
upstreamRequest, err := http.NewRequest(http.MethodGet, upstreamURL, nil)
220+
if err != nil {
221+
writeJSON(writer, http.StatusOK, researchBoundariesUnavailablePayload(false, true))
222+
return
223+
}
224+
225+
response, err := s.doWithRetry(upstreamRequest, maxRetries)
226+
if err != nil {
227+
writeJSON(writer, http.StatusOK, researchBoundariesUnavailablePayload(false, true))
228+
return
229+
}
230+
defer response.Body.Close()
231+
232+
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
233+
writeJSON(writer, http.StatusOK, researchBoundariesUnavailablePayload(false, true))
234+
return
235+
}
236+
237+
responseBody, err := io.ReadAll(response.Body)
238+
if err != nil || !json.Valid(responseBody) {
239+
writeJSON(writer, http.StatusOK, researchBoundariesUnavailablePayload(false, true))
240+
return
241+
}
242+
243+
writer.Header().Set("Content-Type", "application/json")
244+
writer.WriteHeader(http.StatusOK)
245+
_, _ = writer.Write(responseBody)
246+
}
247+
248+
func researchBoundariesUnavailablePayload(demoMode bool, runtimeConfigured bool) map[string]any {
249+
return map[string]any{
250+
"status": "unavailable",
251+
"source": "runtime",
252+
"candidate_policy": "not-exposed-as-live-jobs",
253+
"boundaries": []any{},
254+
"demo_mode": demoMode,
255+
"runtime_configured": runtimeConfigured,
256+
"source_readiness": map[string]any{
257+
"configured": runtimeConfigured,
258+
"ready": false,
259+
},
260+
}
261+
}
262+
206263
func (s *Server) handleControlGet(writer http.ResponseWriter, request *http.Request) {
207264
if s.config.DemoMode {
208265
s.handleDemoControlGet(writer, request)

apps/api-go/internal/proxy/server_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,104 @@ func TestSnapshotBackedRouteReturns503WhenSnapshotMissing(t *testing.T) {
429429
}
430430
}
431431

432+
func TestResearchBoundariesEndpointProxiesRuntimeBoundaryRegistry(t *testing.T) {
433+
upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
434+
if request.URL.Path != "/api/v1/research-boundaries" {
435+
t.Fatalf("unexpected path %s", request.URL.Path)
436+
}
437+
writeJSON(writer, http.StatusOK, map[string]any{
438+
"status": "ok",
439+
"source": "registry",
440+
"candidate_policy": "not-exposed-as-live-jobs",
441+
"boundaries": []map[string]any{
442+
{
443+
"key": "h2-output-cloud-geometry-candidate-no-runtime-job",
444+
"admission_status": "watch",
445+
},
446+
},
447+
"source_readiness": map[string]any{"ready": true},
448+
})
449+
}))
450+
defer upstream.Close()
451+
452+
server := NewServer(Config{RuntimeBaseURL: upstream.URL})
453+
request := httptest.NewRequest(http.MethodGet, "/api/v1/research-boundaries", nil)
454+
recorder := httptest.NewRecorder()
455+
456+
server.Handler().ServeHTTP(recorder, request)
457+
458+
if recorder.Code != http.StatusOK {
459+
t.Fatalf("expected 200, got %d", recorder.Code)
460+
}
461+
var payload map[string]any
462+
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
463+
t.Fatalf("decode failed: %v", err)
464+
}
465+
if payload["source"] != "registry" {
466+
t.Fatalf("expected runtime registry payload, got %v", payload)
467+
}
468+
if payload["candidate_policy"] != "not-exposed-as-live-jobs" {
469+
t.Fatalf("unexpected candidate policy %v", payload["candidate_policy"])
470+
}
471+
if payload["job_type"] != nil {
472+
t.Fatalf("research boundary payload must not expose live job_type: %v", payload)
473+
}
474+
}
475+
476+
func TestResearchBoundariesEndpointReturnsStableUnavailablePayloadWithoutRuntime(t *testing.T) {
477+
server := NewServer(Config{})
478+
request := httptest.NewRequest(http.MethodGet, "/api/v1/research-boundaries", nil)
479+
recorder := httptest.NewRecorder()
480+
481+
server.Handler().ServeHTTP(recorder, request)
482+
483+
if recorder.Code != http.StatusOK {
484+
t.Fatalf("expected 200, got %d", recorder.Code)
485+
}
486+
var payload map[string]any
487+
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
488+
t.Fatalf("decode failed: %v", err)
489+
}
490+
if payload["status"] != "unavailable" {
491+
t.Fatalf("expected unavailable status, got %v", payload["status"])
492+
}
493+
if payload["candidate_policy"] != "not-exposed-as-live-jobs" {
494+
t.Fatalf("unexpected candidate policy %v", payload["candidate_policy"])
495+
}
496+
if payload["runtime_configured"] != false {
497+
t.Fatalf("expected runtime_configured=false, got %v", payload["runtime_configured"])
498+
}
499+
}
500+
501+
func TestResearchBoundariesEndpointDoesNotExposeRuntimeErrors(t *testing.T) {
502+
server := NewServer(Config{
503+
RuntimeBaseURL: "http://192.0.2.10:8765",
504+
RuntimeTimeout: 10 * time.Millisecond,
505+
})
506+
request := httptest.NewRequest(http.MethodGet, "/api/v1/research-boundaries", nil)
507+
recorder := httptest.NewRecorder()
508+
509+
server.Handler().ServeHTTP(recorder, request)
510+
511+
if recorder.Code != http.StatusOK {
512+
t.Fatalf("expected 200, got %d", recorder.Code)
513+
}
514+
raw := recorder.Body.String()
515+
if strings.Contains(raw, "192.0.2.10") || strings.Contains(raw, "connection refused") || strings.Contains(raw, "deadline") {
516+
t.Fatalf("research boundaries leaked upstream details: %s", raw)
517+
}
518+
var payload map[string]any
519+
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
520+
t.Fatalf("decode failed: %v", err)
521+
}
522+
if payload["status"] != "unavailable" {
523+
t.Fatalf("expected unavailable status, got %v", payload["status"])
524+
}
525+
if payload["runtime_configured"] != true {
526+
t.Fatalf("expected runtime_configured=true, got %v", payload["runtime_configured"])
527+
}
528+
}
529+
432530
func TestJobsListEndpointIsProxied(t *testing.T) {
433531
upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
434532
if request.URL.Path != "/api/v1/audit/jobs" {

apps/web/src/app/api/v1/proxy-routes.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ describe("platform api proxy routes", () => {
7070
expect(proxyToBackend).toHaveBeenCalledWith("/api/v1/evidence/attack-defense-table");
7171
});
7272

73+
it("proxies research boundary requests to the backend", async () => {
74+
const route = await import("./research-boundaries/route");
75+
76+
await route.GET();
77+
78+
expect(proxyToBackend).toHaveBeenCalledWith("/api/v1/research-boundaries");
79+
});
80+
7381
it("proxies workspace summary requests to the backend", async () => {
7482
const route = await import("./experiments/[workspace]/summary/route");
7583

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { proxyToBackend } from "@/lib/api-proxy";
2+
3+
export async function GET() {
4+
return proxyToBackend("/api/v1/research-boundaries");
5+
}

0 commit comments

Comments
 (0)