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
1 change: 1 addition & 0 deletions packages/junior/src/chat/respond.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,7 @@ export async function generateAssistantReply(
credentialEgress: requesterId
? {
requesterId,
activeProvider: () => skillSandbox.getActiveSkill()?.pluginProvider,
}
: undefined,
onSandboxAcquired: async (sandbox) => {
Expand Down
75 changes: 50 additions & 25 deletions packages/junior/src/chat/sandbox/egress-policy.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { NetworkPolicy, NetworkPolicyRule } from "@vercel/sandbox";
import type { CredentialHeaderTransform } from "@/chat/credentials/broker";
import { resolveAuthTokenPlaceholder } from "@/chat/plugins/auth/auth-token-placeholder";
import { resolvePluginCommandEnv } from "@/chat/plugins/command-env";
import { getPluginProviders } from "@/chat/plugins/registry";
import type { PluginManifest } from "@/chat/plugins/types";
import { resolveBaseUrl } from "@/chat/oauth-flow";

/** Return whether an outbound host is covered by a sandbox egress domain rule. */
export function matchesSandboxEgressDomain(
Expand Down Expand Up @@ -31,6 +31,10 @@ function providerEntries(): Array<{ provider: string; domains: string[] }> {
.sort((left, right) => left.provider.localeCompare(right.provider));
}

function normalizeDomain(domain: string): string {
return domain.toLowerCase();
}

/** Resolve the plugin provider responsible for an outbound sandbox host. */
export function resolveSandboxEgressProviderForHost(
host: string,
Expand All @@ -40,49 +44,70 @@ export function resolveSandboxEgressProviderForHost(
)?.provider;
}

function proxyUrl(): string | undefined {
const baseUrl = resolveBaseUrl();
if (!baseUrl) {
return undefined;
}
return new URL("/", baseUrl).toString();
/** Return whether a provider can supply host-managed sandbox credential headers. */
export function hasSandboxCredentialEgress(provider: string): boolean {
const plugin = getPluginProviders().find(
(candidate) => candidate.manifest.name === provider,
);
return Boolean(plugin?.manifest.credentials || plugin?.manifest.apiHeaders);
}

/** Build the forwarding policy that keeps provider credentials outside the sandbox. */
export function buildSandboxEgressNetworkPolicy(): NetworkPolicy | undefined {
const entries = providerEntries();
if (entries.length === 0) {
return undefined;
}
const forwardURL = proxyUrl();
if (!forwardURL) {
// Credential placeholders must not reach real provider domains. If Junior
// cannot receive forwarded requests, fail setup before running commands.
throw new Error(
"Cannot determine base URL for sandbox credential egress (set JUNIOR_BASE_URL or deploy to Vercel)",
);
function mergeHeaderTransforms(
headerTransforms: CredentialHeaderTransform[],
): Map<string, Record<string, string>> {
const headersByDomain = new Map<string, Record<string, string>>();
for (const transform of headerTransforms) {
const domain = normalizeDomain(transform.domain);
const existing = headersByDomain.get(domain) ?? {};
headersByDomain.set(domain, {
...existing,
...transform.headers,
});
}
return headersByDomain;
}

/** Build the command-scoped policy that injects credential headers without rewriting URLs. */
export function buildSandboxEgressNetworkPolicy(input?: {
headerTransforms?: CredentialHeaderTransform[];
}): NetworkPolicy {
const headerTransforms = input?.headerTransforms ?? [];
const headersByDomain = mergeHeaderTransforms(headerTransforms);
const allow: Record<string, NetworkPolicyRule[]> = {
"*": [],
};
for (const entry of entries) {

for (const entry of providerEntries()) {
for (const domain of entry.domains) {
allow[domain] = [{ forwardURL }];
const headers = headersByDomain.get(normalizeDomain(domain));
if (headers && Object.keys(headers).length > 0) {
allow[domain] = [{ transform: [{ headers }] }];
}
headersByDomain.delete(normalizeDomain(domain));
}
}
for (const [domain, headers] of [...headersByDomain.entries()].sort(
([left], [right]) => left.localeCompare(right),
)) {
if (Object.keys(headers).length > 0) {
allow[domain] = [{ transform: [{ headers }] }];
}
}

return { allow };
}

/** Resolve non-secret command environment values for registered sandbox providers. */
export async function resolveSandboxCommandEnvironment(): Promise<
Record<string, string>
> {
export async function resolveSandboxCommandEnvironment(
provider?: string,
): Promise<Record<string, string>> {
const env: Record<string, string> = {};
for (const plugin of getPluginProviders().sort((left, right) =>
left.manifest.name.localeCompare(right.manifest.name),
)) {
if (provider && plugin.manifest.name !== provider) {
continue;
}
Object.assign(env, resolvePluginCommandEnv(plugin.manifest));
const credentials = plugin.manifest.credentials;
if (credentials) {
Expand Down
130 changes: 124 additions & 6 deletions packages/junior/src/chat/sandbox/egress-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@ function jsonError(message: string, status: number): Response {
return Response.json({ error: message }, { status });
}

function egressAttributes(input: {
egressId?: string;
host?: string;
method?: string;
path?: string;
provider?: string;
status?: number;
}): Record<string, unknown> {
return {
...(input.egressId ? { "app.sandbox.egress_id": input.egressId } : {}),
...(input.provider ? { "app.credential.provider": input.provider } : {}),
...(input.host ? { "server.address": input.host } : {}),
...(input.method ? { "http.request.method": input.method } : {}),
...(input.path ? { "url.path": input.path } : {}),
...(input.status ? { "http.response.status_code": input.status } : {}),
};
}

function normalizeHost(value: string): string | undefined {
const trimmed = value.trim().toLowerCase();
if (
Expand Down Expand Up @@ -262,6 +280,15 @@ export async function proxySandboxEgressRequest(

const activeEgressId = sandboxIdFromPayload(oidcPayload);
if (!activeEgressId) {
logWarn(
"sandbox_egress_oidc_session_missing",
{},
{
"http.request.method": request.method,
"url.path": new URL(request.url).pathname,
},
"Sandbox egress OIDC payload did not include a VM session id",
);
return jsonError(
"Vercel Sandbox OIDC token did not include sandbox_id",
401,
Expand All @@ -270,19 +297,55 @@ export async function proxySandboxEgressRequest(

const upstreamResult = buildUpstreamUrl(request);
if (!upstreamResult.ok) {
logWarn(
"sandbox_egress_upstream_url_invalid",
{},
egressAttributes({
egressId: activeEgressId,
method: request.method,
path: new URL(request.url).pathname,
status: 400,
}),
"Sandbox egress forwarded request had invalid upstream routing headers",
);
return jsonError(upstreamResult.error, 400);
}
const upstreamUrl = upstreamResult.url;

const provider = resolveSandboxEgressProviderForHost(upstreamUrl.hostname);
if (!provider) {
logWarn(
"sandbox_egress_provider_unresolved",
{},
egressAttributes({
egressId: activeEgressId,
host: upstreamUrl.hostname,
method: request.method,
path: upstreamUrl.pathname,
status: 403,
}),
"Sandbox egress forwarded host is not owned by any credential provider",
);
return jsonError("No provider owns forwarded host", 403);
}

// Vercel OIDC authenticates the forwarded VM session; Junior's egress
// session authorizes credential activation for the current requester.
const session = await getSandboxEgressSession(activeEgressId);
if (!session) {
logWarn(
"sandbox_egress_session_unauthorized",
{},
egressAttributes({
egressId: activeEgressId,
host: upstreamUrl.hostname,
method: request.method,
path: upstreamUrl.pathname,
provider,
status: 403,
}),
"Sandbox egress VM session is not authorized for requester credentials",
);
return jsonError("Sandbox egress session is not authorized", 403);
}

Expand All @@ -291,6 +354,19 @@ export async function proxySandboxEgressRequest(
lease = await credentialLease(activeEgressId, provider, session);
} catch (error) {
if (error instanceof CredentialUnavailableError) {
logWarn(
"sandbox_egress_credential_unavailable",
{},
egressAttributes({
egressId: activeEgressId,
host: upstreamUrl.hostname,
method: request.method,
path: upstreamUrl.pathname,
provider,
status: 401,
}),
"Sandbox egress provider credential is unavailable",
);
return new Response(
`junior-auth-required provider=${error.provider} 401 unauthorized\n${error.message}`,
{
Expand All @@ -303,25 +379,67 @@ export async function proxySandboxEgressRequest(
}

if (!hasTransformForHost(lease, upstreamUrl.hostname)) {
logWarn(
"sandbox_egress_transform_missing",
{},
{
...egressAttributes({
egressId: activeEgressId,
host: upstreamUrl.hostname,
method: request.method,
path: upstreamUrl.pathname,
provider,
status: 403,
}),
"app.sandbox.egress.transform_domains": lease.headerTransforms.map(
(transform) => transform.domain,
),
},
"Sandbox egress credential lease does not cover forwarded host",
);
return jsonError("Credential lease does not cover forwarded host", 403);
}

const body = await requestBodyBytes(request);
const upstream = await (deps.fetch ?? fetch)(upstreamUrl, {
const fetchImpl = deps.fetch ?? fetch;
const headers = requestHeaders(request, lease, upstreamUrl.hostname);
const upstream = await fetchImpl(upstreamUrl, {
method: request.method,
headers: requestHeaders(request, lease, upstreamUrl.hostname),
headers,
...(body ? { body } : {}),
redirect: "manual",
});
if (!upstream.ok) {
logWarn(
"sandbox_egress_upstream_error_response",
{},
{
...egressAttributes({
egressId: activeEgressId,
host: upstreamUrl.hostname,
method: request.method,
path: upstreamUrl.pathname,
provider,
status: upstream.status,
}),
"error.type": `http_${upstream.status}`,
},
`Sandbox egress upstream returned HTTP ${upstream.status}`,
);
}
Comment thread
cursor[bot] marked this conversation as resolved.
if (AUTH_REJECTION_STATUS.has(upstream.status)) {
logWarn(
"sandbox_egress_upstream_auth_rejected",
{},
{
"app.credential.provider": provider,
"http.request.method": request.method,
"http.response.status_code": upstream.status,
"server.address": upstreamUrl.hostname,
...egressAttributes({
egressId: activeEgressId,
host: upstreamUrl.hostname,
method: request.method,
path: upstreamUrl.pathname,
provider,
status: upstream.status,
}),
},
"Sandbox egress upstream auth rejected",
);
Expand Down
Loading
Loading