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
33 changes: 33 additions & 0 deletions packages/control-plane/src/sandbox/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,37 @@ describe("ModalClient", () => {
"https://acme-prod-web--open-inspect-api-health.modal.run"
);
});

it("routes the restore session_config through buildSessionConfig (carries mcp_servers)", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ success: true, data: { sandbox_id: "sb-1" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);

const client = createModalClient("secret", "acme", "prod-web");
await client.restoreSandbox({
snapshotImageId: "img-1",
sessionId: "session-123",
sandboxId: "sandbox-456",
sandboxAuthToken: "auth-token",
controlPlaneUrl: "https://control-plane.test",
repoOwner: "testowner",
repoName: "testrepo",
provider: "anthropic",
model: "anthropic/claude-sonnet-4-5",
mcpServers: [{ id: "mcp-1", name: "Tool", type: "local", enabled: true }],
});

const body = JSON.parse((fetchMock.mock.calls[0]?.[1] as RequestInit).body as string);
expect(body.session_config).toEqual({
session_id: "session-123",
repo_owner: "testowner",
repo_name: "testrepo",
provider: "anthropic",
model: "anthropic/claude-sonnet-4-5",
mcp_servers: [{ id: "mcp-1", name: "Tool", type: "local", enabled: true }],
});
});
});
11 changes: 2 additions & 9 deletions packages/control-plane/src/sandbox/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { generateInternalToken, type SandboxSettings } from "@open-inspect/share
import type { McpServerConfig } from "@open-inspect/shared";
import { createLogger } from "../logger";
import type { CorrelationContext } from "../logger";
import { buildSessionConfig } from "./sandbox-env";

const log = createLogger("modal-client");

Expand Down Expand Up @@ -332,15 +333,7 @@ export class ModalClient {
headers,
body: JSON.stringify({
snapshot_image_id: request.snapshotImageId,
session_config: {
session_id: request.sessionId,
repo_owner: request.repoOwner,
repo_name: request.repoName,
provider: request.provider,
model: request.model,
branch: request.branch || null,
mcp_servers: request.mcpServers || null,
},
session_config: buildSessionConfig(request),
sandbox_id: request.sandboxId,
control_plane_url: request.controlPlaneUrl,
sandbox_auth_token: request.sandboxAuthToken,
Expand Down
13 changes: 2 additions & 11 deletions packages/control-plane/src/sandbox/providers/daytona-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createLogger } from "../../logger";
import type { SourceControlProviderName } from "../../source-control";
import type { DaytonaRestClient, DaytonaCreateSandboxParams } from "../daytona-rest-client";
import { DaytonaApiError, DaytonaNotFoundError } from "../daytona-rest-client";
import { buildSessionConfig } from "../sandbox-env";
import {
SandboxProviderError,
type CreateSandboxConfig,
Expand Down Expand Up @@ -194,17 +195,7 @@ export class DaytonaSandboxProvider implements SandboxProvider {
// Start with user env vars (repo secrets), then overlay system vars
const envVars: Record<string, string> = { ...(config.userEnvVars ?? {}) };

const sessionConfig: Record<string, unknown> = {
session_id: config.sessionId,
repo_owner: config.repoOwner,
repo_name: config.repoName,
provider: config.provider,
model: config.model,
mcp_servers: config.mcpServers,
};
if (config.branch) {
sessionConfig.branch = config.branch;
}
const sessionConfig = buildSessionConfig(config);

Object.assign(envVars, {
PYTHONUNBUFFERED: "1",
Expand Down
11 changes: 2 additions & 9 deletions packages/control-plane/src/sandbox/providers/vercel/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { computeHmacHex, MAX_TUNNEL_PORTS, type SandboxSettings } from "@open-in
import { createLogger } from "../../../logger";
import type { CorrelationContext } from "../../../logger";
import type { SourceControlProviderName } from "../../../source-control";
import { buildSessionConfig } from "../../sandbox-env";
import {
DEFAULT_SANDBOX_TIMEOUT_SECONDS,
SandboxProviderError,
Expand Down Expand Up @@ -308,15 +309,7 @@ export class VercelSandboxProvider implements SandboxProvider {
}
): Promise<Record<string, string>> {
const envVars: Record<string, string> = { ...(config.userEnvVars ?? {}) };
const sessionConfig: Record<string, unknown> = {
session_id: config.sessionId,
repo_owner: config.repoOwner,
repo_name: config.repoName,
provider: config.provider,
model: config.model,
mcp_servers: config.mcpServers,
};
if (config.branch) sessionConfig.branch = config.branch;
const sessionConfig = buildSessionConfig(config);

Object.assign(envVars, {
HOME: "/root",
Expand Down
45 changes: 45 additions & 0 deletions packages/control-plane/src/sandbox/sandbox-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { buildSessionConfig } from "./sandbox-env";

const baseInput = {
sessionId: "session-123",
repoOwner: "testowner",
repoName: "testrepo",
provider: "anthropic",
model: "anthropic/claude-sonnet-4-5",
};

describe("buildSessionConfig", () => {
it("maps provider inputs to the snake_case runtime contract", () => {
const mcpServers = [{ id: "mcp-1", name: "Tool", type: "local" as const, enabled: true }];

expect(buildSessionConfig({ ...baseInput, branch: "feature/x", mcpServers })).toEqual({
session_id: "session-123",
repo_owner: "testowner",
repo_name: "testrepo",
provider: "anthropic",
model: "anthropic/claude-sonnet-4-5",
mcp_servers: mcpServers,
branch: "feature/x",
});
});

it("omits branch when not provided", () => {
expect(buildSessionConfig(baseInput)).not.toHaveProperty("branch");
});

it("serializes to a SESSION_CONFIG that omits undefined mcp_servers", () => {
// With no MCP servers configured, the key must not appear in the serialized
// payload — the runtime treats an absent key and an empty list identically.
const parsed = JSON.parse(JSON.stringify(buildSessionConfig(baseInput)));

expect(parsed).toEqual({
session_id: "session-123",
repo_owner: "testowner",
repo_name: "testrepo",
provider: "anthropic",
model: "anthropic/claude-sonnet-4-5",
});
expect(parsed).not.toHaveProperty("mcp_servers");
});
});
63 changes: 63 additions & 0 deletions packages/control-plane/src/sandbox/sandbox-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { McpServerConfig } from "@open-inspect/shared";

/**
* Shared assembly for the sandbox environment contract.
*
* The runtime decodes the `SESSION_CONFIG` env var into a single canonical
* shape (see the Python `SessionConfig` in
* `packages/sandbox-runtime/src/sandbox_runtime/types.py`). Every provider used
* to hand-roll that object independently, which let fields silently diverge —
* the Daytona provider dropped `mcp_servers` entirely because its local copy
* never added the key. This module is the single source of truth for the shape
* so providers serialize it instead of reassembling ad-hoc objects.
*
* The runtime reads `session_id`, `branch`, `provider`, `model`, and
* `mcp_servers` from this payload; `repo_owner` / `repo_name` are included to
* mirror the full contract.
*/

/** Canonical `SESSION_CONFIG` payload handed to the sandbox runtime. */
export interface SessionConfigPayload {
session_id: string;
repo_owner: string;
repo_name: string;
provider: string;
model: string;
/** Omitted from the serialized payload when undefined. */
mcp_servers?: McpServerConfig[];
/** Omitted from the serialized payload when undefined. */
branch?: string;
}

/** Provider-agnostic inputs needed to assemble a {@link SessionConfigPayload}. */
export interface SessionConfigInput {
sessionId: string;
repoOwner: string;
repoName: string;
provider: string;
model: string;
mcpServers?: McpServerConfig[];
branch?: string;
}

/**
* Build the canonical `SESSION_CONFIG` payload from provider inputs.
*
* `mcp_servers` is always set (left undefined when absent) so `JSON.stringify`
* omits it — matching how the runtime treats an absent key and an empty list
* identically. `branch` is only set when provided.
*/
export function buildSessionConfig(input: SessionConfigInput): SessionConfigPayload {
Comment thread
ColeMurray marked this conversation as resolved.
const payload: SessionConfigPayload = {
session_id: input.sessionId,
repo_owner: input.repoOwner,
repo_name: input.repoName,
provider: input.provider,
model: input.model,
mcp_servers: input.mcpServers,
};
if (input.branch) {
payload.branch = input.branch;
}
return payload;
}
Loading