Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/server/handlers/execution-surface-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const CAPABILITY_GATED_SURFACES = [
"request/api/app-router-handler.ts",
"request/api/project-discovery.ts",
"request/module/module.handler.ts",
"request/public-agent-metadata.handler.ts",
"request/public-agents-list.handler.ts",
"request/snippet.handler.ts",
"request/ssr/ssr.handler.ts",
].toSorted();
Expand Down
130 changes: 130 additions & 0 deletions src/server/handlers/request/public-agent-metadata.handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,37 @@ import { createEmptyDiscoveryResult } from "#veryfront/discovery";
import { assertEquals, assertExists } from "#veryfront/testing/assert.ts";
import { describe, it } from "#veryfront/testing/bdd.ts";
import { PublicAgentMetadataHandler } from "./public-agent-metadata.handler.ts";
import { ensureProjectDiscovery } from "./api/project-discovery.ts";
import type { HandlerContext } from "../types.ts";
import { createAgentWithConfig, createCtx } from "./internal-agent-run.test-helpers.ts";

/**
* A shared multi-project runtime context without a host execution grant.
* Mirrors the proxy topology that produced Sentry VERYFRONT-SERVER-Z: the
* runtime is shared, so remote executable discovery must not run in-process.
*/
function createSharedRuntimeCtx(overrides: Record<string, unknown> = {}): HandlerContext {
const fs = {
isMultiProjectMode: () => true,
isContextualMode: () => false,
runWithContext: async (
_slug: string,
_token: string,
fn: () => Promise<unknown>,
) => await fn(),
};
return {
projectDir: "/project",
projectSlug: "demo-project",
projectId: "proj-1",
proxyToken: "token",
isLocalProject: false,
securityConfig: null,
adapter: { env: { get: () => undefined }, fs },
...overrides,
} as unknown as HandlerContext;
}

describe("server/handlers/request/public-agent-metadata.handler", () => {
it("returns browser-safe source-defined agent metadata", async () => {
let discoveryCalls = 0;
Expand Down Expand Up @@ -108,4 +137,105 @@ describe("server/handlers/request/public-agent-metadata.handler", () => {

assertEquals(result.continue, true);
});

describe("shared runtime without a host execution grant", () => {
it("fails closed with project-execution-unavailable instead of leaking the discovery error", async () => {
// Regression for Sentry VERYFRONT-SERVER-Z (issue-inbox#854): in a
// shared proxy runtime the real discovery guard throws
// "Remote executable discovery requires an isolated project runtime and
// cannot run in the shared host", and this handler let that raw 500
// escape. Sibling surfaces (SSR, snippet, app-router) answer the same
// topology with a structured 503 problem response.
const handler = new PublicAgentMetadataHandler({
ensureProjectDiscovery,
getAgent: () => undefined,
getAllAgentIds: () => [],
});

const result = await handler.handle(
new Request("https://example.com/api/agents/support-agent", { method: "GET" }),
createSharedRuntimeCtx(),
);

assertExists(
result.response,
"an ungranted shared runtime must receive a structured response, not a thrown discovery error",
);
assertEquals(
result.response.status,
503,
"the shared-runtime denial must surface as project-execution-unavailable",
);
assertEquals(
result.response.headers.get("content-type"),
"application/problem+json",
"the denial must be an RFC 9457 problem response",
);
assertEquals(
(await result.response.json() as { type?: string }).type,
"https://veryfront.com/docs/code/guides/errors#project-execution-unavailable",
"the problem type must identify the dedicated-runtime requirement",
);
});

it("still rejects a malformed agent id with 400 before the runtime gate", async () => {
// Input validation needs neither discovery nor project-code execution,
// so the endpoint's 400 contract must hold on an ungranted shared
// runtime too. Gating first would turn every malformed id into a
// retryable 503.
const handler = new PublicAgentMetadataHandler({
ensureProjectDiscovery: async () => {
throw new Error("should not discover");
},
getAgent: () => undefined,
getAllAgentIds: () => [],
});

const result = await handler.handle(
new Request("https://example.com/api/agents/%", { method: "GET" }),
createSharedRuntimeCtx(),
);

assertExists(result.response);
assertEquals(
result.response.status,
400,
"a malformed agent id must be rejected as input error, not as runtime-unavailable",
);
assertEquals(await result.response.json(), { error: "Invalid agent id" });
});

it("serves a shared runtime the host granted execution", async () => {
// The granted counterpart. Without it, a handler that denies every
// shared runtime passes the fail-closed test above.
let discoveryCalls = 0;
const handler = new PublicAgentMetadataHandler({
ensureProjectDiscovery: async () => {
discoveryCalls += 1;
return createEmptyDiscoveryResult();
},
getAgent: (id) =>
id === "support-agent"
? createAgentWithConfig("support-agent", {
name: "Support Agent",
description: null,
})
: undefined,
getAllAgentIds: () => ["support-agent"],
});

const result = await handler.handle(
new Request("https://example.com/api/agents/support-agent", { method: "GET" }),
createSharedRuntimeCtx({ allowHostProjectCodeExecution: true }),
);

assertExists(result.response);
assertEquals(
result.response.status,
200,
"a granted shared executor must not return project-execution-unavailable",
);
assertEquals(discoveryCalls, 1, "the granted path must reach discovery");
});
});
});
33 changes: 24 additions & 9 deletions src/server/handlers/request/public-agent-metadata.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import {
type RuntimeAgentDiscoveryDeps,
} from "#veryfront/channels/control-plane.ts";
import { defaultChannelInvokeDeps } from "#veryfront/channels/invoke.ts";
import { requiresIsolatedProjectRuntime } from "#veryfront/security/project-locality.ts";
import { PRIORITY_MEDIUM_API } from "#veryfront/utils/constants/index.ts";
import { BaseHandler } from "../response/base.ts";
import type { HandlerContext, HandlerMetadata, HandlerPriority, HandlerResult } from "../types.ts";
import { buildProjectExecutionUnavailableResponse } from "../utils/project-execution-unavailable.ts";

const PUBLIC_AGENT_METADATA_PATH = /^\/api\/agents\/([^/]+)$/;

Expand Down Expand Up @@ -39,17 +41,30 @@ export class PublicAgentMetadataHandler extends BaseHandler {
return this.continue();
}

return this.withProxyContext(ctx, async () => {
const builder = this.createResponseBuilder(ctx)
.withCORS(req, ctx.securityConfig?.cors)
.withSecurity(ctx.securityConfig ?? undefined, req);
const builder = this.createResponseBuilder(ctx)
.withCORS(req, ctx.securityConfig?.cors)
.withSecurity(ctx.securityConfig ?? undefined, req);

const { pathname } = new URL(req.url);
const agentId = getAgentIdFromPath(pathname);
if (!agentId) {
return this.respond(builder.json({ error: "Invalid agent id" }, 400));
}
// Validate input before the runtime gate: a malformed id needs neither
// discovery nor project-code execution, so it keeps its 400 contract on
// every topology instead of turning into a retryable 503.
const { pathname } = new URL(req.url);
const agentId = getAgentIdFromPath(pathname);
if (!agentId) {
return this.respond(builder.json({ error: "Invalid agent id" }, 400));
}

if (requiresIsolatedProjectRuntime(ctx)) {
Comment thread
kwakayama marked this conversation as resolved.
Comment thread
kwakayama marked this conversation as resolved.
return this.respond(
buildProjectExecutionUnavailableResponse(this.helpers, req, ctx, {
detail:
"Shared runtimes require a dedicated isolated project runtime for agent discovery",
instance: pathname,
}),
);
}

return this.withProxyContext(ctx, async () => {
await this.deps.ensureProjectDiscovery(ctx);
const agent = this.deps.getAgent(agentId);
if (!agent) {
Expand Down
97 changes: 97 additions & 0 deletions src/server/handlers/request/public-agents-list.handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,37 @@ import { createEmptyDiscoveryResult } from "#veryfront/discovery";
import { assertEquals, assertExists } from "#veryfront/testing/assert.ts";
import { describe, it } from "#veryfront/testing/bdd.ts";
import { PublicAgentsListHandler } from "./public-agents-list.handler.ts";
import { ensureProjectDiscovery } from "./api/project-discovery.ts";
import type { HandlerContext } from "../types.ts";
import { createAgentWithConfig, createCtx } from "./internal-agent-run.test-helpers.ts";

/**
* A shared multi-project runtime context without a host execution grant.
* Mirrors the proxy topology that produced Sentry VERYFRONT-SERVER-Z: the
* runtime is shared, so remote executable discovery must not run in-process.
*/
function createSharedRuntimeCtx(overrides: Record<string, unknown> = {}): HandlerContext {
const fs = {
isMultiProjectMode: () => true,
isContextualMode: () => false,
runWithContext: async (
_slug: string,
_token: string,
fn: () => Promise<unknown>,
) => await fn(),
};
return {
projectDir: "/project",
projectSlug: "demo-project",
projectId: "proj-1",
proxyToken: "token",
isLocalProject: false,
securityConfig: null,
adapter: { env: { get: () => undefined }, fs },
...overrides,
} as unknown as HandlerContext;
}

describe("server/handlers/request/public-agents-list.handler", () => {
it("returns every browser-safe agent, sorted by name", async () => {
let discoveryCalls = 0;
Expand Down Expand Up @@ -91,4 +120,72 @@ describe("server/handlers/request/public-agents-list.handler", () => {

assertEquals(result.continue, true);
});

describe("shared runtime without a host execution grant", () => {
it("fails closed with project-execution-unavailable instead of leaking the discovery error", async () => {
// Regression for Sentry VERYFRONT-SERVER-Z (issue-inbox#854): in a
// shared proxy runtime the real discovery guard throws
// "Remote executable discovery requires an isolated project runtime and
// cannot run in the shared host", and this handler let that raw 500
// escape. Sibling surfaces (SSR, snippet, app-router) answer the same
// topology with a structured 503 problem response.
const handler = new PublicAgentsListHandler({
ensureProjectDiscovery,
getAgent: () => undefined,
getAllAgentIds: () => [],
});

const result = await handler.handle(
new Request("https://example.com/api/agents", { method: "GET" }),
createSharedRuntimeCtx(),
);

assertExists(
result.response,
"an ungranted shared runtime must receive a structured response, not a thrown discovery error",
);
assertEquals(
result.response.status,
503,
"the shared-runtime denial must surface as project-execution-unavailable",
);
assertEquals(
result.response.headers.get("content-type"),
"application/problem+json",
"the denial must be an RFC 9457 problem response",
);
assertEquals(
(await result.response.json() as { type?: string }).type,
"https://veryfront.com/docs/code/guides/errors#project-execution-unavailable",
"the problem type must identify the dedicated-runtime requirement",
);
});

it("serves a shared runtime the host granted execution", async () => {
// The granted counterpart. Without it, a handler that denies every
// shared runtime passes the fail-closed test above.
let discoveryCalls = 0;
const handler = new PublicAgentsListHandler({
ensureProjectDiscovery: async () => {
discoveryCalls += 1;
return createEmptyDiscoveryResult();
},
getAgent: () => undefined,
getAllAgentIds: () => [],
});

const result = await handler.handle(
new Request("https://example.com/api/agents", { method: "GET" }),
createSharedRuntimeCtx({ allowHostProjectCodeExecution: true }),
);

assertExists(result.response);
assertEquals(
result.response.status,
200,
"a granted shared executor must not return project-execution-unavailable",
);
assertEquals(discoveryCalls, 1, "the granted path must reach discovery");
});
});
});
12 changes: 12 additions & 0 deletions src/server/handlers/request/public-agents-list.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import {
type RuntimeAgentPublicMetadata,
} from "#veryfront/channels/control-plane.ts";
import { defaultChannelInvokeDeps } from "#veryfront/channels/invoke.ts";
import { requiresIsolatedProjectRuntime } from "#veryfront/security/project-locality.ts";
import { PRIORITY_MEDIUM_API } from "#veryfront/utils/constants/index.ts";
import { BaseHandler } from "../response/base.ts";
import type { HandlerContext, HandlerMetadata, HandlerPriority, HandlerResult } from "../types.ts";
import { buildProjectExecutionUnavailableResponse } from "../utils/project-execution-unavailable.ts";

const PUBLIC_AGENTS_LIST_PATH = "/api/agents";

Expand Down Expand Up @@ -36,6 +38,16 @@ export class PublicAgentsListHandler extends BaseHandler {
return this.continue();
}

if (requiresIsolatedProjectRuntime(ctx)) {
return this.respond(
buildProjectExecutionUnavailableResponse(this.helpers, req, ctx, {
detail:
"Shared runtimes require a dedicated isolated project runtime for agent discovery",
instance: PUBLIC_AGENTS_LIST_PATH,
}),
);
}

return this.withProxyContext(ctx, async () => {
const builder = this.createResponseBuilder(ctx)
.withCORS(req, ctx.securityConfig?.cors)
Expand Down
17 changes: 4 additions & 13 deletions src/server/handlers/request/snippet.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@ import { serverLogger } from "#veryfront/utils";
import { renderSnippet } from "#veryfront/rendering/snippet-renderer.ts";
import {
createErrorResponse,
createErrorResponseFromDefinition,
FILE_NOT_FOUND,
getErrorMessage,
PROJECT_EXECUTION_UNAVAILABLE,
SECURITY_VIOLATION,
VeryfrontError,
} from "#veryfront/errors";
Expand All @@ -17,6 +15,7 @@ import {
createHandlerDependencyPinningSource,
getHandlerDependencyPinningIdentity,
} from "#veryfront/server/handlers/utils/dependency-pinning-source.ts";
import { buildProjectExecutionUnavailableResponse } from "#veryfront/server/handlers/utils/project-execution-unavailable.ts";

const logger = serverLogger.component("snippet-handler");

Expand Down Expand Up @@ -48,21 +47,13 @@ export class SnippetHandler extends BaseHandler {
}

if (requiresIsolatedProjectRuntime(ctx)) {
const problem = createErrorResponseFromDefinition(
PROJECT_EXECUTION_UNAVAILABLE,
{
return this.respond(
buildProjectExecutionUnavailableResponse(this.helpers, req, ctx, {
detail:
"Shared runtimes require a dedicated isolated project runtime for snippet rendering",
instance: pathname,
},
}),
);
const response = this.createResponseBuilder(ctx)
.withCORS(req, ctx.securityConfig?.cors)
.withSecurity(ctx.securityConfig ?? undefined, req)
.withCache("no-store")
.withHeaders(problem.headers)
.build(problem.body, problem.status);
return Promise.resolve(this.respond(response));
}

logger.debug("Handling snippet request", {
Expand Down
4 changes: 4 additions & 0 deletions src/server/handlers/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,7 @@ export {
stripSnapshotQuery,
withSnapshotResponseHeaders,
} from "./dependency-snapshot-protocol.ts";
export {
buildProjectExecutionUnavailableResponse,
type ProjectExecutionUnavailableOptions,
} from "./project-execution-unavailable.ts";
Loading
Loading