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);
}
99 changes: 99 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,99 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";
import {
downstreamResponseHeaders,
harnessTargetUrl,
upstreamRequestHeaders,
} from "./proxy-to-harness";

describe("Local Studio Harness proxy headers", () => {
test("forwards only protocol headers required by the Harness API", () => {
const source = new Headers({
accept: "application/json",
authorization: "Bearer test-token",
cookie: "local_studio_token=cookie-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-local-studio-csrf": "csrf-token",
"x-local-studio-token": "header-token",
"x-request-id": "request-1",
});

const upstream = upstreamRequestHeaders(source);

for (const name of [
"authorization",
"cookie",
"origin",
"referer",
"sec-fetch-dest",
"sec-fetch-mode",
"sec-fetch-site",
"sec-fetch-user",
"x-local-studio-csrf",
"x-local-studio-token",
]) {
assert.equal(upstream.get(name), null, `${name} must not cross the proxy boundary`);
}
assert.equal(upstream.get("accept"), "application/json");
assert.equal(upstream.get("content-type"), "application/json");
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("does not let the Harness set Local Studio cookies", () => {
const downstream = downstreamResponseHeaders(
new Headers({
"content-type": "application/json",
"set-cookie": "local_studio_token=attacker-controlled",
"x-request-id": "request-2",
}),
);

assert.equal(downstream.get("set-cookie"), null);
assert.equal(downstream.get("content-type"), "application/json");
assert.equal(downstream.get("x-request-id"), "request-2");
});

test("rejects dot segments before URL normalization can escape the API namespace", () => {
assert.throws(() => harnessTargetUrl(["..", "admin"], "api"), /dot segments/);
assert.throws(() => harnessTargetUrl(["%2e%2e", "admin"], "api"), /dot segments/);
});

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");
});
});
155 changes: 155 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,155 @@
import { readRequestBytesWithinLimit } from "@shared/agent/agent-turn-body";

const UPSTREAM_REQUEST_HEADER_ALLOWLIST = [
"accept",
"content-type",
"if-match",
"if-none-match",
"last-event-id",
"x-request-id",
];
const DOWNSTREAM_RESPONSE_HEADER_ALLOWLIST = [
"cache-control",
"content-type",
"etag",
"last-modified",
"retry-after",
"x-request-id",
];
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();
for (const name of UPSTREAM_REQUEST_HEADER_ALLOWLIST) {
const value = requestHeaders.get(name);
if (value !== null) headers.set(name, value);
}
return headers;
}

export function downstreamResponseHeaders(upstreamHeaders: Headers): Headers {
const headers = new Headers();
for (const name of DOWNSTREAM_RESPONSE_HEADER_ALLOWLIST) {
const value = upstreamHeaders.get(name);
if (value !== null) headers.set(name, value);
}
return headers;
}

function harnessPathSegment(part: string): string {
let decoded = part;
try {
decoded = decodeURIComponent(part);
} catch {
throw new TypeError("Harness path contains invalid encoding");
}
if (decoded === "." || decoded === "..") {
throw new TypeError("Harness path cannot contain dot segments");
}
return encodeURIComponent(part);
}

export function harnessTargetUrl(
path: string[],
namespace: "v1" | "api" = "v1",
target: HarnessTarget = "managed",
): string {
const targetPath = path.map(harnessPathSegment).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);
let upstreamTarget: string;
try {
upstreamTarget = `${harnessTargetUrl(path, namespace, target)}${sourceUrl.search}`;
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "Harness path is invalid" },
{ status: 400 },
);
}
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 },
);
}

return new Response(upstream.body, {
status: upstream.status,
headers: downstreamResponseHeaders(upstream.headers),
});
}

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