diff --git a/.changeset/brave-clocks-burn.md b/.changeset/brave-clocks-burn.md new file mode 100644 index 0000000000..712eded4a1 --- /dev/null +++ b/.changeset/brave-clocks-burn.md @@ -0,0 +1,6 @@ +--- +'@e2b/python-sdk': minor +'e2b': minor +--- + +Added ability to secure communication with sandbox envd diff --git a/packages/cli/src/commands/sandbox/list.ts b/packages/cli/src/commands/sandbox/list.ts index 7f12e67e4f..6eea001074 100644 --- a/packages/cli/src/commands/sandbox/list.ts +++ b/packages/cli/src/commands/sandbox/list.ts @@ -82,7 +82,7 @@ export const listCommand = new commander.Command('list') }) export async function listSandboxes(): Promise< - e2b.components['schemas']['RunningSandbox'][] + e2b.components['schemas']['ListedSandbox'][] > { ensureAPIKey() diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index f4ea32fccf..1107f25d3b 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -30,7 +30,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RunningSandbox"][]; + "application/json": components["schemas"]["ListedSandbox"][]; }; }; 400: components["responses"]["400"]; @@ -98,7 +98,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RunningSandbox"]; + "application/json": components["schemas"]["SandboxDetail"]; }; }; 401: components["responses"]["401"]; @@ -502,6 +502,7 @@ export interface paths { "application/json": components["schemas"]["Template"]; }; }; + 400: components["responses"]["400"]; 401: components["responses"]["401"]; 500: components["responses"]["500"]; }; @@ -689,6 +690,54 @@ export interface paths { patch?: never; trace?: never; }; + "/v2/sandboxes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description List all sandboxes */ + get: { + parameters: { + query?: { + /** @description Maximum number of items to return per page */ + limit?: number; + /** @description Metadata query used to filter the sandboxes (e.g. "user=abc&app=prod"). Each key and values must be URL encoded. */ + metadata?: string; + /** @description Cursor to start the list from */ + nextToken?: string; + /** @description Filter sandboxes by one or more states */ + state?: components["schemas"]["SandboxState"][]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned all running sandboxes */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListedSandbox"][]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -752,6 +801,30 @@ export interface components { /** @description Error */ message: string; }; + ListedSandbox: { + /** @description Alias of the template */ + alias?: string; + /** @description Identifier of the client */ + clientID: string; + cpuCount: components["schemas"]["CPUCount"]; + /** + * Format: date-time + * @description Time when the sandbox will expire + */ + endAt: string; + memoryMB: components["schemas"]["MemoryMB"]; + metadata?: components["schemas"]["SandboxMetadata"]; + /** @description Identifier of the sandbox */ + sandboxID: string; + /** + * Format: date-time + * @description Time when the sandbox was started + */ + startedAt: string; + state: components["schemas"]["SandboxState"]; + /** @description Identifier of the template from which is the sandbox created */ + templateID: string; + }; /** * Format: int32 * @description Memory for the sandbox in MB @@ -769,6 +842,8 @@ export interface components { autoPause: boolean; envVars?: components["schemas"]["EnvVars"]; metadata?: components["schemas"]["SandboxMetadata"]; + /** @description Secure all system communication with sandbox */ + secure?: boolean; /** @description Identifier of the required template */ templateID: string; /** @@ -811,6 +886,8 @@ export interface components { */ sandboxStartingCount: number; status: components["schemas"]["NodeStatus"]; + /** @description Version of the orchestrator */ + version: string; }; NodeDetail: { /** @description List of cached builds id on the node */ @@ -823,8 +900,10 @@ export interface components { /** @description Identifier of the node */ nodeID: string; /** @description List of sandboxes running on the node */ - sandboxes: components["schemas"]["RunningSandbox"][]; + sandboxes: components["schemas"]["ListedSandbox"][]; status: components["schemas"]["NodeStatus"]; + /** @description Version of the orchestrator */ + version: string; }; /** * @description Status of the node @@ -847,7 +926,7 @@ export interface components { */ timeout: number; }; - RunningSandbox: { + RunningSandboxWithMetrics: { /** @description Alias of the template */ alias?: string; /** @description Identifier of the client */ @@ -860,6 +939,7 @@ export interface components { endAt: string; memoryMB: components["schemas"]["MemoryMB"]; metadata?: components["schemas"]["SandboxMetadata"]; + metrics?: components["schemas"]["SandboxMetric"][]; /** @description Identifier of the sandbox */ sandboxID: string; /** @@ -870,7 +950,21 @@ export interface components { /** @description Identifier of the template from which is the sandbox created */ templateID: string; }; - RunningSandboxWithMetrics: { + Sandbox: { + /** @description Alias of the template */ + alias?: string; + /** @description Identifier of the client */ + clientID: string; + /** @description Access token used for envd communication */ + envdAccessToken?: string; + /** @description Version of the envd running in the sandbox */ + envdVersion: string; + /** @description Identifier of the sandbox */ + sandboxID: string; + /** @description Identifier of the template from which is the sandbox created */ + templateID: string; + }; + SandboxDetail: { /** @description Alias of the template */ alias?: string; /** @description Identifier of the client */ @@ -881,9 +975,12 @@ export interface components { * @description Time when the sandbox will expire */ endAt: string; + /** @description Access token used for envd communication */ + envdAccessToken?: string; + /** @description Version of the envd running in the sandbox */ + envdVersion?: string; memoryMB: components["schemas"]["MemoryMB"]; metadata?: components["schemas"]["SandboxMetadata"]; - metrics?: components["schemas"]["SandboxMetric"][]; /** @description Identifier of the sandbox */ sandboxID: string; /** @@ -891,18 +988,7 @@ export interface components { * @description Time when the sandbox was started */ startedAt: string; - /** @description Identifier of the template from which is the sandbox created */ - templateID: string; - }; - Sandbox: { - /** @description Alias of the template */ - alias?: string; - /** @description Identifier of the client */ - clientID: string; - /** @description Version of the envd running in the sandbox */ - envdVersion: string; - /** @description Identifier of the sandbox */ - sandboxID: string; + state: components["schemas"]["SandboxState"]; /** @description Identifier of the template from which is the sandbox created */ templateID: string; }; @@ -951,6 +1037,11 @@ export interface components { */ timestamp: string; }; + /** + * @description State of the sandbox + * @enum {string} + */ + SandboxState: "running" | "paused"; Team: { /** @description API key for the team */ apiKey: string; diff --git a/packages/js-sdk/src/envd/api.ts b/packages/js-sdk/src/envd/api.ts index 7f55bf7ebc..bb7936cd02 100644 --- a/packages/js-sdk/src/envd/api.ts +++ b/packages/js-sdk/src/envd/api.ts @@ -98,13 +98,15 @@ class EnvdApiClient { readonly version: string | undefined constructor( - config: Pick, + config: Pick & { fetch?: (request: Request) => ReturnType, headers?: Record }, metadata: { version?: string - } + }, ) { this.api = createClient({ baseUrl: config.apiUrl, + fetch: config?.fetch, + headers: config?.headers, // keepalive: true, // TODO: Return keepalive }) this.version = metadata.version diff --git a/packages/js-sdk/src/envd/schema.gen.ts b/packages/js-sdk/src/envd/schema.gen.ts index 8a256f5a60..8202121141 100644 --- a/packages/js-sdk/src/envd/schema.gen.ts +++ b/packages/js-sdk/src/envd/schema.gen.ts @@ -4,6 +4,42 @@ */ export interface paths { + "/envs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the environment variables */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Environment variables */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EnvVars"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/files": { parameters: { query?: never; @@ -17,6 +53,10 @@ export interface paths { query: { /** @description Path to the file, URL encoded. Can be relative to user's home directory. */ path?: components["parameters"]["FilePath"]; + /** @description Signature used for file access permission verification. */ + signature?: components["parameters"]["Signature"]; + /** @description Signature expiration used for defining the expiration time of the signature. */ + signature_expiration?: components["parameters"]["SignatureExpiration"]; /** @description User used for setting the owner, or resolving relative paths. */ username: components["parameters"]["User"]; }; @@ -40,6 +80,10 @@ export interface paths { query: { /** @description Path to the file, URL encoded. Can be relative to user's home directory. */ path?: components["parameters"]["FilePath"]; + /** @description Signature used for file access permission verification. */ + signature?: components["parameters"]["Signature"]; + /** @description Signature expiration used for defining the expiration time of the signature. */ + signature_expiration?: components["parameters"]["SignatureExpiration"]; /** @description User used for setting the owner, or resolving relative paths. */ username: components["parameters"]["User"]; }; @@ -105,7 +149,7 @@ export interface paths { }; get?: never; put?: never; - /** Set env vars, ensure the time and metadata is synced with the host */ + /** Set initial vars, ensure the time and metadata is synced with the host */ post: { parameters: { query?: never; @@ -116,6 +160,8 @@ export interface paths { requestBody?: { content: { "application/json": { + /** @description Access token for secure access to envd service */ + accessToken?: string; envVars?: components["schemas"]["EnvVars"]; }; }; @@ -136,6 +182,42 @@ export interface paths { patch?: never; trace?: never; }; + "/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the stats of the service */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The resource usage metrics of the service */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Metrics"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -161,6 +243,16 @@ export interface components { /** @description Error message */ message: string; }; + /** @description Resource usage metrics */ + Metrics: { + /** + * Format: float + * @description CPU usage percentage + */ + cpu_used_pct?: number; + /** @description Total virtual memory usage in bytes */ + mem_bytes?: number; + }; }; responses: { /** @description Entire file downloaded successfully. */ @@ -230,6 +322,10 @@ export interface components { parameters: { /** @description Path to the file, URL encoded. Can be relative to user's home directory. */ FilePath: string; + /** @description Signature used for file access permission verification. */ + Signature: string; + /** @description Signature expiration used for defining the expiration time of the signature. */ + SignatureExpiration: number; /** @description User used for setting the owner, or resolving relative paths. */ User: string; }; diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 17e6e59d8b..7aa151a7c7 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -14,6 +14,8 @@ export { } from './errors' export type { Logger } from './logs' +export { getSignature } from './sandbox/signature' + export { FileType } from './sandbox/filesystem' export type { EntryInfo, Filesystem } from './sandbox/filesystem' export { FilesystemEventType } from './sandbox/filesystem/watchHandle' diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index 302486a1c4..097891bba6 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -10,6 +10,7 @@ import { createRpcLogger } from '../logs' import { Commands, Pty } from './commands' import { Filesystem } from './filesystem' import { SandboxApi } from './sandboxApi' +import { getSignature } from './signature' /** * Options for creating a new Sandbox. @@ -21,6 +22,7 @@ export interface SandboxOpts extends ConnectionOpts { * @default {} */ metadata?: Record + /** * Custom environment variables for the sandbox. * @@ -30,6 +32,7 @@ export interface SandboxOpts extends ConnectionOpts { * @default {} */ envs?: Record + /** * Timeout for the sandbox in **milliseconds**. * Maximum time a sandbox can be kept alive is 24 hours (86_400_000 milliseconds) for Pro users and 1 hour (3_600_000 milliseconds) for Hobby users. @@ -37,6 +40,32 @@ export interface SandboxOpts extends ConnectionOpts { * @default 300_000 // 5 minutes */ timeoutMs?: number + + /** + * Secure all traffic coming to the sandbox controller with auth token + * + * @default false + */ + secure?: boolean +} + +/** + * Options for sandbox upload/download URL generation. + */ +export interface SandboxUrlOpts { + /** + * Use signature for the URL. + * This needs to be used in case of using secured envd in sandbox. + * + * @default false + */ + useSignature?: true, + + /** + * Use signature expiration for the URL. + * Optional parameter to set the expiration time for the signature. + */ + useSignatureExpiration?: number } /** @@ -86,6 +115,7 @@ export class Sandbox extends SandboxApi { protected readonly connectionConfig: ConnectionConfig private readonly envdApiUrl: string + private readonly envdAccessToken?: string private readonly envdApi: EnvdApiClient /** @@ -100,15 +130,16 @@ export class Sandbox extends SandboxApi { opts: Omit & { sandboxId: string envdVersion?: string + envdAccessToken?: string } ) { super() this.sandboxId = opts.sandboxId this.connectionConfig = new ConnectionConfig(opts) - this.envdApiUrl = `${ - this.connectionConfig.debug ? 'http' : 'https' - }://${this.getHost(this.envdPort)}` + + this.envdAccessToken = opts.envdAccessToken + this.envdApiUrl = `${this.connectionConfig.debug ? 'http' : 'https'}://${this.getHost(this.envdPort)}` const rpcTransport = createConnectTransport({ baseUrl: this.envdApiUrl, @@ -119,10 +150,18 @@ export class Sandbox extends SandboxApi { // connect-web doesn't allow to configure redirect option - https://github.com/connectrpc/connect-es/pull/1082 // connect-web package uses redirect: "error" which is not supported in edge runtimes // E2B endpoints should be safe to use with redirect: "follow" https://github.com/e2b-dev/E2B/issues/531#issuecomment-2779492867 + + const headers = new Headers(options?.headers) + if (this.envdAccessToken) { + headers.append('X-Access-Token', this.envdAccessToken) + } + options = { ...(options ?? {}), + headers: headers, redirect: 'follow', } + return fetch(url, options) }, }) @@ -131,6 +170,8 @@ export class Sandbox extends SandboxApi { { apiUrl: this.envdApiUrl, logger: opts?.logger, + accessToken: this.envdAccessToken, + headers: this.envdAccessToken ? { 'X-Access-Token': this.envdAccessToken } : { }, }, { version: opts?.envdVersion, @@ -193,7 +234,6 @@ export class Sandbox extends SandboxApi { : { template: this.defaultTemplate, sandboxOpts: templateOrOpts } const config = new ConnectionConfig(sandboxOpts) - if (config.debug) { return new this({ sandboxId: 'debug_sandbox_id', @@ -233,9 +273,11 @@ export class Sandbox extends SandboxApi { opts?: Omit ): Promise> { const config = new ConnectionConfig(opts) + const info = await this.getInfo(sandboxId, opts) - const sbx = new this({ sandboxId, ...config }) as InstanceType - return sbx + return new this( + { sandboxId, envdAccessToken: info.envdAccessToken, envdVersion: info.envdVersion, ...config } + ) as InstanceType } /** @@ -342,35 +384,83 @@ export class Sandbox extends SandboxApi { * * You have to send a POST request to this URL with the file as multipart/form-data. * - * @param path the directory where to upload the file, defaults to user's home directory. + * @param path path to the file in the sandbox. + * + * @param opts download url options. * * @returns URL for uploading file. */ - uploadUrl(path?: string) { - return this.fileUrl(path) + uploadUrl(path?: string, opts?: SandboxUrlOpts) { + opts = opts ?? {} + + if (!this.envdAccessToken && (opts.useSignature || opts.useSignatureExpiration != undefined)) { + throw new Error('Signature can be used only when sandbox is spawned with secure option.') + } + + if (!opts.useSignature && opts.useSignatureExpiration != undefined) { + throw new Error('Signature expiration can be used only when signature is set to true.') + } + + const filePath = path ?? '' + const fileUrl = this.fileUrl(filePath, defaultUsername) + + if (opts.useSignature) { + const url = new URL(fileUrl) + const sig = getSignature( + { path: filePath, operation: 'write', user: defaultUsername, expirationInSeconds: opts.useSignatureExpiration, envdAccessToken: this.envdAccessToken} + ) + + url.searchParams.set('signature', sig.signature) + if (sig.expiration) { + url.searchParams.set('signature_expiration', sig.expiration.toString()) + } + + return url.toString() + } + + return fileUrl } /** * Get the URL to download a file from the sandbox. * - * @param path path to the file to download. + * @param path path to the file in the sandbox. + * + * @param opts download url options. * * @returns URL for downloading file. */ - downloadUrl(path: string) { - return this.fileUrl(path) - } + downloadUrl(path: string, opts?: SandboxUrlOpts) { //path: string, useSignature?: boolean, signatureExpirationInSeconds?: number) { + opts = opts ?? {} - private fileUrl(path?: string) { - const url = new URL('/files', this.envdApiUrl) - url.searchParams.set('username', defaultUsername) - if (path) { - url.searchParams.set('path', path) + if (!this.envdAccessToken && (opts.useSignature || opts.useSignatureExpiration != undefined)) { + throw new Error('Signature can be used only when sandbox is spawned with secure option.') } - return url.toString() + if (!opts.useSignature && opts.useSignatureExpiration != undefined) { + throw new Error('Signature expiration can be used only when signature is set to true.') + } + + const fileUrl = this.fileUrl(path, defaultUsername) + + if (opts.useSignature) { + const url = new URL(fileUrl) + const sig = getSignature( + { path, operation: 'read', user: defaultUsername, expirationInSeconds: opts.useSignatureExpiration, envdAccessToken: this.envdAccessToken} + ) + + url.searchParams.set('signature', sig.signature) + if (sig.expiration) { + url.searchParams.set('signature_expiration', sig.expiration.toString()) + } + + return url.toString() + } + + return fileUrl } + /** * Get sandbox information like sandbox ID, template, metadata, started at/end at date. * @@ -384,4 +474,16 @@ export class Sandbox extends SandboxApi { ...opts, }) } + + + private fileUrl(path?: string, username?: string) { + const url = new URL('/files', this.envdApiUrl) + + url.searchParams.set('username', username ?? defaultUsername) + if (path) { + url.searchParams.set('path', path) + } + + return url.toString() + } } diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 96941ebf1a..49c43c3fe6 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -40,6 +40,16 @@ export interface SandboxInfo { */ name?: string + /** + * Envd access token. + */ + envdAccessToken?: string + + /** + * Envd version. + */ + envdVersion?: string + /** * Saved sandbox metadata. */ @@ -56,6 +66,60 @@ export interface SandboxInfo { endAt: Date } +export interface ListedSandbox { + /** + * Sandbox ID. + */ + sandboxId: string + + /** + * Template ID alias. + */ + alias?: string; + + /** + * Template ID. + */ + templateId: string; + + /** + * Client ID. + * @deprecated + */ + clientId: string; + + /** + * Sandbox state. + */ + state: 'running' | 'paused'; + + /** + * Sandbox CPU count. + */ + cpuCount: number; + + /** + * Sandbox Memory size in MB. + */ + memoryMB: number; + + /** + * Saved sandbox metadata. + */ + metadata?: Record + + /** + * Sandbox expected end time. + */ + endAt: Date; + + /** + * Sandbox start time. + */ + startedAt: Date; +} + + export class SandboxApi { protected constructor() {} @@ -102,7 +166,7 @@ export class SandboxApi { * * @returns list of running sandboxes. */ - static async list(opts?: SandboxListOpts): Promise { + static async list(opts?: SandboxListOpts): Promise { const config = new ConnectionConfig(opts) const client = new ApiClient(config) @@ -132,14 +196,15 @@ export class SandboxApi { } return ( - res.data?.map((sandbox: components['schemas']['RunningSandbox']) => ({ - sandboxId: this.getSandboxId({ - sandboxId: sandbox.sandboxID, - clientId: sandbox.clientID, - }), + res.data?.map((sandbox: components['schemas']['ListedSandbox']) => ({ + sandboxId: this.getSandboxId({ sandboxId: sandbox.sandboxID, clientId: sandbox.clientID }), templateId: sandbox.templateID, - ...(sandbox.alias && { name: sandbox.alias }), - metadata: sandbox.metadata ?? {}, + clientId: sandbox.clientID, + state: sandbox.state, + cpuCount: sandbox.cpuCount, + memoryMB: sandbox.memoryMB, + alias: sandbox.alias, + metadata: sandbox.metadata, startedAt: new Date(sandbox.startedAt), endAt: new Date(sandbox.endAt), })) ?? [] @@ -187,6 +252,8 @@ export class SandboxApi { templateId: res.data.templateID, ...(res.data.alias && { name: res.data.alias }), metadata: res.data.metadata ?? {}, + envdVersion: res.data.envdVersion, + envdAccessToken: res.data.envdAccessToken, startedAt: new Date(res.data.startedAt), endAt: new Date(res.data.endAt), } @@ -236,10 +303,12 @@ export class SandboxApi { opts?: SandboxApiOpts & { metadata?: Record envs?: Record + secure?: boolean } ): Promise<{ sandboxId: string envdVersion: string + envdAccessToken?: string }> { const config = new ConnectionConfig(opts) const client = new ApiClient(config) @@ -251,6 +320,7 @@ export class SandboxApi { metadata: opts?.metadata, envVars: opts?.envs, timeout: this.timeoutToSeconds(timeoutMs), + secure: opts?.secure, }, signal: config.getSignal(opts?.requestTimeoutMs), }) @@ -273,12 +343,14 @@ export class SandboxApi { 'You can do this by running `e2b template build` in the directory with the template.' ) } + return { sandboxId: this.getSandboxId({ sandboxId: res.data!.sandboxID, clientId: res.data!.clientID, }), envdVersion: res.data!.envdVersion, + envdAccessToken: res.data!.envdAccessToken } } diff --git a/packages/js-sdk/src/sandbox/signature.ts b/packages/js-sdk/src/sandbox/signature.ts new file mode 100644 index 0000000000..fd187c0bce --- /dev/null +++ b/packages/js-sdk/src/sandbox/signature.ts @@ -0,0 +1,47 @@ +import crypto from 'node:crypto' + +/** + * Get the URL signature for the specified path, operation and user. + * + * @param path Path to the file in the sandbox. + * + * @param operation File system operation. Can be either `read` or `write`. + * + * @param user Sandbox user. + * + * @param expirationInSeconds Optional signature expiration time in seconds. + */ + + +interface SignatureOpts { + path: string + operation: 'read' | 'write' + user: string + expirationInSeconds?: number + envdAccessToken?: string +} + +export function getSignature({ path, operation, user, expirationInSeconds, envdAccessToken }: SignatureOpts): { signature: string; expiration: number | null } { + if (!envdAccessToken) { + throw new Error('Access token is not set and signature cannot be generated!') + } + + // expiration is unix timestamp + const signatureExpiration = expirationInSeconds ? Math.floor(Date.now() / 1000) + expirationInSeconds : null + let signatureRaw: string + + if (signatureExpiration === null) { + signatureRaw = `${path}:${operation}:${user}:${envdAccessToken}` + } else { + signatureRaw = `${path}:${operation}:${user}:${envdAccessToken}:${signatureExpiration.toString()}` + } + + const buff = Buffer.from(signatureRaw, 'utf8') + const hash = crypto.createHash('sha256').update(buff).digest() + const signature = 'v1_' + hash.toString('base64').replace(/=+$/, '') + + return { + signature: signature, + expiration: signatureExpiration + } +} \ No newline at end of file diff --git a/packages/js-sdk/tests/sandbox/connect.test.ts b/packages/js-sdk/tests/sandbox/connect.test.ts index d7eddd802d..cff3c8fef0 100644 --- a/packages/js-sdk/tests/sandbox/connect.test.ts +++ b/packages/js-sdk/tests/sandbox/connect.test.ts @@ -1,4 +1,4 @@ -import { assert, test } from 'vitest' +import { assert, test, expect } from 'vitest' import { Sandbox } from '../../src' import { isDebug, sandboxTest, template } from '../setup.js' @@ -27,8 +27,9 @@ sandboxTest.skipIf(isDebug)( assert.isTrue(isRunning) await sandbox.kill() - const sbxConnection = await Sandbox.connect(sandbox.sandboxId) - const isRunning2 = await sbxConnection.isRunning() - assert.isFalse(isRunning2) + const connectPromise = Sandbox.connect(sandbox.sandboxId) + await expect(connectPromise).rejects.toThrowError( + expect.objectContaining({ message: `404: sandbox "${sandbox.sandboxId}" doesn't exist or you don't have access to it` }) + ) } ) diff --git a/packages/js-sdk/tests/sandbox/files/signing.test.ts b/packages/js-sdk/tests/sandbox/files/signing.test.ts new file mode 100644 index 0000000000..24fd2c183f --- /dev/null +++ b/packages/js-sdk/tests/sandbox/files/signing.test.ts @@ -0,0 +1,97 @@ +import {assert, test} from 'vitest' +import {Sandbox} from '../../../src' +import {template} from '../../setup' + +const timeout = 20 * 1000 + +test('test access file with expired signing', async () => { + const sbx = await Sandbox.create(template, { timeoutMs: timeout, secure: true }) + await sbx.files.write('hello.txt', 'hello world') + + const fileUrlWithSigning = sbx.downloadUrl('hello.txt', { useSignature: true, useSignatureExpiration: -10_000 }) + + const res = await fetch(fileUrlWithSigning) + const resBody = await res.text() + const resStatus = res.status + + assert.equal(resStatus, 401) + assert.deepEqual(JSON.parse(resBody), {code: 401, message: 'signature is already expired'}) + + await sbx.kill() +}) + +test('test access file with valid signing', async () => { + const sbx = await Sandbox.create(template, { timeoutMs: timeout, secure: true }) + await sbx.files.write('hello.txt', 'hello world') + + const fileUrlWithSigning = sbx.downloadUrl('hello.txt', { useSignature: true, useSignatureExpiration: 10_000 }) + + const res = await fetch(fileUrlWithSigning) + const resBody = await res.text() + const resStatus = res.status + + assert.equal(resStatus, 200) + assert.equal(resBody, 'hello world') + + await sbx.kill() +}) + +test('test upload file with valid signing', async () => { + const sbx = await Sandbox.create(template, { timeoutMs: timeout, secure: true }) + const fileUrlWithSigning = sbx.uploadUrl('hello.txt', { useSignature: true, useSignatureExpiration: 10_000 }) + + const form = new FormData() + form.append('file', 'file content') + + const res = await fetch(fileUrlWithSigning, { method: 'POST', body: form }) + const resBody = await res.text() + const resStatus = res.status + + assert.equal(resStatus, 200) + assert.deepEqual(JSON.parse(resBody), [{name: 'hello.txt', path: '/home/user/hello.txt', type: 'file'}]) + + await sbx.kill() +}) + +test('test upload file with invalid signing', async () => { + const sbx = await Sandbox.create(template, { timeoutMs: timeout, secure: true }) + const fileUrlWithSigning = sbx.uploadUrl('hello.txt', { useSignature: true, useSignatureExpiration: -10_000 }) + + const form = new FormData() + form.append('file', 'file content') + + const res = await fetch(fileUrlWithSigning, { method: 'POST', body: form }) + const resBody = await res.text() + const resStatus = res.status + + assert.equal(resStatus, 401) + assert.deepEqual(JSON.parse(resBody), {code: 401, message: 'signature is already expired'}) + + await sbx.kill() +}) + +test('test upload file with missing signing', async () => { + const sbx = await Sandbox.create(template, { timeoutMs: timeout, secure: true }) + const fileUrlWithSigning = sbx.uploadUrl('hello.txt') + + const form = new FormData() + form.append('file', 'file content') + + const res = await fetch(fileUrlWithSigning, { method: 'POST', body: form }) + const resBody = await res.text() + const resStatus = res.status + + assert.equal(resStatus, 401) + assert.deepEqual(JSON.parse(resBody), {code: 401, message: 'missing signature query parameter'}) + + await sbx.kill() +}) + +test('test command run with secured sbx', async () => { + const sbx = await Sandbox.create(template, { timeoutMs: timeout, secure: true }) + const response = await sbx.commands.run('echo Hello World!') + + assert.equal(response.stdout, 'Hello World!\n') +}) + + diff --git a/packages/js-sdk/tests/sandbox/secure.test.ts b/packages/js-sdk/tests/sandbox/secure.test.ts new file mode 100644 index 0000000000..f6578aee9c --- /dev/null +++ b/packages/js-sdk/tests/sandbox/secure.test.ts @@ -0,0 +1,104 @@ +import { assert, test } from 'vitest' +import { getSignature, Sandbox } from '../../src' +import {template } from '../setup' +import { randomUUID, createHash } from 'node:crypto' + +const timeout = 20 * 1000 + +test('test access file without signing', async () => { + const sbx = await Sandbox.create(template, { timeoutMs: timeout, secure: true }) + await sbx.files.write('hello.txt', 'hello world') + + const fileUrlWithoutSigning = sbx.downloadUrl('hello.txt') + + const res = await fetch(fileUrlWithoutSigning) + const resBody = await res.text() + const resStatus = res.status + + assert.equal(resStatus, 401) + assert.deepEqual(JSON.parse(resBody), {code: 401, message: 'missing signature query parameter'}) + + await sbx.kill() +}) + +test('test access file with signing', async () => { + const sbx = await Sandbox.create(template, { timeoutMs: timeout, secure: true }) + await sbx.files.write('hello.txt', 'hello world') + + const fileUrlWithSigning = sbx.downloadUrl('hello.txt', { useSignature: true }) + + const res = await fetch(fileUrlWithSigning) + const resBody = await res.text() + const resStatus = res.status + + assert.equal(resStatus, 200) + assert.equal(resBody, 'hello world') + + await sbx.kill() +}) + +test('try to re-connect to sandbox', async () => { + const sbx = await Sandbox.create(template, { timeoutMs: timeout, secure: true }) + const sbxReconnect = await Sandbox.connect(sbx.sandboxId) + + await sbxReconnect.files.write('hello.txt', 'hello world') + await sbxReconnect.kill() +}) + +test('signing generation', async () => { + const operation = 'read' + const path = '/home/user/hello.txt' + const user = 'root' + const envdAccessToken = randomUUID() + + const signatureRaw = `${path}:${operation}:${user}:${envdAccessToken}` + + const buff = Buffer.from(signatureRaw, 'utf8') + const hash = createHash('sha256').update(buff).digest() + const signature = 'v1_' + hash.toString('base64').replace(/=+$/, '') + + const readSignatureExpected = { + signature: signature, + expiration: null + } + + const readSignatureReceived = getSignature({ path, operation, user, envdAccessToken }) + + assert.deepEqual(readSignatureExpected, readSignatureReceived) +}) + +test('signing generation with expiration', async () => { + const operation = 'read' + const path = '/home/user/hello.txt' + const user = 'root' + const envdAccessToken = randomUUID() + const expirationInSeconds = 120 + + const signatureExpiration = expirationInSeconds ? Math.floor(Date.now() / 1000) + expirationInSeconds : null + const signatureRaw = `${path}:${operation}:${user}:${envdAccessToken}:${signatureExpiration.toString()}` + + const buff = Buffer.from(signatureRaw, 'utf8') + const hash = createHash('sha256').update(buff).digest() + const signature = 'v1_' + hash.toString('base64').replace(/=+$/, '') + + const readSignatureExpected = { + signature: signature, + expiration: signatureExpiration + } + + const readSignatureReceived = getSignature({ path, operation, user, envdAccessToken, expirationInSeconds }) + + assert.deepEqual(readSignatureExpected, readSignatureReceived) +}) + + +test('static signing key comparison', async () => { + const operation = 'read' + const path = 'hello.txt' + const user = 'user' + const envdAccessToken = '0tQG31xiMp0IOQfaz9dcwi72L1CPo8e0' + + const signatureReceived = getSignature({ path, operation, user, envdAccessToken }) + + assert.equal('v1_gUtH/s9YCJWgCizjfUxuWfhFE4QSydOWEIIvfLwDr6E', signatureReceived.signature) +}) diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py index 7f21c3da55..614ff25e11 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -22,6 +22,7 @@ class SandboxCreateResponse: sandbox_id: str envd_version: str + envd_access_token: str def handle_api_exception(e: Response): diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes.py b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes.py index ecc6c28147..19829e5ca6 100644 --- a/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes.py +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes.py @@ -6,7 +6,7 @@ from ... import errors from ...client import AuthenticatedClient, Client from ...models.error import Error -from ...models.running_sandbox import RunningSandbox +from ...models.listed_sandbox import ListedSandbox from ...types import UNSET, Response, Unset @@ -31,12 +31,12 @@ def _get_kwargs( def _parse_response( *, client: Union[AuthenticatedClient, Client], response: httpx.Response -) -> Optional[Union[Error, list["RunningSandbox"]]]: +) -> Optional[Union[Error, list["ListedSandbox"]]]: if response.status_code == 200: response_200 = [] _response_200 = response.json() for response_200_item_data in _response_200: - response_200_item = RunningSandbox.from_dict(response_200_item_data) + response_200_item = ListedSandbox.from_dict(response_200_item_data) response_200.append(response_200_item) @@ -61,7 +61,7 @@ def _parse_response( def _build_response( *, client: Union[AuthenticatedClient, Client], response: httpx.Response -) -> Response[Union[Error, list["RunningSandbox"]]]: +) -> Response[Union[Error, list["ListedSandbox"]]]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -74,7 +74,7 @@ def sync_detailed( *, client: AuthenticatedClient, metadata: Union[Unset, str] = UNSET, -) -> Response[Union[Error, list["RunningSandbox"]]]: +) -> Response[Union[Error, list["ListedSandbox"]]]: """List all running sandboxes Args: @@ -85,7 +85,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Union[Error, list['RunningSandbox']]] + Response[Union[Error, list['ListedSandbox']]] """ kwargs = _get_kwargs( @@ -103,7 +103,7 @@ def sync( *, client: AuthenticatedClient, metadata: Union[Unset, str] = UNSET, -) -> Optional[Union[Error, list["RunningSandbox"]]]: +) -> Optional[Union[Error, list["ListedSandbox"]]]: """List all running sandboxes Args: @@ -114,7 +114,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Union[Error, list['RunningSandbox']] + Union[Error, list['ListedSandbox']] """ return sync_detailed( @@ -127,7 +127,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, metadata: Union[Unset, str] = UNSET, -) -> Response[Union[Error, list["RunningSandbox"]]]: +) -> Response[Union[Error, list["ListedSandbox"]]]: """List all running sandboxes Args: @@ -138,7 +138,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Union[Error, list['RunningSandbox']]] + Response[Union[Error, list['ListedSandbox']]] """ kwargs = _get_kwargs( @@ -154,7 +154,7 @@ async def asyncio( *, client: AuthenticatedClient, metadata: Union[Unset, str] = UNSET, -) -> Optional[Union[Error, list["RunningSandbox"]]]: +) -> Optional[Union[Error, list["ListedSandbox"]]]: """List all running sandboxes Args: @@ -165,7 +165,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Union[Error, list['RunningSandbox']] + Union[Error, list['ListedSandbox']] """ return ( diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id.py b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id.py index c824db6981..74f0b27f83 100644 --- a/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id.py +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id.py @@ -6,7 +6,7 @@ from ... import errors from ...client import AuthenticatedClient, Client from ...models.error import Error -from ...models.running_sandbox import RunningSandbox +from ...models.sandbox_detail import SandboxDetail from ...types import Response @@ -23,9 +23,9 @@ def _get_kwargs( def _parse_response( *, client: Union[AuthenticatedClient, Client], response: httpx.Response -) -> Optional[Union[Error, RunningSandbox]]: +) -> Optional[Union[Error, SandboxDetail]]: if response.status_code == 200: - response_200 = RunningSandbox.from_dict(response.json()) + response_200 = SandboxDetail.from_dict(response.json()) return response_200 if response.status_code == 401: @@ -48,7 +48,7 @@ def _parse_response( def _build_response( *, client: Union[AuthenticatedClient, Client], response: httpx.Response -) -> Response[Union[Error, RunningSandbox]]: +) -> Response[Union[Error, SandboxDetail]]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -61,7 +61,7 @@ def sync_detailed( sandbox_id: str, *, client: AuthenticatedClient, -) -> Response[Union[Error, RunningSandbox]]: +) -> Response[Union[Error, SandboxDetail]]: """Get a sandbox by id Args: @@ -72,7 +72,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Union[Error, RunningSandbox]] + Response[Union[Error, SandboxDetail]] """ kwargs = _get_kwargs( @@ -90,7 +90,7 @@ def sync( sandbox_id: str, *, client: AuthenticatedClient, -) -> Optional[Union[Error, RunningSandbox]]: +) -> Optional[Union[Error, SandboxDetail]]: """Get a sandbox by id Args: @@ -101,7 +101,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Union[Error, RunningSandbox] + Union[Error, SandboxDetail] """ return sync_detailed( @@ -114,7 +114,7 @@ async def asyncio_detailed( sandbox_id: str, *, client: AuthenticatedClient, -) -> Response[Union[Error, RunningSandbox]]: +) -> Response[Union[Error, SandboxDetail]]: """Get a sandbox by id Args: @@ -125,7 +125,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Union[Error, RunningSandbox]] + Response[Union[Error, SandboxDetail]] """ kwargs = _get_kwargs( @@ -141,7 +141,7 @@ async def asyncio( sandbox_id: str, *, client: AuthenticatedClient, -) -> Optional[Union[Error, RunningSandbox]]: +) -> Optional[Union[Error, SandboxDetail]]: """Get a sandbox by id Args: @@ -152,7 +152,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Union[Error, RunningSandbox] + Union[Error, SandboxDetail] """ return ( diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/get_v2_sandboxes.py b/packages/python-sdk/e2b/api/client/api/sandboxes/get_v2_sandboxes.py new file mode 100644 index 0000000000..7dd68edf36 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/get_v2_sandboxes.py @@ -0,0 +1,229 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.listed_sandbox import ListedSandbox +from ...models.sandbox_state import SandboxState +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + metadata: Union[Unset, str] = UNSET, + state: Union[Unset, list[SandboxState]] = UNSET, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 1000, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["metadata"] = metadata + + json_state: Union[Unset, list[str]] = UNSET + if not isinstance(state, Unset): + json_state = [] + for state_item_data in state: + state_item = state_item_data.value + json_state.append(state_item) + + params["state"] = json_state + + params["nextToken"] = next_token + + params["limit"] = limit + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/v2/sandboxes", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, list["ListedSandbox"]]]: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = ListedSandbox.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, list["ListedSandbox"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, + state: Union[Unset, list[SandboxState]] = UNSET, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 1000, +) -> Response[Union[Error, list["ListedSandbox"]]]: + """List all sandboxes + + Args: + metadata (Union[Unset, str]): + state (Union[Unset, list[SandboxState]]): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 1000. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['ListedSandbox']]] + """ + + kwargs = _get_kwargs( + metadata=metadata, + state=state, + next_token=next_token, + limit=limit, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, + state: Union[Unset, list[SandboxState]] = UNSET, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 1000, +) -> Optional[Union[Error, list["ListedSandbox"]]]: + """List all sandboxes + + Args: + metadata (Union[Unset, str]): + state (Union[Unset, list[SandboxState]]): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 1000. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['ListedSandbox']] + """ + + return sync_detailed( + client=client, + metadata=metadata, + state=state, + next_token=next_token, + limit=limit, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, + state: Union[Unset, list[SandboxState]] = UNSET, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 1000, +) -> Response[Union[Error, list["ListedSandbox"]]]: + """List all sandboxes + + Args: + metadata (Union[Unset, str]): + state (Union[Unset, list[SandboxState]]): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 1000. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['ListedSandbox']]] + """ + + kwargs = _get_kwargs( + metadata=metadata, + state=state, + next_token=next_token, + limit=limit, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, + state: Union[Unset, list[SandboxState]] = UNSET, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 1000, +) -> Optional[Union[Error, list["ListedSandbox"]]]: + """List all sandboxes + + Args: + metadata (Union[Unset, str]): + state (Union[Unset, list[SandboxState]]): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 1000. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['ListedSandbox']] + """ + + return ( + await asyncio_detailed( + client=client, + metadata=metadata, + state=state, + next_token=next_token, + limit=limit, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/models/__init__.py b/packages/python-sdk/e2b/api/client/models/__init__.py index 1041b2ba3d..1cec9c41ae 100644 --- a/packages/python-sdk/e2b/api/client/models/__init__.py +++ b/packages/python-sdk/e2b/api/client/models/__init__.py @@ -3,6 +3,7 @@ from .created_access_token import CreatedAccessToken from .created_team_api_key import CreatedTeamAPIKey from .error import Error +from .listed_sandbox import ListedSandbox from .new_access_token import NewAccessToken from .new_sandbox import NewSandbox from .new_team_api_key import NewTeamAPIKey @@ -15,12 +16,13 @@ ) from .post_sandboxes_sandbox_id_timeout_body import PostSandboxesSandboxIDTimeoutBody from .resumed_sandbox import ResumedSandbox -from .running_sandbox import RunningSandbox from .running_sandbox_with_metrics import RunningSandboxWithMetrics from .sandbox import Sandbox +from .sandbox_detail import SandboxDetail from .sandbox_log import SandboxLog from .sandbox_logs import SandboxLogs from .sandbox_metric import SandboxMetric +from .sandbox_state import SandboxState from .team import Team from .team_api_key import TeamAPIKey from .team_user import TeamUser @@ -35,6 +37,7 @@ "CreatedAccessToken", "CreatedTeamAPIKey", "Error", + "ListedSandbox", "NewAccessToken", "NewSandbox", "NewTeamAPIKey", @@ -45,12 +48,13 @@ "PostSandboxesSandboxIDRefreshesBody", "PostSandboxesSandboxIDTimeoutBody", "ResumedSandbox", - "RunningSandbox", "RunningSandboxWithMetrics", "Sandbox", + "SandboxDetail", "SandboxLog", "SandboxLogs", "SandboxMetric", + "SandboxState", "Team", "TeamAPIKey", "TeamUser", diff --git a/packages/python-sdk/e2b/api/client/models/running_sandbox.py b/packages/python-sdk/e2b/api/client/models/listed_sandbox.py similarity index 88% rename from packages/python-sdk/e2b/api/client/models/running_sandbox.py rename to packages/python-sdk/e2b/api/client/models/listed_sandbox.py index 6e87cc34f9..d8bbf05e17 100644 --- a/packages/python-sdk/e2b/api/client/models/running_sandbox.py +++ b/packages/python-sdk/e2b/api/client/models/listed_sandbox.py @@ -6,13 +6,14 @@ from attrs import field as _attrs_field from dateutil.parser import isoparse +from ..models.sandbox_state import SandboxState from ..types import UNSET, Unset -T = TypeVar("T", bound="RunningSandbox") +T = TypeVar("T", bound="ListedSandbox") @_attrs_define -class RunningSandbox: +class ListedSandbox: """ Attributes: client_id (str): Identifier of the client @@ -21,6 +22,7 @@ class RunningSandbox: memory_mb (int): Memory for the sandbox in MB sandbox_id (str): Identifier of the sandbox started_at (datetime.datetime): Time when the sandbox was started + state (SandboxState): State of the sandbox template_id (str): Identifier of the template from which is the sandbox created alias (Union[Unset, str]): Alias of the template metadata (Union[Unset, Any]): @@ -32,6 +34,7 @@ class RunningSandbox: memory_mb: int sandbox_id: str started_at: datetime.datetime + state: SandboxState template_id: str alias: Union[Unset, str] = UNSET metadata: Union[Unset, Any] = UNSET @@ -50,6 +53,8 @@ def to_dict(self) -> dict[str, Any]: started_at = self.started_at.isoformat() + state = self.state.value + template_id = self.template_id alias = self.alias @@ -66,6 +71,7 @@ def to_dict(self) -> dict[str, Any]: "memoryMB": memory_mb, "sandboxID": sandbox_id, "startedAt": started_at, + "state": state, "templateID": template_id, } ) @@ -91,26 +97,29 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: started_at = isoparse(d.pop("startedAt")) + state = SandboxState(d.pop("state")) + template_id = d.pop("templateID") alias = d.pop("alias", UNSET) metadata = d.pop("metadata", UNSET) - running_sandbox = cls( + listed_sandbox = cls( client_id=client_id, cpu_count=cpu_count, end_at=end_at, memory_mb=memory_mb, sandbox_id=sandbox_id, started_at=started_at, + state=state, template_id=template_id, alias=alias, metadata=metadata, ) - running_sandbox.additional_properties = d - return running_sandbox + listed_sandbox.additional_properties = d + return listed_sandbox @property def additional_keys(self) -> list[str]: diff --git a/packages/python-sdk/e2b/api/client/models/new_sandbox.py b/packages/python-sdk/e2b/api/client/models/new_sandbox.py index e4f1e22e77..7c59167bda 100644 --- a/packages/python-sdk/e2b/api/client/models/new_sandbox.py +++ b/packages/python-sdk/e2b/api/client/models/new_sandbox.py @@ -17,6 +17,7 @@ class NewSandbox: auto_pause (Union[Unset, bool]): Automatically pauses the sandbox after the timeout Default: False. env_vars (Union[Unset, Any]): metadata (Union[Unset, Any]): + secure (Union[Unset, bool]): Secure all system communication with sandbox timeout (Union[Unset, int]): Time to live for the sandbox in seconds. Default: 15. """ @@ -24,6 +25,7 @@ class NewSandbox: auto_pause: Union[Unset, bool] = False env_vars: Union[Unset, Any] = UNSET metadata: Union[Unset, Any] = UNSET + secure: Union[Unset, bool] = UNSET timeout: Union[Unset, int] = 15 additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) @@ -36,6 +38,8 @@ def to_dict(self) -> dict[str, Any]: metadata = self.metadata + secure = self.secure + timeout = self.timeout field_dict: dict[str, Any] = {} @@ -51,6 +55,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["envVars"] = env_vars if metadata is not UNSET: field_dict["metadata"] = metadata + if secure is not UNSET: + field_dict["secure"] = secure if timeout is not UNSET: field_dict["timeout"] = timeout @@ -67,6 +73,8 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: metadata = d.pop("metadata", UNSET) + secure = d.pop("secure", UNSET) + timeout = d.pop("timeout", UNSET) new_sandbox = cls( @@ -74,6 +82,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: auto_pause=auto_pause, env_vars=env_vars, metadata=metadata, + secure=secure, timeout=timeout, ) diff --git a/packages/python-sdk/e2b/api/client/models/node.py b/packages/python-sdk/e2b/api/client/models/node.py index a0450c815c..3e18f67d35 100644 --- a/packages/python-sdk/e2b/api/client/models/node.py +++ b/packages/python-sdk/e2b/api/client/models/node.py @@ -20,6 +20,7 @@ class Node: sandbox_count (int): Number of sandboxes running on the node sandbox_starting_count (int): Number of starting Sandboxes status (NodeStatus): Status of the node + version (str): Version of the orchestrator """ allocated_cpu: int @@ -29,6 +30,7 @@ class Node: sandbox_count: int sandbox_starting_count: int status: NodeStatus + version: str additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -46,6 +48,8 @@ def to_dict(self) -> dict[str, Any]: status = self.status.value + version = self.version + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -57,6 +61,7 @@ def to_dict(self) -> dict[str, Any]: "sandboxCount": sandbox_count, "sandboxStartingCount": sandbox_starting_count, "status": status, + "version": version, } ) @@ -79,6 +84,8 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: status = NodeStatus(d.pop("status")) + version = d.pop("version") + node = cls( allocated_cpu=allocated_cpu, allocated_memory_mi_b=allocated_memory_mi_b, @@ -87,6 +94,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: sandbox_count=sandbox_count, sandbox_starting_count=sandbox_starting_count, status=status, + version=version, ) node.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/node_detail.py b/packages/python-sdk/e2b/api/client/models/node_detail.py index cf798042b2..6f523d37f3 100644 --- a/packages/python-sdk/e2b/api/client/models/node_detail.py +++ b/packages/python-sdk/e2b/api/client/models/node_detail.py @@ -7,7 +7,7 @@ from ..models.node_status import NodeStatus if TYPE_CHECKING: - from ..models.running_sandbox import RunningSandbox + from ..models.listed_sandbox import ListedSandbox T = TypeVar("T", bound="NodeDetail") @@ -20,15 +20,17 @@ class NodeDetail: cached_builds (list[str]): List of cached builds id on the node create_fails (int): Number of sandbox create fails node_id (str): Identifier of the node - sandboxes (list['RunningSandbox']): List of sandboxes running on the node + sandboxes (list['ListedSandbox']): List of sandboxes running on the node status (NodeStatus): Status of the node + version (str): Version of the orchestrator """ cached_builds: list[str] create_fails: int node_id: str - sandboxes: list["RunningSandbox"] + sandboxes: list["ListedSandbox"] status: NodeStatus + version: str additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -45,6 +47,8 @@ def to_dict(self) -> dict[str, Any]: status = self.status.value + version = self.version + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -54,6 +58,7 @@ def to_dict(self) -> dict[str, Any]: "nodeID": node_id, "sandboxes": sandboxes, "status": status, + "version": version, } ) @@ -61,7 +66,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.running_sandbox import RunningSandbox + from ..models.listed_sandbox import ListedSandbox d = dict(src_dict) cached_builds = cast(list[str], d.pop("cachedBuilds")) @@ -73,18 +78,21 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: sandboxes = [] _sandboxes = d.pop("sandboxes") for sandboxes_item_data in _sandboxes: - sandboxes_item = RunningSandbox.from_dict(sandboxes_item_data) + sandboxes_item = ListedSandbox.from_dict(sandboxes_item_data) sandboxes.append(sandboxes_item) status = NodeStatus(d.pop("status")) + version = d.pop("version") + node_detail = cls( cached_builds=cached_builds, create_fails=create_fails, node_id=node_id, sandboxes=sandboxes, status=status, + version=version, ) node_detail.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/sandbox.py b/packages/python-sdk/e2b/api/client/models/sandbox.py index 12ed6341ee..b3b7caa877 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox.py @@ -18,6 +18,7 @@ class Sandbox: sandbox_id (str): Identifier of the sandbox template_id (str): Identifier of the template from which is the sandbox created alias (Union[Unset, str]): Alias of the template + envd_access_token (Union[Unset, str]): Access token used for envd communication """ client_id: str @@ -25,6 +26,7 @@ class Sandbox: sandbox_id: str template_id: str alias: Union[Unset, str] = UNSET + envd_access_token: Union[Unset, str] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -38,6 +40,8 @@ def to_dict(self) -> dict[str, Any]: alias = self.alias + envd_access_token = self.envd_access_token + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -50,6 +54,8 @@ def to_dict(self) -> dict[str, Any]: ) if alias is not UNSET: field_dict["alias"] = alias + if envd_access_token is not UNSET: + field_dict["envdAccessToken"] = envd_access_token return field_dict @@ -66,12 +72,15 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: alias = d.pop("alias", UNSET) + envd_access_token = d.pop("envdAccessToken", UNSET) + sandbox = cls( client_id=client_id, envd_version=envd_version, sandbox_id=sandbox_id, template_id=template_id, alias=alias, + envd_access_token=envd_access_token, ) sandbox.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_detail.py b/packages/python-sdk/e2b/api/client/models/sandbox_detail.py new file mode 100644 index 0000000000..dacfda1504 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_detail.py @@ -0,0 +1,156 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..models.sandbox_state import SandboxState +from ..types import UNSET, Unset + +T = TypeVar("T", bound="SandboxDetail") + + +@_attrs_define +class SandboxDetail: + """ + Attributes: + client_id (str): Identifier of the client + cpu_count (int): CPU cores for the sandbox + end_at (datetime.datetime): Time when the sandbox will expire + memory_mb (int): Memory for the sandbox in MB + sandbox_id (str): Identifier of the sandbox + started_at (datetime.datetime): Time when the sandbox was started + state (SandboxState): State of the sandbox + template_id (str): Identifier of the template from which is the sandbox created + alias (Union[Unset, str]): Alias of the template + envd_access_token (Union[Unset, str]): Access token used for envd communication + envd_version (Union[Unset, str]): Version of the envd running in the sandbox + metadata (Union[Unset, Any]): + """ + + client_id: str + cpu_count: int + end_at: datetime.datetime + memory_mb: int + sandbox_id: str + started_at: datetime.datetime + state: SandboxState + template_id: str + alias: Union[Unset, str] = UNSET + envd_access_token: Union[Unset, str] = UNSET + envd_version: Union[Unset, str] = UNSET + metadata: Union[Unset, Any] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + client_id = self.client_id + + cpu_count = self.cpu_count + + end_at = self.end_at.isoformat() + + memory_mb = self.memory_mb + + sandbox_id = self.sandbox_id + + started_at = self.started_at.isoformat() + + state = self.state.value + + template_id = self.template_id + + alias = self.alias + + envd_access_token = self.envd_access_token + + envd_version = self.envd_version + + metadata = self.metadata + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "clientID": client_id, + "cpuCount": cpu_count, + "endAt": end_at, + "memoryMB": memory_mb, + "sandboxID": sandbox_id, + "startedAt": started_at, + "state": state, + "templateID": template_id, + } + ) + if alias is not UNSET: + field_dict["alias"] = alias + if envd_access_token is not UNSET: + field_dict["envdAccessToken"] = envd_access_token + if envd_version is not UNSET: + field_dict["envdVersion"] = envd_version + if metadata is not UNSET: + field_dict["metadata"] = metadata + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + client_id = d.pop("clientID") + + cpu_count = d.pop("cpuCount") + + end_at = isoparse(d.pop("endAt")) + + memory_mb = d.pop("memoryMB") + + sandbox_id = d.pop("sandboxID") + + started_at = isoparse(d.pop("startedAt")) + + state = SandboxState(d.pop("state")) + + template_id = d.pop("templateID") + + alias = d.pop("alias", UNSET) + + envd_access_token = d.pop("envdAccessToken", UNSET) + + envd_version = d.pop("envdVersion", UNSET) + + metadata = d.pop("metadata", UNSET) + + sandbox_detail = cls( + client_id=client_id, + cpu_count=cpu_count, + end_at=end_at, + memory_mb=memory_mb, + sandbox_id=sandbox_id, + started_at=started_at, + state=state, + template_id=template_id, + alias=alias, + envd_access_token=envd_access_token, + envd_version=envd_version, + metadata=metadata, + ) + + sandbox_detail.additional_properties = d + return sandbox_detail + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_state.py b/packages/python-sdk/e2b/api/client/models/sandbox_state.py new file mode 100644 index 0000000000..2ac256577b --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_state.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class SandboxState(str, Enum): + PAUSED = "paused" + RUNNING = "running" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 6ac6b9829d..1d72581a4f 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -44,7 +44,7 @@ def __init__( self.debug = debug or ConnectionConfig._debug() self.api_key = api_key or ConnectionConfig._api_key() self.access_token = access_token or ConnectionConfig._access_token() - self.headers = headers + self.headers = headers or {} self.proxy = proxy self.request_timeout = ConnectionConfig._get_request_timeout( @@ -78,7 +78,6 @@ def _get_request_timeout( def get_request_timeout(self, request_timeout: Optional[float] = None): return self._get_request_timeout(self.request_timeout, request_timeout) - Username = Literal["root", "user"] """ User used for the operation in the sandbox. diff --git a/packages/python-sdk/e2b/sandbox/main.py b/packages/python-sdk/e2b/sandbox/main.py index 539a0ae7fa..8fda0f95f9 100644 --- a/packages/python-sdk/e2b/sandbox/main.py +++ b/packages/python-sdk/e2b/sandbox/main.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from typing import Optional +from e2b.sandbox.signature import get_signature from e2b.connection_config import ConnectionConfig from e2b.envd.api import ENVD_API_FILES_ROUTE from httpx import Limits @@ -25,6 +26,11 @@ class SandboxSetup(ABC): def connection_config(self) -> ConnectionConfig: ... + @property + @abstractmethod + def _envd_access_token(self) -> Optional[str]: + ... + @property @abstractmethod def envd_api_url(self) -> str: @@ -35,10 +41,18 @@ def envd_api_url(self) -> str: def sandbox_id(self) -> str: ... - def _file_url(self, path: Optional[str] = None) -> str: + def _file_url(self, path: Optional[str] = None, user: str = "user", signature: Optional[str] = None, signature_expiration: Optional[int] = None) -> str: url = urllib.parse.urljoin(self.envd_api_url, ENVD_API_FILES_ROUTE) query = {"path": path} if path else {} - query = {**query, "username": "user"} + query = {**query, "username": user} + + if signature: + query["signature"] = signature + + if signature_expiration: + if signature is None: + raise ValueError("signature_expiration requires signature to be set") + query["signature_expiration"] = str(signature_expiration) params = urllib.parse.urlencode( query, @@ -48,27 +62,43 @@ def _file_url(self, path: Optional[str] = None) -> str: return url - def download_url(self, path: str) -> str: + def download_url(self, path: str, user: str = "user", use_signature: bool = False, use_signature_expiration: Optional[int] = None) -> str: """ Get the URL to download a file from the sandbox. :param path: Path to the file to download + :param user: User to upload the file as + :param use_signature: Whether to use a signed URL for downloading the file + :param use_signature_expiration: Expiration time for the signed URL in seconds :return: URL for downloading file """ - return self._file_url(path) - def upload_url(self, path: Optional[str] = None) -> str: + if use_signature: + signature = get_signature(path, "read", user, self._envd_access_token, use_signature_expiration) + return self._file_url(path, user, signature["signature"], signature["expiration"]) + else: + return self._file_url(path) + + def upload_url(self, path: Optional[str] = None, user: str = "user", use_signature: bool = False, use_signature_expiration: Optional[int] = None) -> str: """ Get the URL to upload a file to the sandbox. You have to send a POST request to this URL with the file as multipart/form-data. :param path: Path to the file to upload + :param user: User to upload the file as + :param use_signature: Whether to use a signed URL for downloading the file + :param use_signature_expiration: Expiration time for the signed URL in seconds :return: URL for uploading file """ - return self._file_url(path) + + if use_signature: + signature = get_signature(path, "write", user, self._envd_access_token, use_signature_expiration) + return self._file_url(path, user, signature["signature"], signature["expiration"]) + else: + return self._file_url(path) def get_host(self, port: int) -> str: """ diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 51175d745f..caaa35442f 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -1,9 +1,12 @@ from abc import ABC from dataclasses import dataclass -from typing import Optional, Dict +from typing import Optional, Dict, Union, Literal from datetime import datetime + from httpx import Limits +from e2b.api.client.models import SandboxState + @dataclass class SandboxInfo: @@ -21,7 +24,32 @@ class SandboxInfo: """Sandbox start time.""" end_at: datetime """Sandbox expiration date.""" + envd_version: Optional[str] + """Envd version.""" + _envd_access_token: Optional[str] + """Envd access token.""" + +@dataclass +class ListedSandbox: + """Information about a sandbox.""" + sandbox_id: str + """Sandbox ID.""" + template_id: str + """Template ID.""" + name: Optional[str] + """Template Alias.""" + state: SandboxState + """Sandbox state.""" + cpu_count: int + """Sandbox CPU count.""" + memory_mb: int + """Sandbox Memory size in MB.""" + metadata: Dict[str, str] + """Saved sandbox metadata.""" + started_at: datetime + """Sandbox start time.""" + end_at: datetime @dataclass class SandboxQuery: diff --git a/packages/python-sdk/e2b/sandbox/signature.py b/packages/python-sdk/e2b/sandbox/signature.py new file mode 100644 index 0000000000..20989d140b --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/signature.py @@ -0,0 +1,41 @@ +import base64 +import hashlib +import time +import urllib.parse + +from typing import Optional, TypedDict, Literal + +Operation = Literal["read", "write"] + +class Signature(TypedDict): + signature: str + expiration: Optional[int] # Unix timestamp or None + + +def get_signature( + path: str, + operation: Operation, + user: str, + envd_access_token: Optional[str], + expiration_in_seconds: Optional[int] = None, +) -> Signature: + """ + Generate a v1 signature for sandbox file URLs. + """ + if not envd_access_token: + raise ValueError("Access token is not set and signature cannot be generated!") + + expiration = ( + int(time.time()) + expiration_in_seconds if expiration_in_seconds else None + ) + + raw = ( + f"{path}:{operation}:{user}:{envd_access_token}" + if expiration is None + else f"{path}:{operation}:{user}:{envd_access_token}:{expiration}" + ) + + digest = hashlib.sha256(raw.encode("utf-8")).digest() + encoded = base64.b64encode(digest).rstrip(b"=").decode("ascii") + + return {"signature": f"v1_{encoded}", "expiration": expiration} diff --git a/packages/python-sdk/e2b/sandbox_async/commands/command.py b/packages/python-sdk/e2b/sandbox_async/commands/command.py index 4fb24ba709..824ce99a92 100644 --- a/packages/python-sdk/e2b/sandbox_async/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_async/commands/command.py @@ -35,6 +35,7 @@ def __init__( # compressor=e2b_connect.GzipCompressor, async_pool=pool, json=True, + headers=connection_config.headers, ) async def list( diff --git a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py index 8d240311c5..f28dfd0951 100644 --- a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py @@ -47,6 +47,7 @@ def __init__( # compressor=e2b_connect.GzipCompressor, async_pool=pool, json=True, + headers=connection_config.headers, ) @overload diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 4ee18a5d7e..324091de07 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -4,6 +4,7 @@ from typing import Dict, Optional, TypedDict, overload from typing_extensions import Unpack +from e2b.api.client.types import Unset from e2b.connection_config import ConnectionConfig, ProxyTypes from e2b.envd.api import ENVD_API_HEALTH_ROUTE, ahandle_envd_api_exception from e2b.exceptions import format_request_timeout_error @@ -32,6 +33,7 @@ async def handle_async_request(self, request): class AsyncSandboxOpts(TypedDict): sandbox_id: str envd_version: Optional[str] + envd_access_token: Optional[str] connection_config: ConnectionConfig @@ -90,6 +92,16 @@ def sandbox_id(self) -> str: def envd_api_url(self) -> str: return self._envd_api_url + @property + def _envd_access_token(self) -> str: + """Private property to access the envd token""" + return self.__envd_access_token + + @_envd_access_token.setter + def _envd_access_token(self, value: str): + """Private setter for envd token""" + self.__envd_access_token = value + @property def connection_config(self) -> ConnectionConfig: return self._connection_config @@ -105,6 +117,7 @@ def __init__(self, **opts: Unpack[AsyncSandboxOpts]): self._envd_api_url = f"{'http' if self.connection_config.debug else 'https'}://{self.get_host(self.envd_port)}" self._envd_version = opts["envd_version"] + self._envd_access_token = opts["envd_access_token"] self._transport = AsyncTransportWithLogger( limits=self._limits, proxy=self._connection_config.proxy @@ -112,6 +125,7 @@ def __init__(self, **opts: Unpack[AsyncSandboxOpts]): self._envd_api = httpx.AsyncClient( base_url=self.envd_api_url, transport=self._transport, + headers=self._connection_config.headers, ) self._filesystem = Filesystem( @@ -180,6 +194,7 @@ async def create( debug: Optional[bool] = None, request_timeout: Optional[float] = None, proxy: Optional[ProxyTypes] = None, + secure: Optional[bool] = None, ): """ Create a new sandbox. @@ -193,22 +208,19 @@ async def create( :param api_key: E2B API Key to use for authentication, defaults to `E2B_API_KEY` environment variable :param request_timeout: Timeout for the request in **seconds** :param proxy: Proxy to use for the request and for the **requests made to the returned sandbox** + :param secure: Envd is secured with access token and cannot be used without it :return: sandbox instance for the new sandbox Use this method instead of using the constructor to create a new sandbox. """ - connection_config = ConnectionConfig( - api_key=api_key, - domain=domain, - debug=debug, - request_timeout=request_timeout, - proxy=proxy, - ) - if connection_config.debug: + connection_headers = {} + + if debug: sandbox_id = "debug_sandbox_id" envd_version = None + envd_access_token = None else: response = await SandboxApi._create_sandbox( template=template or cls.default_template, @@ -219,14 +231,32 @@ async def create( debug=debug, request_timeout=request_timeout, env_vars=envs, + secure=secure, proxy=proxy, ) + sandbox_id = response.sandbox_id envd_version = response.envd_version + envd_access_token = response.envd_access_token + + if envd_access_token is not None and not isinstance( + envd_access_token, Unset + ): + connection_headers["X-Access-Token"] = envd_access_token + + connection_config = ConnectionConfig( + api_key=api_key, + domain=domain, + debug=debug, + request_timeout=request_timeout, + headers=connection_headers, + proxy=proxy, + ) return cls( sandbox_id=sandbox_id, envd_version=envd_version, + envd_access_token=envd_access_token, connection_config=connection_config, ) @@ -257,17 +287,29 @@ async def connect( # Another code block same_sandbox = await AsyncSandbox.connect(sandbox_id) """ + + connection_headers = {} + + response = await SandboxApi.get_info(sandbox_id) + + if response._envd_access_token is not None and not isinstance( + response._envd_access_token, Unset + ): + connection_headers["X-Access-Token"] = response._envd_access_token + connection_config = ConnectionConfig( api_key=api_key, domain=domain, debug=debug, + headers=connection_headers, proxy=proxy, ) return cls( sandbox_id=sandbox_id, - envd_version=None, connection_config=connection_config, + envd_version=response.envd_version, + envd_access_token=response._envd_access_token, ) async def __aenter__(self): diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index 2c49044f01..8220f2a91f 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -4,7 +4,7 @@ from packaging.version import Version -from e2b.sandbox.sandbox_api import SandboxInfo, SandboxApiBase, SandboxQuery +from e2b.sandbox.sandbox_api import SandboxInfo, SandboxApiBase, SandboxQuery, ListedSandbox from e2b.exceptions import TemplateException from e2b.api import AsyncApiClient, SandboxCreateResponse from e2b.api.client.models import NewSandbox, PostSandboxesSandboxIDTimeoutBody @@ -30,7 +30,7 @@ async def list( request_timeout: Optional[float] = None, headers: Optional[Dict[str, str]] = None, proxy: Optional[ProxyTypes] = None, - ) -> List[SandboxInfo]: + ) -> List[ListedSandbox]: """ List all running sandboxes. @@ -79,7 +79,7 @@ async def list( return [] return [ - SandboxInfo( + ListedSandbox( sandbox_id=SandboxApi._get_sandbox_id( sandbox.sandbox_id, sandbox.client_id, @@ -89,6 +89,9 @@ async def list( metadata=( sandbox.metadata if isinstance(sandbox.metadata, dict) else {} ), + state=sandbox.state, + cpu_count=sandbox.cpu_count, + memory_mb=sandbox.memory_mb, started_at=sandbox.started_at, end_at=sandbox.end_at, ) @@ -154,6 +157,8 @@ async def get_info( ), started_at=res.parsed.started_at, end_at=res.parsed.end_at, + envd_version=res.parsed.envd_version, + _envd_access_token=res.parsed.envd_access_token, ) @classmethod @@ -242,6 +247,7 @@ async def _create_sandbox( timeout: int, metadata: Optional[Dict[str, str]] = None, env_vars: Optional[Dict[str, str]] = None, + secure: Optional[bool] = None, api_key: Optional[str] = None, domain: Optional[str] = None, debug: Optional[bool] = None, @@ -268,6 +274,7 @@ async def _create_sandbox( metadata=metadata or {}, timeout=timeout, env_vars=env_vars or {}, + secure=secure or False, ), client=api_client, ) @@ -296,4 +303,5 @@ async def _create_sandbox( res.parsed.client_id, ), envd_version=res.parsed.envd_version, + envd_access_token=res.parsed.envd_access_token, ) diff --git a/packages/python-sdk/e2b/sandbox_sync/commands/command.py b/packages/python-sdk/e2b/sandbox_sync/commands/command.py index 5839efd75d..b460a6c057 100644 --- a/packages/python-sdk/e2b/sandbox_sync/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_sync/commands/command.py @@ -34,6 +34,7 @@ def __init__( # compressor=e2b_connect.GzipCompressor, pool=pool, json=True, + headers=connection_config.headers, ) def list( diff --git a/packages/python-sdk/e2b/sandbox_sync/commands/pty.py b/packages/python-sdk/e2b/sandbox_sync/commands/pty.py index 6e7af290ce..371a28a8fa 100644 --- a/packages/python-sdk/e2b/sandbox_sync/commands/pty.py +++ b/packages/python-sdk/e2b/sandbox_sync/commands/pty.py @@ -34,6 +34,7 @@ def __init__( # compressor=e2b_connect.GzipCompressor, pool=pool, json=True, + headers=connection_config.headers, ) def kill( diff --git a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py index 1ff9e9bf1a..65a84b2641 100644 --- a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py @@ -47,6 +47,7 @@ def __init__( # compressor=e2b_connect.GzipCompressor, pool=pool, json=True, + headers=connection_config.headers, ) @overload @@ -448,7 +449,7 @@ def watch_dir( headers={ **authentication_header(user), KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), - }, + } ) except Exception as e: raise handle_rpc_exception(e) diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 24d8896f40..8b1f6a515d 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -3,6 +3,7 @@ from typing import Dict, Optional, overload +from e2b.api.client.types import Unset from e2b.connection_config import ConnectionConfig, ProxyTypes from e2b.envd.api import ENVD_API_HEALTH_ROUTE, handle_envd_api_exception from e2b.exceptions import SandboxException, format_request_timeout_error @@ -83,6 +84,16 @@ def sandbox_id(self) -> str: def envd_api_url(self) -> str: return self._envd_api_url + @property + def _envd_access_token(self) -> str: + """Private property to access the envd token""" + return self.__envd_access_token + + @_envd_access_token.setter + def _envd_access_token(self, value: Optional[str]): + """Private setter for envd token""" + self.__envd_access_token = value + @property def connection_config(self) -> ConnectionConfig: return self._connection_config @@ -93,6 +104,7 @@ def __init__( timeout: Optional[int] = None, metadata: Optional[Dict[str, str]] = None, envs: Optional[Dict[str, str]] = None, + secure: Optional[bool] = None, api_key: Optional[str] = None, domain: Optional[str] = None, debug: Optional[bool] = None, @@ -123,20 +135,23 @@ def __init__( "Use Sandbox.connect method instead.", ) - self._connection_config = ConnectionConfig( - api_key=api_key, - domain=domain, - debug=debug, - request_timeout=request_timeout, - proxy=proxy, - ) + connection_headers = {} - if self.connection_config.debug: + if debug: self._sandbox_id = "debug_sandbox_id" self._envd_version = None + self._envd_access_token = None elif sandbox_id is not None: + response = SandboxApi.get_info(sandbox_id) + self._sandbox_id = sandbox_id - self._envd_version = None + self._envd_version = response.envd_version + self._envd_access_token = response._envd_access_token + + if response._envd_access_token is not None and not isinstance( + response._envd_access_token, Unset + ): + connection_headers["X-Access-Token"] = response._envd_access_token else: template = template or self.default_template timeout = timeout or self.default_sandbox_timeout @@ -149,17 +164,35 @@ def __init__( domain=domain, debug=debug, request_timeout=request_timeout, + secure=secure or False, proxy=proxy, ) self._sandbox_id = response.sandbox_id self._envd_version = response.envd_version - self._envd_api_url = f"{'http' if self.connection_config.debug else 'https'}://{self.get_host(self.envd_port)}" + if response.envd_access_token is not None and not isinstance( + response.envd_access_token, Unset + ): + self._envd_access_token = response.envd_access_token + connection_headers["X-Access-Token"] = response.envd_access_token + else: + self._envd_access_token = None self._transport = TransportWithLogger(limits=self._limits, proxy=proxy) + self._connection_config = ConnectionConfig( + api_key=api_key, + domain=domain, + debug=debug, + request_timeout=request_timeout, + headers=connection_headers, + proxy=proxy, + ) + + self._envd_api_url = f"{'http' if self.connection_config.debug else 'https'}://{self.get_host(self.envd_port)}" self._envd_api = httpx.Client( base_url=self.envd_api_url, transport=self._transport, + headers=self.connection_config.headers, ) self._filesystem = Filesystem( @@ -244,6 +277,7 @@ def connect( same_sandbox = Sandbox.connect(sandbox_id) ``` """ + return cls( sandbox_id=sandbox_id, api_key=api_key, diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index bf43487bd2..aa211c17f2 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -3,7 +3,7 @@ from typing import Optional, Dict, List from packaging.version import Version -from e2b.sandbox.sandbox_api import SandboxInfo, SandboxApiBase, SandboxQuery +from e2b.sandbox.sandbox_api import SandboxInfo, SandboxApiBase, SandboxQuery, ListedSandbox from e2b.exceptions import TemplateException from e2b.api import ApiClient, SandboxCreateResponse from e2b.api.client.models import NewSandbox, PostSandboxesSandboxIDTimeoutBody @@ -29,7 +29,7 @@ def list( request_timeout: Optional[float] = None, headers: Optional[Dict[str, str]] = None, proxy: Optional[ProxyTypes] = None, - ) -> List[SandboxInfo]: + ) -> List[ListedSandbox]: """ List all running sandboxes. @@ -75,7 +75,7 @@ def list( return [] return [ - SandboxInfo( + ListedSandbox( sandbox_id=SandboxApi._get_sandbox_id( sandbox.sandbox_id, sandbox.client_id, @@ -85,6 +85,9 @@ def list( metadata=( sandbox.metadata if isinstance(sandbox.metadata, dict) else {} ), + state=sandbox.state, + cpu_count=sandbox.cpu_count, + memory_mb=sandbox.memory_mb, started_at=sandbox.started_at, end_at=sandbox.end_at, ) @@ -149,6 +152,8 @@ def get_info( ), started_at=res.parsed.started_at, end_at=res.parsed.end_at, + envd_version=res.parsed.envd_version, + _envd_access_token=res.parsed.envd_access_token, ) @classmethod @@ -237,6 +242,7 @@ def _create_sandbox( timeout: int, metadata: Optional[Dict[str, str]] = None, env_vars: Optional[Dict[str, str]] = None, + secure: Optional[bool] = None, api_key: Optional[str] = None, domain: Optional[str] = None, debug: Optional[bool] = None, @@ -253,16 +259,14 @@ def _create_sandbox( proxy=proxy, ) - with ApiClient( - config, - limits=SandboxApiBase._limits - ) as api_client: + with ApiClient(config, limits=SandboxApiBase._limits) as api_client: res = post_sandboxes.sync_detailed( body=NewSandbox( template_id=template, metadata=metadata or {}, timeout=timeout, env_vars=env_vars or {}, + secure=secure or False, ), client=api_client, ) @@ -291,4 +295,5 @@ def _create_sandbox( res.parsed.client_id, ), envd_version=res.parsed.envd_version, + envd_access_token=res.parsed.envd_access_token, ) diff --git a/packages/python-sdk/tests/async/api_async/test_sbx_list.py b/packages/python-sdk/tests/async/api_async/test_sbx_list.py index 40fb2f60bf..1979fc23c8 100644 --- a/packages/python-sdk/tests/async/api_async/test_sbx_list.py +++ b/packages/python-sdk/tests/async/api_async/test_sbx_list.py @@ -17,7 +17,9 @@ async def test_list_sandboxes(async_sandbox: AsyncSandbox): @pytest.mark.skip_debug() async def test_list_sandboxes_with_filter(template): unique_id = "".join(random.choices(string.ascii_letters, k=5)) - sbx = await AsyncSandbox.create(template=template, metadata={"unique_id": unique_id}) + sbx = await AsyncSandbox.create( + template=template, metadata={"unique_id": unique_id} + ) try: # There's an extra sandbox created by the test runner sandboxes = await AsyncSandbox.list( diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_secured.py b/packages/python-sdk/tests/async/sandbox_async/files/test_secured.py new file mode 100644 index 0000000000..98147444c1 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_secured.py @@ -0,0 +1,70 @@ +import urllib.request +import urllib.error +import json +import pytest + +from e2b import ( + AsyncSandbox, +) + + +async def test_download_url_with_signing(template): + sbx = await AsyncSandbox.create(template, timeout=100, secure=True) + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + try: + await sbx.files.write(file_path, file_content) + signed_url = sbx.download_url(file_path, "user", True) + + with urllib.request.urlopen(signed_url) as resp: + assert resp.status == 200 + body_bytes = resp.read() + body_text = body_bytes.decode() + assert body_text == file_content + finally: + await sbx.kill() + +async def test_download_url_with_signing_and_expiration(template): + sbx = await AsyncSandbox.create(template, timeout=100, secure=True) + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + try: + await sbx.files.write(file_path, file_content) + signed_url = sbx.download_url(file_path, "user", True, 120) + + with urllib.request.urlopen(signed_url) as resp: + assert resp.status == 200 + body_bytes = resp.read() + body_text = body_bytes.decode() + assert body_text == file_content + finally: + await sbx.kill() + +async def test_download_url_with_expired_signing(template): + sbx = await AsyncSandbox.create(template, timeout=100, secure=True) + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + try: + await sbx.files.write(file_path, file_content) + + signed_url = sbx.download_url( + file_path, "user", use_signature=True, use_signature_expiration=-120 + ) + + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(signed_url) + + err = exc_info.value + assert err.code == 401, f"Unexpected status {err.code}" + + error_json_str = err.read().decode() # bytes ➜ str + error_payload = json.loads(error_json_str) # str ➜ dict + + expected_payload = {"code": 401, "message": "signature is already expired"} + assert error_payload == expected_payload + + finally: + await sbx.kill() \ No newline at end of file diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_watch.py b/packages/python-sdk/tests/async/sandbox_async/files/test_watch.py index af1f566634..51402573f5 100644 --- a/packages/python-sdk/tests/async/sandbox_async/files/test_watch.py +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_watch.py @@ -112,3 +112,12 @@ async def test_watch_file(async_sandbox: AsyncSandbox): with pytest.raises(SandboxException): await async_sandbox.files.watch_dir(filename, on_event=lambda e: None) + + +async def test_watch_file_with_secured_envd(template): + sbx = await AsyncSandbox.create(template, timeout=30, secure=True) + try: + await sbx.files.watch_dir("/home/user/", on_event=lambda e: None) + await sbx.files.write("test_watch.txt", "This file will be watched.") + finally: + await sbx.kill() diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_write.py b/packages/python-sdk/tests/async/sandbox_async/files/test_write.py index b413112aaf..46a065c951 100644 --- a/packages/python-sdk/tests/async/sandbox_async/files/test_write.py +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_write.py @@ -1,4 +1,5 @@ import io +import uuid from e2b import AsyncSandbox from e2b.sandbox_async.filesystem.filesystem import EntryInfo @@ -111,7 +112,7 @@ async def test_overwrite_file(async_sandbox: AsyncSandbox): async def test_write_to_non_existing_directory(async_sandbox: AsyncSandbox): - filename = "non_existing_dir/test_write.txt" + filename = f"non_existing_dir_{uuid.uuid4()}/test_write.txt" content = "This should succeed too." await async_sandbox.files.write(filename, content) @@ -120,3 +121,25 @@ async def test_write_to_non_existing_directory(async_sandbox: AsyncSandbox): read_content = await async_sandbox.files.read(filename) assert read_content == content + + +async def test_write_with_secured_envd(template): + filename = f"non_existing_dir_{uuid.uuid4()}/test_write.txt" + content = "This should succeed too." + + sbx = await AsyncSandbox.create(template, timeout=30, secure=True) + try: + assert await sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + + await sbx.files.write(filename, content) + + exists = await sbx.files.exists(filename) + assert exists + + read_content = await sbx.files.read(filename) + assert read_content == content + + finally: + await sbx.kill() diff --git a/packages/python-sdk/tests/async/sandbox_async/test_connect.py b/packages/python-sdk/tests/async/sandbox_async/test_connect.py index dbb7463535..b5f1d2b47f 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_connect.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_connect.py @@ -1,4 +1,4 @@ -import pytest +import uuid from e2b import AsyncSandbox @@ -12,3 +12,20 @@ async def test_connect(template): assert await sbx_connection.is_running() finally: await sbx.kill() + + +async def test_connect_with_secure(template): + dir_name = f"test_directory_{uuid.uuid4()}" + + sbx = await AsyncSandbox.create(template, timeout=10, secure=True) + assert await sbx.is_running() + + try: + sbx_connection = await AsyncSandbox.connect(sbx.sandbox_id) + + await sbx_connection.files.make_dir(dir_name) + files = await sbx_connection.files.list(dir_name) + assert len(files) == 0 + assert await sbx_connection.is_running() + finally: + await sbx.kill() diff --git a/packages/python-sdk/tests/async/sandbox_async/test_secure.py b/packages/python-sdk/tests/async/sandbox_async/test_secure.py new file mode 100644 index 0000000000..1fe2c46d16 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_secure.py @@ -0,0 +1,28 @@ +import pytest + +from e2b import AsyncSandbox + + +async def test_start_secured(template): + sbx = await AsyncSandbox.create(template, timeout=5, secure=True) + try: + assert await sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + finally: + await sbx.kill() + + +async def test_connect_to_secured(template): + sbx = await AsyncSandbox.create(template, timeout=100, secure=True) + try: + assert await sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + + sbx_connection = await AsyncSandbox.connect(sbx.sandbox_id) + assert await sbx_connection.is_running() + assert sbx_connection._envd_version is not None + assert sbx_connection._envd_access_token is not None + finally: + await sbx.kill() diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_secured.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_secured.py new file mode 100644 index 0000000000..6840742342 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_secured.py @@ -0,0 +1,67 @@ +import urllib.request +import urllib.error +import json +import pytest + +from e2b import Sandbox + +async def test_download_url_with_signing(template): + sbx = Sandbox(template, timeout=100, secure=True) + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + try: + sbx.files.write(file_path, file_content) + signed_url = sbx.download_url(file_path, "user", True) + + with urllib.request.urlopen(signed_url) as resp: + assert resp.status == 200 + body_bytes = resp.read() + body_text = body_bytes.decode() + assert body_text == file_content + finally: + sbx.kill() + +async def test_download_url_with_signing_and_expiration(template): + sbx = Sandbox(template, timeout=100, secure=True) + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + try: + sbx.files.write(file_path, file_content) + signed_url = sbx.download_url(file_path, "user", True, 120) + + with urllib.request.urlopen(signed_url) as resp: + assert resp.status == 200 + body_bytes = resp.read() + body_text = body_bytes.decode() + assert body_text == file_content + finally: + sbx.kill() + +async def test_download_url_with_expired_signing(template): + sbx = Sandbox(template, timeout=100, secure=True) + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + try: + sbx.files.write(file_path, file_content) + + signed_url = sbx.download_url( + file_path, "user", use_signature=True, use_signature_expiration=-120 + ) + + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(signed_url) + + err = exc_info.value + assert err.code == 401, f"Unexpected status {err.code}" + + error_json_str = err.read().decode() # bytes ➜ str + error_payload = json.loads(error_json_str) # str ➜ dict + + expected_payload = {"code": 401, "message": "signature is already expired"} + assert error_payload == expected_payload + + finally: + sbx.kill() \ No newline at end of file diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_watch.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_watch.py index c76084364d..e8f8bc4b66 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/files/test_watch.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_watch.py @@ -109,3 +109,12 @@ def test_watch_file(sandbox: Sandbox): with pytest.raises(SandboxException): sandbox.files.watch_dir(filename) + + +def test_watch_file_with_secured_envd(template): + sbx = Sandbox(template, timeout=30, secure=True) + try: + sbx.files.watch_dir("/home/user/") + sbx.files.write("test_watch.txt", "This file will be watched.") + finally: + sbx.kill() diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_write.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_write.py index 2cbf404fcf..8ea5a5e4fa 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/files/test_write.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_write.py @@ -1,6 +1,8 @@ import io +import uuid from e2b.sandbox.filesystem.filesystem import EntryInfo +from e2b.sandbox_sync.main import Sandbox def test_write_text_file(sandbox): @@ -119,3 +121,25 @@ def test_write_to_non_existing_directory(sandbox): read_content = sandbox.files.read(filename) assert read_content == content + + +def test_write_with_secured_envd(template): + filename = f"non_existing_dir_{uuid.uuid4()}/test_write.txt" + content = "This should succeed too." + + sbx = Sandbox(template, timeout=30, secure=True) + try: + assert sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + + sbx.files.write(filename, content) + + exists = sbx.files.exists(filename) + assert exists + + read_content = sbx.files.read(filename) + assert read_content == content + + finally: + sbx.kill() diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_connect.py b/packages/python-sdk/tests/sync/sandbox_sync/test_connect.py index 3310df9be5..d2be9ed359 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_connect.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_connect.py @@ -1,3 +1,5 @@ +import uuid + from e2b import Sandbox @@ -10,3 +12,20 @@ def test_connect(template): assert sbx_connection.is_running() finally: sbx.kill() + + +def test_connect_with_secure(template): + dir_name = f"test_directory_{uuid.uuid4()}" + + sbx = Sandbox(template, timeout=10, secure=True) + try: + assert sbx.is_running() + + sbx_connection = Sandbox.connect(sbx.sandbox_id) + + sbx_connection.files.make_dir(dir_name) + files = sbx_connection.files.list(dir_name) + assert len(files) == 0 + + finally: + sbx.kill() diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_secure.py b/packages/python-sdk/tests/sync/sandbox_sync/test_secure.py new file mode 100644 index 0000000000..6e49a0afd4 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_secure.py @@ -0,0 +1,27 @@ +import pytest + +from e2b import Sandbox + + +def test_start_secured(template): + sbx = Sandbox(template, timeout=5, secure=True) + try: + assert sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + finally: + sbx.kill() + +def test_connect_to_secured(template): + sbx = Sandbox(template, timeout=5, secure=True) + try: + assert sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + + sbx_connection = Sandbox.connect(sbx.sandbox_id) + assert sbx_connection.is_running() + assert sbx_connection._envd_version is not None + assert sbx_connection._envd_access_token is not None + finally: + sbx.kill() diff --git a/spec/envd/envd.yaml b/spec/envd/envd.yaml index 4b7d49f8d3..d03e24abb7 100644 --- a/spec/envd/envd.yaml +++ b/spec/envd/envd.yaml @@ -15,9 +15,26 @@ paths: "204": description: The service is healthy + /metrics: + get: + summary: Get the stats of the service + security: + - AccessTokenAuth: [] + - {} + responses: + "200": + description: The resource usage metrics of the service + content: + application/json: + schema: + $ref: "#/components/schemas/Metrics" + /init: post: - summary: Set env vars, ensure the time and metadata is synced with the host + summary: Set initial vars, ensure the time and metadata is synced with the host + security: + - AccessTokenAuth: [] + - {} requestBody: content: application/json: @@ -26,17 +43,39 @@ paths: properties: envVars: $ref: "#/components/schemas/EnvVars" + accessToken: + type: string + description: Access token for secure access to envd service responses: "204": description: Env vars set, the time and metadata is synced with the host + /envs: + get: + summary: Get the environment variables + security: + - AccessTokenAuth: [] + - {} + responses: + "200": + description: Environment variables + content: + application/json: + schema: + $ref: "#/components/schemas/EnvVars" + /files: get: summary: Download a file tags: [files] + security: + - AccessTokenAuth: [] + - {} parameters: - $ref: "#/components/parameters/FilePath" - $ref: "#/components/parameters/User" + - $ref: "#/components/parameters/Signature" + - $ref: "#/components/parameters/SignatureExpiration" responses: "200": $ref: "#/components/responses/DownloadSuccess" @@ -51,9 +90,14 @@ paths: post: summary: Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten. tags: [files] + security: + - AccessTokenAuth: [] + - {} parameters: - $ref: "#/components/parameters/FilePath" - $ref: "#/components/parameters/User" + - $ref: "#/components/parameters/Signature" + - $ref: "#/components/parameters/SignatureExpiration" requestBody: $ref: "#/components/requestBodies/File" responses: @@ -69,6 +113,12 @@ paths: $ref: "#/components/responses/NotEnoughDiskSpace" components: + securitySchemes: + AccessTokenAuth: + type: apiKey + scheme: header + name: X-Access-Token + parameters: FilePath: name: path @@ -85,6 +135,20 @@ components: schema: type: string pattern: "^(root|user)$" + Signature: + name: signature + in: query + required: false + description: Signature used for file access permission verification. + schema: + type: string + SignatureExpiration: + name: signature_expiration + in: query + required: false + description: Signature expiration used for defining the expiration time of the signature. + schema: + type: integer requestBodies: File: @@ -181,3 +245,14 @@ components: description: Environment variables to set additionalProperties: type: string + Metrics: + type: object + description: Resource usage metrics + properties: + cpu_used_pct: + type: number + format: float + description: CPU usage percentage + mem_bytes: + type: integer + description: Total virtual memory usage in bytes diff --git a/spec/envd/process/process.proto b/spec/envd/process/process.proto index 031e267347..0a2fad4b66 100644 --- a/spec/envd/process/process.proto +++ b/spec/envd/process/process.proto @@ -28,7 +28,7 @@ message PTY { message ProcessConfig { string cmd = 1; repeated string args = 2; - + map envs = 3; optional string cwd = 4; } @@ -45,7 +45,7 @@ message ListResponse { repeated ProcessInfo processes = 1; } -message StartRequest { +message StartRequest { ProcessConfig process = 1; optional PTY pty = 2; optional string tag = 3; @@ -66,11 +66,11 @@ message ProcessEvent { EndEvent end = 3; KeepAlive keepalive = 4; } - + message StartEvent { uint32 pid = 1; } - + message DataEvent { oneof output { bytes stdout = 1; @@ -78,7 +78,7 @@ message ProcessEvent { bytes pty = 3; } } - + message EndEvent { sint32 exit_code = 1; bool exited = 2; diff --git a/spec/openapi.yml b/spec/openapi.yml index f01c467790..b47e639963 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -4,7 +4,7 @@ info: title: E2B API servers: - - url: https://api.e2b.dev + - url: https://api.e2b.app components: securitySchemes: @@ -146,14 +146,12 @@ components: type: integer format: int32 minimum: 1 - maximum: 8 description: CPU cores for the sandbox MemoryMB: type: integer format: int32 minimum: 128 - maximum: 8192 description: Memory for the sandbox in MB SandboxMetadata: @@ -161,6 +159,13 @@ components: type: string description: Metadata of the sandbox + SandboxState: + type: string + description: State of the sandbox + enum: + - running + - paused + EnvVars: additionalProperties: type: string @@ -242,8 +247,11 @@ components: envdVersion: type: string description: Version of the envd running in the sandbox + envdAccessToken: + type: string + description: Access token used for envd communication - RunningSandbox: + SandboxDetail: required: - templateID - sandboxID @@ -252,6 +260,7 @@ components: - cpuCount - memoryMB - endAt + - state properties: templateID: type: string @@ -273,12 +282,60 @@ components: type: string format: date-time description: Time when the sandbox will expire + envdVersion: + type: string + description: Version of the envd running in the sandbox + envdAccessToken: + type: string + description: Access token used for envd communication cpuCount: $ref: "#/components/schemas/CPUCount" memoryMB: $ref: "#/components/schemas/MemoryMB" metadata: $ref: "#/components/schemas/SandboxMetadata" + state: + $ref: "#/components/schemas/SandboxState" + + ListedSandbox: + required: + - templateID + - sandboxID + - clientID + - startedAt + - cpuCount + - memoryMB + - endAt + - state + properties: + templateID: + type: string + description: Identifier of the template from which is the sandbox created + alias: + type: string + description: Alias of the template + sandboxID: + type: string + description: Identifier of the sandbox + clientID: + type: string + description: Identifier of the client + startedAt: + type: string + format: date-time + description: Time when the sandbox was started + endAt: + type: string + format: date-time + description: Time when the sandbox will expire + cpuCount: + $ref: "#/components/schemas/CPUCount" + memoryMB: + $ref: "#/components/schemas/MemoryMB" + metadata: + $ref: "#/components/schemas/SandboxMetadata" + state: + $ref: "#/components/schemas/SandboxState" RunningSandboxWithMetrics: required: @@ -338,6 +395,9 @@ components: type: boolean default: false description: Automatically pauses the sandbox after the timeout + secure: + type: boolean + description: Secure all system communication with sandbox metadata: $ref: "#/components/schemas/SandboxMetadata" envVars: @@ -487,7 +547,11 @@ components: - allocatedMemoryMiB - createFails - sandboxStartingCount + - version properties: + version: + type: string + description: Version of the orchestrator nodeID: type: string description: Identifier of the node @@ -521,7 +585,11 @@ components: - sandboxes - cachedBuilds - createFails + - version properties: + version: + type: string + description: Version of the orchestrator nodeID: type: string description: Identifier of the node @@ -531,7 +599,7 @@ components: type: array description: List of sandboxes running on the node items: - $ref: "#/components/schemas/RunningSandbox" + $ref: "#/components/schemas/ListedSandbox" cachedBuilds: type: array description: List of cached builds id on the node @@ -738,7 +806,7 @@ paths: type: array items: allOf: - - $ref: "#/components/schemas/RunningSandbox" + - $ref: "#/components/schemas/ListedSandbox" "401": $ref: "#/components/responses/401" "400": @@ -772,6 +840,64 @@ paths: "500": $ref: "#/components/responses/500" + /v2/sandboxes: + get: + description: List all sandboxes + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - name: metadata + in: query + description: Metadata query used to filter the sandboxes (e.g. "user=abc&app=prod"). Each key and values must be URL encoded. + required: false + schema: + type: string + - name: state + in: query + description: Filter sandboxes by one or more states + required: false + schema: + type: array + items: + $ref: "#/components/schemas/SandboxState" + style: form + explode: false + - name: nextToken + in: query + description: Cursor to start the list from + required: false + schema: + type: string + - name: limit + in: query + description: Maximum number of items to return per page + required: false + schema: + type: integer + format: int32 + minimum: 1 + default: 1000 + maximum: 1000 + responses: + "200": + description: Successfully returned all running sandboxes + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: "#/components/schemas/ListedSandbox" + "401": + $ref: "#/components/responses/401" + "400": + $ref: "#/components/responses/400" + "500": + $ref: "#/components/responses/500" + /sandboxes/metrics: get: description: List all running sandboxes with metrics @@ -867,7 +993,6 @@ paths: "500": $ref: "#/components/responses/500" - /sandboxes/{sandboxID}: get: description: Get a sandbox by id @@ -884,7 +1009,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/RunningSandbox" + $ref: "#/components/schemas/SandboxDetail" "404": $ref: "#/components/responses/404" "401": @@ -1076,6 +1201,8 @@ paths: application/json: schema: $ref: "#/components/schemas/Template" + "400": + $ref: "#/components/responses/400" "401": $ref: "#/components/responses/401" "500": @@ -1387,4 +1514,4 @@ paths: "404": $ref: "#/components/responses/404" "500": - $ref: "#/components/responses/500" \ No newline at end of file + $ref: "#/components/responses/500"