diff --git a/packages/junior/src/chat/respond.ts b/packages/junior/src/chat/respond.ts index 77603a76..10ce5031 100644 --- a/packages/junior/src/chat/respond.ts +++ b/packages/junior/src/chat/respond.ts @@ -501,6 +501,7 @@ export async function generateAssistantReply( credentialEgress: requesterId ? { requesterId, + activeProvider: () => skillSandbox.getActiveSkill()?.pluginProvider, } : undefined, onSandboxAcquired: async (sandbox) => { diff --git a/packages/junior/src/chat/sandbox/egress-policy.ts b/packages/junior/src/chat/sandbox/egress-policy.ts index 094b7d43..6e6a4a71 100644 --- a/packages/junior/src/chat/sandbox/egress-policy.ts +++ b/packages/junior/src/chat/sandbox/egress-policy.ts @@ -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( @@ -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, @@ -40,35 +44,53 @@ 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> { + const headersByDomain = new Map>(); + 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 = { "*": [], }; - 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 }] }]; } } @@ -76,13 +98,16 @@ export function buildSandboxEgressNetworkPolicy(): NetworkPolicy | undefined { } /** Resolve non-secret command environment values for registered sandbox providers. */ -export async function resolveSandboxCommandEnvironment(): Promise< - Record -> { +export async function resolveSandboxCommandEnvironment( + provider?: string, +): Promise> { const env: Record = {}; 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) { diff --git a/packages/junior/src/chat/sandbox/egress-proxy.ts b/packages/junior/src/chat/sandbox/egress-proxy.ts index 2f505774..704bc567 100644 --- a/packages/junior/src/chat/sandbox/egress-proxy.ts +++ b/packages/junior/src/chat/sandbox/egress-proxy.ts @@ -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 { + 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 ( @@ -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, @@ -270,12 +297,35 @@ 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); } @@ -283,6 +333,19 @@ export async function proxySandboxEgressRequest( // 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); } @@ -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}`, { @@ -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}`, + ); + } 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", ); diff --git a/packages/junior/src/chat/sandbox/sandbox.ts b/packages/junior/src/chat/sandbox/sandbox.ts index db447191..6d06f76c 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -1,4 +1,9 @@ import fs from "node:fs/promises"; +import { issueProviderCredentialLease } from "@/chat/capabilities/factory"; +import { + CredentialUnavailableError, + type CredentialHeaderTransform, +} from "@/chat/credentials/broker"; import { logInfo, setSpanAttributes, @@ -8,6 +13,7 @@ import { } from "@/chat/logging"; import { buildSandboxEgressNetworkPolicy, + hasSandboxCredentialEgress, resolveSandboxCommandEnvironment, } from "@/chat/sandbox/egress-policy"; import { @@ -103,6 +109,7 @@ export function createSandboxExecutor(options?: { traceContext?: LogContext; credentialEgress?: { requesterId: string; + activeProvider?: () => string | undefined; }; onSandboxAcquired?: (sandbox: SandboxAcquiredState) => void | Promise; runBashCustomCommand?: ( @@ -113,17 +120,36 @@ export function createSandboxExecutor(options?: { let referenceFiles: string[] = []; const traceContext = options?.traceContext ?? {}; const credentialEgress = options?.credentialEgress; - const syncSandboxEgressSession = credentialEgress + let commandHeaderTransforms: CredentialHeaderTransform[] = []; + const activateSandboxEgressForCommand = credentialEgress ? async (egressId: string): Promise => { await upsertSandboxEgressSession({ egressId, requesterId: credentialEgress.requesterId, ttlMs: options?.timeoutMs, }); + commandHeaderTransforms = []; + const provider = credentialEgress.activeProvider?.(); + if (!provider || !hasSandboxCredentialEgress(provider)) { + return; + } + const lease = await issueProviderCredentialLease({ + provider, + requesterId: credentialEgress.requesterId, + reason: `sandbox-command:${provider}`, + }); + const headerTransforms = lease.headerTransforms ?? []; + if (headerTransforms.length === 0) { + throw new Error( + `Credential lease for ${provider} did not include header transforms`, + ); + } + commandHeaderTransforms = headerTransforms; } : undefined; - const clearSandboxEgressSessionForCommand = credentialEgress + const clearSandboxEgressForCommand = credentialEgress ? async (egressId: string): Promise => { + commandHeaderTransforms = []; await clearSandboxEgressSession(egressId); } : undefined; @@ -133,13 +159,21 @@ export function createSandboxExecutor(options?: { timeoutMs: options?.timeoutMs, traceContext, commandEnv: credentialEgress - ? async () => await resolveSandboxCommandEnvironment() + ? async () => { + const provider = credentialEgress.activeProvider?.(); + return provider + ? await resolveSandboxCommandEnvironment(provider) + : {}; + } : undefined, createNetworkPolicy: credentialEgress - ? buildSandboxEgressNetworkPolicy + ? () => + buildSandboxEgressNetworkPolicy({ + headerTransforms: commandHeaderTransforms, + }) : undefined, - beforeCommand: syncSandboxEgressSession, - afterCommand: clearSandboxEgressSessionForCommand, + beforeCommand: activateSandboxEgressForCommand, + afterCommand: clearSandboxEgressForCommand, onSandboxAcquired: async (sandbox) => { await options?.onSandboxAcquired?.(sandbox); }, @@ -211,6 +245,27 @@ export function createSandboxExecutor(options?: { setSpanStatus(response.exitCode === 0 ? "ok" : "error"); return response; } catch (error) { + if (error instanceof CredentialUnavailableError) { + const response = { + stdout: "", + stderr: `junior-auth-required provider=${error.provider} 401 unauthorized\n${error.message}`, + exitCode: 1, + stdoutTruncated: false, + stderrTruncated: false, + timedOut: false, + }; + setSpanAttributes({ + "process.exit.code": response.exitCode, + "app.sandbox.stdout_bytes": 0, + "app.sandbox.stderr_bytes": Buffer.byteLength( + response.stderr, + "utf8", + ), + "error.type": error.name, + }); + setSpanStatus("error"); + return response; + } setSpanAttributes({ "error.type": error instanceof Error ? error.name : "sandbox_execute_error", diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 653bf6e5..12799db8 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -636,7 +636,6 @@ export function createSandboxSessionManager(options?: { return { bash: async (input) => { const commandEgressId = sandboxInstance.sandboxEgressId; - await options?.beforeCommand?.(commandEgressId); let timedOut = false; let timeoutId: ReturnType | undefined; let commandFinished = false; @@ -646,6 +645,7 @@ export function createSandboxSessionManager(options?: { } commandFinished = true; await options?.afterCommand?.(commandEgressId); + await refreshNetworkPolicy(sandboxInstance); }; const finishCommandBestEffort = async (): Promise => { try { @@ -666,6 +666,8 @@ export function createSandboxSessionManager(options?: { } }; try { + await options?.beforeCommand?.(commandEgressId); + await refreshNetworkPolicy(sandboxInstance); const sandboxCommandEnv = await resolveCommandEnv(); const script = buildNonInteractiveShellScript(input.command, { env: { ...sandboxCommandEnv, ...(input.env ?? {}) }, diff --git a/packages/junior/tests/unit/handlers/sandbox-egress-proxy.test.ts b/packages/junior/tests/unit/handlers/sandbox-egress-proxy.test.ts index 5d6869f5..c3eb1717 100644 --- a/packages/junior/tests/unit/handlers/sandbox-egress-proxy.test.ts +++ b/packages/junior/tests/unit/handlers/sandbox-egress-proxy.test.ts @@ -63,6 +63,7 @@ vi.mock("@/chat/capabilities/factory", () => ({ import { buildSandboxEgressNetworkPolicy, + hasSandboxCredentialEgress, matchesSandboxEgressDomain, resolveSandboxCommandEnvironment, } from "@/chat/sandbox/egress-policy"; @@ -148,7 +149,6 @@ function proxy( describe("sandbox egress proxy", () => { beforeEach(async () => { process.env.JUNIOR_STATE_ADAPTER = "memory"; - process.env.JUNIOR_BASE_URL = "https://junior.example.com"; createRemoteJWKSetMock.mockClear(); createRemoteJWKSetMock.mockReturnValue(async () => null); decodeJwtMock.mockReset(); @@ -160,39 +160,41 @@ describe("sandbox egress proxy", () => { afterEach(async () => { await disconnectStateAdapter(); delete process.env.JUNIOR_STATE_ADAPTER; - delete process.env.JUNIOR_BASE_URL; delete process.env.SENTRY_BOT_EMAIL; vi.restoreAllMocks(); }); - it("builds provider forwarding policy for sandbox egress", () => { + it("builds provider transform policy for sandbox egress", () => { expect(matchesSandboxEgressDomain("SENTRY.IO", "sentry.io")).toBe(true); expect(matchesSandboxEgressDomain("eu.sentry.io", "sentry.io")).toBe(false); + expect(hasSandboxCredentialEgress("sentry")).toBe(true); + expect(hasSandboxCredentialEgress("github")).toBe(false); expect(buildSandboxEgressNetworkPolicy()).toEqual({ allow: { "*": [], - "sentry.io": [ + }, + }); + expect( + buildSandboxEgressNetworkPolicy({ + headerTransforms: [ { - forwardURL: "https://junior.example.com/", + domain: "sentry.io", + headers: { Authorization: "Bearer sentry-token" }, }, ], - "us.sentry.io": [ + }), + ).toEqual({ + allow: { + "*": [], + "sentry.io": [ { - forwardURL: "https://junior.example.com/", + transform: [{ headers: { Authorization: "Bearer sentry-token" } }], }, ], }, }); }); - it("fails sandbox egress policy setup without a public callback URL", () => { - delete process.env.JUNIOR_BASE_URL; - - expect(() => buildSandboxEgressNetworkPolicy()).toThrow( - "Cannot determine base URL for sandbox credential egress", - ); - }); - it("resolves command env for registered sandbox providers", async () => { await expect(resolveSandboxCommandEnvironment()).resolves.toEqual({ SENTRY_READ_ONLY: "1", @@ -200,6 +202,16 @@ describe("sandbox egress proxy", () => { }); }); + it("resolves command env for a single active sandbox provider", async () => { + await expect(resolveSandboxCommandEnvironment("sentry")).resolves.toEqual({ + SENTRY_READ_ONLY: "1", + SENTRY_AUTH_TOKEN: "host_managed_credential", + }); + await expect(resolveSandboxCommandEnvironment("github")).resolves.toEqual( + {}, + ); + }); + it("resolves host env bindings for sandbox commands", async () => { process.env.SENTRY_BOT_EMAIL = "123+sentry[bot]@users.noreply.github.com"; @@ -210,9 +222,7 @@ describe("sandbox egress proxy", () => { }); }); - it("requires OIDC before proxy configuration details", async () => { - delete process.env.JUNIOR_BASE_URL; - + it("requires OIDC before forwarded routing details", async () => { const response = await ALL( new Request("https://junior.example.com/api/0/issues/"), ); diff --git a/packages/junior/tests/unit/misc/sandbox-executor.test.ts b/packages/junior/tests/unit/misc/sandbox-executor.test.ts index 5d5a37fa..0f0ccea1 100644 --- a/packages/junior/tests/unit/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/unit/misc/sandbox-executor.test.ts @@ -1,14 +1,17 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CredentialUnavailableError } from "@/chat/credentials/broker"; import { SANDBOX_WORKSPACE_ROOT, sandboxSkillDir } from "@/chat/sandbox/paths"; import type { SandboxInstance } from "@/chat/sandbox/workspace"; -const { sandboxGetMock, sandboxCreateMock } = vi.hoisted(() => ({ - sandboxGetMock: vi.fn(), - sandboxCreateMock: vi.fn(), -})); +const { sandboxGetMock, sandboxCreateMock, issueProviderCredentialLeaseMock } = + vi.hoisted(() => ({ + sandboxGetMock: vi.fn(), + sandboxCreateMock: vi.fn(), + issueProviderCredentialLeaseMock: vi.fn(), + })); vi.mock("@vercel/sandbox", () => ({ Sandbox: { @@ -21,6 +24,45 @@ vi.mock("bash-tool", () => ({ createBashTool: vi.fn(), })); +vi.mock("@/chat/config", async (importOriginal) => { + const original = await importOriginal(); + const memoryConfig = original.readChatConfig({ + ...process.env, + JUNIOR_STATE_ADAPTER: "memory", + }); + return { + ...original, + botConfig: memoryConfig.bot, + getChatConfig: () => memoryConfig, + }; +}); + +vi.mock("@/chat/capabilities/factory", () => ({ + issueProviderCredentialLease: issueProviderCredentialLeaseMock, +})); + +vi.mock("@/chat/plugins/registry", () => ({ + getPluginProviders: () => [ + { + manifest: { + name: "sentry", + description: "Sentry", + capabilities: ["sentry.api"], + configKeys: [], + commandEnv: { + SENTRY_READ_ONLY: "1", + }, + credentials: { + type: "oauth-bearer", + domains: ["sentry.io"], + authTokenEnv: "SENTRY_AUTH_TOKEN", + authTokenPlaceholder: "host_managed_credential", + }, + }, + }, + ], +})); + const { resolveRuntimeDependencySnapshotMock, isSnapshotMissingErrorMock, @@ -54,6 +96,7 @@ vi.mock("@/chat/sandbox/runtime-dependency-snapshots", () => ({ import { createSandboxExecutor } from "@/chat/sandbox/sandbox"; import { createSandboxSessionManager } from "@/chat/sandbox/session"; +import { disconnectStateAdapter } from "@/chat/state/adapter"; import { createBashTool } from "bash-tool"; interface MockSandbox { @@ -175,6 +218,7 @@ describe("createSandboxExecutor", () => { beforeEach(() => { sandboxGetMock.mockReset(); sandboxCreateMock.mockReset(); + issueProviderCredentialLeaseMock.mockReset(); vi.mocked(createBashTool).mockReset(); resolveRuntimeDependencySnapshotMock.mockReset(); resolveRuntimeDependencySnapshotMock.mockResolvedValue({ @@ -194,6 +238,10 @@ describe("createSandboxExecutor", () => { delete process.env.EVAL_ENABLE_TEST_CREDENTIALS; }); + afterEach(async () => { + await disconnectStateAdapter(); + }); + it("recreates a sandbox when sandboxId hint points to a stopped sandbox", async () => { const stoppedSandbox = makeSandbox("sbx_stopped", { mkDirError: createApiError( @@ -615,6 +663,159 @@ describe("createSandboxExecutor", () => { ); }); + it("applies credential transforms only while running bash commands", async () => { + const sandbox = makeSandbox("sbx_transform_credentials"); + sandboxGetMock.mockResolvedValue(sandbox); + vi.mocked(createBashTool).mockResolvedValue({ + tools: { + readFile: { execute: vi.fn(async () => ({ content: "" })) }, + writeFile: { execute: vi.fn(async () => ({ success: true })) }, + }, + } as never); + issueProviderCredentialLeaseMock.mockResolvedValue({ + id: "lease-1", + provider: "sentry", + env: { SENTRY_AUTH_TOKEN: "host_managed_credential" }, + headerTransforms: [ + { + domain: "sentry.io", + headers: { Authorization: "Bearer sentry-token" }, + }, + ], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }); + + const executor = createSandboxExecutor({ + sandboxId: "sbx_transform_credentials", + credentialEgress: { + requesterId: "U123", + activeProvider: () => "sentry", + }, + }); + executor.configureSkills([]); + + await executor.execute({ + toolName: "bash", + input: { + command: "sentry-cli issues list", + }, + }); + + expect(issueProviderCredentialLeaseMock).toHaveBeenCalledWith({ + provider: "sentry", + requesterId: "U123", + reason: "sandbox-command:sentry", + }); + expect(sandbox.update).toHaveBeenNthCalledWith(1, { + networkPolicy: { allow: { "*": [] } }, + }); + expect(sandbox.update).toHaveBeenNthCalledWith(2, { + networkPolicy: { + allow: { + "*": [], + "sentry.io": [ + { + transform: [ + { headers: { Authorization: "Bearer sentry-token" } }, + ], + }, + ], + }, + }, + }); + expect(sandbox.update).toHaveBeenNthCalledWith(3, { + networkPolicy: { allow: { "*": [] } }, + }); + const invocation = sandbox.runCommand.mock.calls[0]?.[0]; + expect(invocation.args?.[1]).toContain( + "export SENTRY_AUTH_TOKEN='host_managed_credential'", + ); + expect(invocation.args?.[1]).toContain("sentry-cli issues list"); + }); + + it("runs active provider commands without credentials when no credential surface exists", async () => { + const sandbox = makeSandbox("sbx_provider_without_credentials"); + sandboxGetMock.mockResolvedValue(sandbox); + vi.mocked(createBashTool).mockResolvedValue({ + tools: { + readFile: { execute: vi.fn(async () => ({ content: "" })) }, + writeFile: { execute: vi.fn(async () => ({ success: true })) }, + }, + } as never); + + const executor = createSandboxExecutor({ + sandboxId: "sbx_provider_without_credentials", + credentialEgress: { + requesterId: "U123", + activeProvider: () => "docs", + }, + }); + executor.configureSkills([]); + + await executor.execute({ + toolName: "bash", + input: { + command: "echo local-only", + }, + }); + + expect(issueProviderCredentialLeaseMock).not.toHaveBeenCalled(); + expect(sandbox.update).toHaveBeenCalledTimes(1); + expect(sandbox.update).toHaveBeenCalledWith({ + networkPolicy: { allow: { "*": [] } }, + }); + const invocation = sandbox.runCommand.mock.calls[0]?.[0]; + expect(invocation.args?.[1]).not.toContain("SENTRY_AUTH_TOKEN"); + expect(invocation.args?.[1]).toContain("echo local-only"); + }); + + it("returns an auth marker when command credential activation is unavailable", async () => { + const sandbox = makeSandbox("sbx_missing_credentials"); + sandboxGetMock.mockResolvedValue(sandbox); + vi.mocked(createBashTool).mockResolvedValue({ + tools: { + readFile: { execute: vi.fn(async () => ({ content: "" })) }, + writeFile: { execute: vi.fn(async () => ({ success: true })) }, + }, + } as never); + issueProviderCredentialLeaseMock.mockRejectedValue( + new CredentialUnavailableError( + "sentry", + "No sentry credentials available.", + ), + ); + + const executor = createSandboxExecutor({ + sandboxId: "sbx_missing_credentials", + credentialEgress: { + requesterId: "U123", + activeProvider: () => "sentry", + }, + }); + executor.configureSkills([]); + + const response = await executor.execute({ + toolName: "bash", + input: { + command: "sentry-cli issues list", + }, + }); + + expect(response.result).toMatchObject({ + ok: false, + exit_code: 1, + stdout: "", + stderr: expect.stringContaining( + "junior-auth-required provider=sentry 401 unauthorized", + ), + }); + expect(sandbox.update).toHaveBeenCalledTimes(1); + expect(sandbox.update).toHaveBeenCalledWith({ + networkPolicy: { allow: { "*": [] } }, + }); + expect(sandbox.runCommand).not.toHaveBeenCalled(); + }); + it("clears sandbox command hooks when command env resolution fails", async () => { const sandbox = makeSandbox("sbx_command_env_failure"); sandboxGetMock.mockResolvedValue(sandbox);