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
53 changes: 50 additions & 3 deletions lib/resources/abstraction/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
PodSandboxCreateImageFromFilesystemResponse,
PodSandboxUpdateTtlResponse,
PodSandboxExposePortResponse,
PodSandboxUpdateNetworkPermissionsResponse,
PodSandboxExecResponse,
PodSandboxListFilesResponse,
PodSandboxCreateDirectoryResponse,
Expand Down Expand Up @@ -51,6 +52,10 @@ function shellQuote(arg: string): string {
* for sandboxes that never timeout.
* - volumes (Volume[]): The volumes and/or cloud buckets to mount into the sandbox container.
* - secrets (SecretVar[]): Secrets to pass to the sandbox, e.g. [{ name: "API_KEY" }].
* - blockNetwork (boolean): Whether to block all outbound network access. Cannot be combined with
* `allowList`.
* - allowList (string[]): CIDR ranges that are allowed for outbound network access. When specified,
* all other outbound traffic is blocked.
* - authorized (boolean): Ignored for sandboxes (forced to false).
*/
export class Sandbox extends Pod {
Expand Down Expand Up @@ -373,17 +378,59 @@ export class SandboxInstance extends PodInstance {
}

/**
* List all exposed URLs in the sandbox.
* Dynamically update outbound network permissions for the sandbox.
*
* Parameters:
* - blockNetwork (boolean): If true, block all outbound network access. Defaults to false.
* - allowList (string[]): Optional list of allowed outbound domains/IPs. Cannot be used with blockNetwork=true.
*
* Throws: SandboxConnectionError if the update fails.
*/
public async listUrls(): Promise<string[]> {
public async updateNetworkPermissions(
blockNetwork: boolean = false,
allowList?: string[]
): Promise<void> {
if (blockNetwork && allowList !== undefined) {
throw new Error(
"Cannot specify both 'blockNetwork=true' and 'allowList'. Use 'allowList' with CIDR notation to allow specific ranges, or use 'blockNetwork=true' to block all outbound traffic."
);
}

const resp = await beamClient.request({
method: "POST",
url: `/api/v1/gateway/pods/${this.containerId}/network/update`,
data: {
stubId: this.stubId,
blockNetwork,
allowList: allowList ?? [],
},
});
const data = resp.data as PodSandboxUpdateNetworkPermissionsResponse;
if (!data.ok) {
throw new SandboxConnectionError(
data.errorMsg || "Failed to update network permissions"
);
}
}

/**
* List all exposed URLs in the sandbox, keyed by port.
*/
public async listUrls(): Promise<Record<number, string>> {
const resp = await beamClient.request({
method: "GET",
url: `/api/v1/gateway/pods/${this.containerId}/urls`,
});
const data = resp.data as PodSandboxListUrlsResponse;
if (!data.ok)
throw new SandboxProcessError(data.errorMsg || "Failed to list URLs");
return Object.values(data.urls || {});

const urlsByPort: Record<number, string> = {};
for (const [port, url] of Object.entries(data.urls || {})) {
urlsByPort[Number(port)] = url;
}

return urlsByPort;
}

/**
Expand Down
14 changes: 14 additions & 0 deletions lib/resources/abstraction/stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export interface StubConfig {
inputs?: Schema;
outputs?: Schema;
tcp: boolean;
blockNetwork: boolean;
allowList?: string[];
}

export interface CreateStubConfig extends Partial<StubConfig> {
Expand Down Expand Up @@ -110,6 +112,8 @@ export class StubBuilder {
inputs = undefined,
outputs = undefined,
tcp = false,
blockNetwork = false,
allowList = undefined,
}: CreateStubConfig) {
this.config = {} as StubConfig;
this.config.name = name;
Expand Down Expand Up @@ -137,6 +141,14 @@ export class StubBuilder {
this.config.pricing = pricing;
this.config.inputs = inputs;
this.config.outputs = outputs;
this.config.blockNetwork = blockNetwork;
this.config.allowList = allowList;

if (this.config.blockNetwork && this.config.allowList !== undefined) {
throw new Error(
"Cannot specify both 'blockNetwork=true' and 'allowList'. Use 'allowList' with CIDR notation to allow specific ranges, or use 'blockNetwork=true' to block all outbound traffic."
);
}

// Set GPU count if GPU specified but count is 0
if (
Expand Down Expand Up @@ -340,6 +352,8 @@ export class StubBuilder {
inputs,
outputs,
tcp: this.config.tcp,
blockNetwork: this.config.blockNetwork,
allowList: this.config.allowList,
};

try {
Expand Down
12 changes: 12 additions & 0 deletions lib/types/pod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,18 @@ export interface PodSandboxExposePortResponse {
errorMsg: string;
}

export interface PodSandboxUpdateNetworkPermissionsRequest {
containerId: string;
stubId: string;
blockNetwork: boolean;
allowList: string[];
}

export interface PodSandboxUpdateNetworkPermissionsResponse {
ok: boolean;
errorMsg: string;
}

export interface PodSandboxListUrlsResponse {
ok: boolean;
urls: Record<string, string>;
Expand Down
2 changes: 2 additions & 0 deletions lib/types/stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export interface GetOrCreateStubRequest {
inputs?: Schema;
outputs?: Schema;
tcp: boolean;
blockNetwork: boolean;
allowList?: string[];
}

export interface GetOrCreateStubResponse {
Expand Down
168 changes: 168 additions & 0 deletions tests/sandbox-network.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import beamClient from "../lib";
import { Sandbox, SandboxInstance } from "../lib/resources/abstraction/sandbox";
import { EStubType } from "../lib/types/stub";

describe("Sandbox network parity", () => {
beforeEach(() => {
jest.spyOn(console, "log").mockImplementation(() => undefined);
jest.spyOn(console, "warn").mockImplementation(() => undefined);
jest.spyOn(console, "error").mockImplementation(() => undefined);
});

afterEach(() => {
jest.restoreAllMocks();
});

test("rejects sandbox configs that set both blockNetwork and allowList", () => {
expect(() => {
new Sandbox({
name: "networked-sandbox",
blockNetwork: true,
allowList: ["8.8.8.8/32"],
});
}).toThrow(
"Cannot specify both 'blockNetwork=true' and 'allowList'. Use 'allowList' with CIDR notation to allow specific ranges, or use 'blockNetwork=true' to block all outbound traffic."
);
});

test("includes allowList in stub creation requests", async () => {
const requestMock = jest.spyOn(beamClient, "request").mockResolvedValue({
data: {
ok: true,
stubId: "stub-123",
},
});

const sandbox = new Sandbox({
name: "networked-sandbox",
allowList: ["8.8.8.8/32"],
});

sandbox.stub.imageAvailable = true;
sandbox.stub.filesSynced = true;
sandbox.stub.objectId = "object-123";
sandbox.stub.config.image.id = "image-123";

await expect(
sandbox.stub.prepareRuntime(undefined, EStubType.Sandbox, true, ["*"])
).resolves.toBe(true);

expect(requestMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "POST",
url: "/api/v1/gateway/stubs",
data: expect.objectContaining({
block_network: false,
allow_list: ["8.8.8.8/32"],
}),
})
);
});

test("updates network permissions with the sandbox update endpoint", async () => {
const requestMock = jest.spyOn(beamClient, "request").mockResolvedValue({
data: {
ok: true,
errorMsg: "",
},
});

const instance = new SandboxInstance(
{
containerId: "sandbox-123",
stubId: "stub-123",
url: "",
ok: true,
errorMsg: "",
},
new Sandbox({ name: "networked-sandbox" })
);

await expect(instance.updateNetworkPermissions(true)).resolves.toBeUndefined();

expect(requestMock).toHaveBeenCalledWith({
method: "POST",
url: "/api/v1/gateway/pods/sandbox-123/network/update",
data: {
stubId: "stub-123",
blockNetwork: true,
allowList: [],
},
});
});

test("rejects conflicting network permission updates before making a request", async () => {
const requestMock = jest.spyOn(beamClient, "request");

const instance = new SandboxInstance(
{
containerId: "sandbox-123",
stubId: "stub-123",
url: "",
ok: true,
errorMsg: "",
},
new Sandbox({ name: "networked-sandbox" })
);

await expect(
instance.updateNetworkPermissions(true, ["8.8.8.8/32"])
).rejects.toThrow(
"Cannot specify both 'blockNetwork=true' and 'allowList'. Use 'allowList' with CIDR notation to allow specific ranges, or use 'blockNetwork=true' to block all outbound traffic."
);

expect(requestMock).not.toHaveBeenCalled();
});

test("rejects blockNetwork=true with empty allowList", async () => {
const requestMock = jest.spyOn(beamClient, "request");

const instance = new SandboxInstance(
{
containerId: "sandbox-123",
stubId: "stub-123",
url: "",
ok: true,
errorMsg: "",
},
new Sandbox({ name: "networked-sandbox" })
);

await expect(
instance.updateNetworkPermissions(true, [])
).rejects.toThrow(
"Cannot specify both 'blockNetwork=true' and 'allowList'. Use 'allowList' with CIDR notation to allow specific ranges, or use 'blockNetwork=true' to block all outbound traffic."
);

expect(requestMock).not.toHaveBeenCalled();
});

test("returns exposed URLs keyed by port", async () => {
jest.spyOn(beamClient, "request").mockResolvedValue({
data: {
ok: true,
urls: {
"3000": "https://3000.example.com",
"8080": "https://8080.example.com",
},
errorMsg: "",
},
});

const instance = new SandboxInstance(
{
containerId: "sandbox-123",
stubId: "stub-123",
url: "",
ok: true,
errorMsg: "",
},
new Sandbox({ name: "networked-sandbox" })
);

await expect(instance.listUrls()).resolves.toEqual({
3000: "https://3000.example.com",
8080: "https://8080.example.com",
});
});
});