Skip to content
Closed
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
26 changes: 26 additions & 0 deletions frontend/src/app/api/harness/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { NextRequest } from "next/server";
import { requireApiAccess } from "@/lib/auth/guard";
import { proxyToHarness } from "@/app/api/harness/proxy-to-harness";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
): Promise<Response> {
const denied = requireApiAccess(request);
if (denied) return denied;
const { path } = await params;
return proxyToHarness(request, path);
}

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
): Promise<Response> {
const denied = requireApiAccess(request);
if (denied) return denied;
const { path } = await params;
return proxyToHarness(request, path);
}
26 changes: 26 additions & 0 deletions frontend/src/app/api/harness/managed/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { NextRequest } from "next/server";
import { requireApiAccess } from "@/lib/auth/guard";
import { proxyToManagedHarness } from "@/app/api/harness/proxy-to-harness";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
): Promise<Response> {
const denied = requireApiAccess(request);
if (denied) return denied;
const { path } = await params;
return proxyToManagedHarness(request, path);
}

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
): Promise<Response> {
const denied = requireApiAccess(request);
if (denied) return denied;
const { path } = await params;
return proxyToManagedHarness(request, path);
}
26 changes: 26 additions & 0 deletions frontend/src/app/api/harness/provider/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { NextRequest } from "next/server";
import { requireApiAccess } from "@/lib/auth/guard";
import { proxyToProviderHarness } from "@/app/api/harness/proxy-to-harness";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
): Promise<Response> {
const denied = requireApiAccess(request);
if (denied) return denied;
const { path } = await params;
return proxyToProviderHarness(request, path);
}

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
): Promise<Response> {
const denied = requireApiAccess(request);
if (denied) return denied;
const { path } = await params;
return proxyToProviderHarness(request, path);
}
69 changes: 69 additions & 0 deletions frontend/src/app/api/harness/proxy-to-harness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";
import { harnessTargetUrl, upstreamRequestHeaders } from "./proxy-to-harness";

describe("Local Studio Harness proxy headers", () => {
test("removes browser-origin metadata from the internal upstream request", () => {
const source = new Headers({
accept: "application/json",
authorization: "Bearer test-token",
"content-type": "application/json",
origin: "http://127.0.0.1:4783",
referer: "http://127.0.0.1:4783/harness",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"sec-fetch-user": "?1",
"x-request-id": "request-1",
});

const upstream = upstreamRequestHeaders(source);

for (const name of [
"origin",
"sec-fetch-dest",
"sec-fetch-mode",
"sec-fetch-site",
"sec-fetch-user",
]) {
assert.equal(upstream.get(name), null, `${name} must not cross the proxy boundary`);
}
assert.equal(upstream.get("authorization"), "Bearer test-token");
assert.equal(upstream.get("content-type"), "application/json");
assert.equal(upstream.get("referer"), "http://127.0.0.1:4783/harness");
assert.equal(upstream.get("x-request-id"), "request-1");
assert.equal(source.get("origin"), "http://127.0.0.1:4783");
});

test("still removes hop-by-hop transport headers", () => {
const source = new Headers({
host: "127.0.0.1:4783",
connection: "keep-alive",
"content-length": "42",
"accept-encoding": "gzip, br",
"content-type": "application/json",
});

const upstream = upstreamRequestHeaders(source);

for (const name of ["host", "connection", "content-length", "accept-encoding"]) {
assert.equal(upstream.get(name), null, `${name} must not cross the proxy boundary`);
}
assert.equal(upstream.get("content-type"), "application/json");
});

test("keeps managed goals on the explicit /api namespace", () => {
assert.equal(
harnessTargetUrl(["tasks", "current", "stop"], "api"),
"http://127.0.0.1:8771/api/tasks/current/stop",
);
assert.equal(
harnessTargetUrl(["tasks", "current"], "v1"),
"http://127.0.0.1:8771/v1/tasks/current",
);
});

test("keeps provider-neutral goals on their isolated upstream", () => {
assert.equal(harnessTargetUrl(["tasks"], "api", "provider"), "http://127.0.0.1:8772/api/tasks");
});
});
130 changes: 130 additions & 0 deletions frontend/src/app/api/harness/proxy-to-harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { readRequestBytesWithinLimit } from "@shared/agent/agent-turn-body";

const UPSTREAM_REQUEST_HEADERS_TO_REMOVE = [
"host",
"connection",
"content-length",
"accept-encoding",
// The Harness server validates browser-origin requests against its own
// listener. These headers describe the browser-to-Local-Studio hop and must
// not be replayed on the trusted Local-Studio-to-Harness hop.
// Stripping them here is safe because the browser hop is already enforced
// before this route runs: src/proxy.ts applies evaluateRequestBoundary
// (host allowlist, cross-site rejection, Origin match, CSRF double-submit;
// see src/lib/security/request-boundary.ts and its tests) and the route
// handler re-checks the access token via requireApiAccess. Do not add a
// second origin gate here, and do not stop stripping these headers.
"origin",
"sec-fetch-dest",
"sec-fetch-mode",
"sec-fetch-site",
"sec-fetch-user",
];
const DEFAULT_HARNESS_URL = "http://127.0.0.1:8771";
const DEFAULT_PROVIDER_HARNESS_URL = "http://127.0.0.1:8772";

export type HarnessTarget = "managed" | "provider";

export function harnessBaseUrl(target: HarnessTarget = "managed"): string {
const raw = (
target === "provider"
? process.env.LOCAL_STUDIO_PROVIDER_HARNESS_URL
: process.env.LOCAL_STUDIO_HARNESS_URL
)?.trim();
const fallback = target === "provider" ? DEFAULT_PROVIDER_HARNESS_URL : DEFAULT_HARNESS_URL;
return (raw || fallback).replace(/\/+$/, "");
}

export function upstreamRequestHeaders(requestHeaders: Headers): Headers {
const headers = new Headers(requestHeaders);
for (const name of UPSTREAM_REQUEST_HEADERS_TO_REMOVE) headers.delete(name);
return headers;
}

export function harnessTargetUrl(
path: string[],
namespace: "v1" | "api" = "v1",
target: HarnessTarget = "managed",
): string {
const targetPath = path.map((part) => encodeURIComponent(part)).join("/");
return `${harnessBaseUrl(target)}/${namespace}/${targetPath}`;
}

async function proxyToHarnessNamespace(
request: Request,
path: string[],
namespace: "v1" | "api",
target: HarnessTarget,
bodyLimitBytes = 256 * 1024,
): Promise<Response> {
const sourceUrl = new URL(request.url);
const upstreamTarget = `${harnessTargetUrl(path, namespace, target)}${sourceUrl.search}`;
const headers = upstreamRequestHeaders(request.headers);

let body: ArrayBuffer | undefined;
if (request.method !== "GET" && request.method !== "HEAD") {
const bounded = await readRequestBytesWithinLimit(request, bodyLimitBytes);
if (!bounded.ok) return Response.json({ error: bounded.error }, { status: bounded.status });
body = new ArrayBuffer(bounded.value.byteLength);
new Uint8Array(body).set(bounded.value);
}

let upstream: Response;
try {
upstream = await fetch(upstreamTarget, {
method: request.method,
headers,
body,
signal: request.signal,
cache: "no-store",
});
} catch (error) {
if (request.signal.aborted) throw error;
return Response.json(
{
error: `agentic harness unreachable at ${harnessBaseUrl(target)}: ${
error instanceof Error ? error.message : "fetch failed"
}`,
},
{ status: 502 },
);
}

const responseHeaders = new Headers(upstream.headers);
responseHeaders.delete("content-length");
responseHeaders.delete("content-encoding");
responseHeaders.delete("transfer-encoding");
return new Response(upstream.body, {
status: upstream.status,
headers: responseHeaders,
});
}

export function proxyToHarness(
request: Request,
path: string[],
bodyLimitBytes = 256 * 1024,
): Promise<Response> {
return proxyToHarnessNamespace(request, path, "v1", "managed", bodyLimitBytes);
}

/**
* Proxy the managed local-goal API separately from the read-only integration
* API. Keeping the namespace explicit prevents a UI caller from accidentally
* turning a v1 canary endpoint into a privileged durable-goal action.
*/
export function proxyToManagedHarness(
request: Request,
path: string[],
bodyLimitBytes = 256 * 1024,
): Promise<Response> {
return proxyToHarnessNamespace(request, path, "api", "managed", bodyLimitBytes);
}

export function proxyToProviderHarness(
request: Request,
path: string[],
bodyLimitBytes = 256 * 1024,
): Promise<Response> {
return proxyToHarnessNamespace(request, path, "api", "provider", bodyLimitBytes);
}
5 changes: 5 additions & 0 deletions frontend/src/app/harness/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import HarnessPage from "@/features/harness/harness-page";

export default function Page() {
return <HarnessPage />;
}
Loading