-
Notifications
You must be signed in to change notification settings - Fork 1k
fix(sdk): match JS and Python on malformed and null egress proxy input #1702
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| --- | ||
| 'e2b': patch | ||
| '@e2b/python-sdk': patch | ||
| --- | ||
|
|
||
| Bring the JS and Python halves of `network.egressProxy` / `network["egress_proxy"]` back in line for callers that bypass the types, and stop `null` credentials from reaching the wire. | ||
|
|
||
| A malformed proxy now raises `InvalidArgumentError` / `InvalidArgumentException` in both SDKs. Before, only Python did; JS rebuilt the body from the known fields, so a proxy passed as a bare string sent `{}` and the caller got an API error about a field they never left out: | ||
|
|
||
| ```ts | ||
| // Now: InvalidArgumentError, naming the option you typed. | ||
| // Before: sent `"egressProxy": {}` and failed at the API. | ||
| await Sandbox.create({ | ||
| network: { egressProxy: 'proxy.example.com:1080' as never }, | ||
| }) | ||
| ``` | ||
|
|
||
| A `username` or `password` that is `null` / `None` is treated as "no credentials" rather than serialized as a JSON null the API rejects — the same reading both SDKs already gave `egressProxy: null` itself: | ||
|
|
||
| ```ts | ||
| await Sandbox.create({ | ||
| network: { | ||
| egressProxy: { | ||
| address: 'proxy.example.com:1080', | ||
| // Unset in the environment; the proxy takes no credentials. | ||
| username: process.env.PROXY_USER, | ||
| }, | ||
| }, | ||
| }) | ||
| ``` | ||
|
|
||
| ```python | ||
| Sandbox.create( | ||
| network={ | ||
| "egress_proxy": { | ||
| "address": "proxy.example.com:1080", | ||
| "username": os.environ.get("PROXY_USER"), | ||
| }, | ||
| }, | ||
| ) | ||
| ``` | ||
|
|
||
| `getInfo` / `get_info` normalizes a `null` `username` the same way, so `SandboxEgressProxyInfo.username` is `undefined` / an absent key rather than a null that its type says cannot be there. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -997,20 +997,30 @@ function resolveRulesForBody( | |
| /** | ||
| * Rebuild the proxy config from the known fields so stray properties on the | ||
| * caller's object never reach the wire and a later mutation of it cannot alter | ||
| * the in-flight request. Validation is the server's — it is the only side that | ||
| * can tell whether the address resolves, and to where. | ||
| * the in-flight request. Address reachability is the server's — it is the only | ||
| * side that can tell whether the address resolves, and to where. | ||
| */ | ||
| function buildEgressProxyBody( | ||
| egressProxy: SandboxEgressProxyOpts | ||
| ): components['schemas']['SandboxEgressProxyConfig'] { | ||
| // Re-check at runtime for callers that bypass the type — rebuilding from the | ||
| // known fields drops an address that isn't there, and the API error for the | ||
| // resulting `{}` names neither the option the caller typed nor the mistake. | ||
| // Python raises `InvalidArgumentException` on the same input. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The guard is right, but this justification isn't — and unlike the PR description, a code comment outlives the diff. The API error does name both the option and the mistake. Live, with the pre-PR body: and What the guard actually buys is enough on its own: the failure is local instead of a round trip, it is an |
||
| if (typeof egressProxy.address !== 'string') { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional, and the precedent cuts toward what you did — flagging it only because "match JS and Python" is the title. Python's guard also requires a class Cfg:
address = "proxy.example.com:1080"
build_network_config({"egress_proxy": Cfg()}) # InvalidArgumentException// accepted: sends { address: 'proxy.example.com:1080' }
await Sandbox.create('base', { network: { egressProxy: new Cfg() } as never })This file has both styles: |
||
| throw new InvalidArgumentError( | ||
| "network egressProxy must be an object with a string 'address' " + | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Small vocabulary nit: the same guard is reached from the update path, where there is no TASTE asks for the rule stated "in the option names the user typed". Dropping the leading |
||
| "(e.g. 'proxy.example.com:1080')." | ||
| ) | ||
| } | ||
|
|
||
| return { | ||
| address: egressProxy.address, | ||
| ...(egressProxy.username !== undefined | ||
| ? { username: egressProxy.username } | ||
| : {}), | ||
| ...(egressProxy.password !== undefined | ||
| ? { password: egressProxy.password } | ||
| : {}), | ||
| // `!= null` so a credential read out of an unset environment variable | ||
| // reads as "no credentials" rather than reaching the wire as JSON null, | ||
| // which the API rejects. Same reasoning as `egressProxy: null` itself. | ||
| ...(egressProxy.username != null ? { username: egressProxy.username } : {}), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Docs gap, since TASTE treats JSDoc/docstrings as part of the API. Two things this change establishes are documented only in the changeset:
|
||
| ...(egressProxy.password != null ? { password: egressProxy.password } : {}), | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -1054,8 +1064,9 @@ function buildNetworkEgress( | |
|
|
||
| /** | ||
| * Map the wire proxy config into the SDK-owned shape: `password` is dropped | ||
| * because the API never returns it, and the wire's `null` for "no proxy" is | ||
| * normalized so the union never reaches a consumer. | ||
| * because the API never returns it, and the wire's `null` — for "no proxy" and | ||
| * for an anonymous proxy's `username` alike — is normalized so it never reaches | ||
| * a consumer typed to see `undefined`. | ||
| */ | ||
| function fromApiEgressProxy( | ||
| egressProxy: components['schemas']['SandboxEgressProxyConfig'] | undefined | ||
|
|
@@ -1066,9 +1077,7 @@ function fromApiEgressProxy( | |
|
|
||
| return { | ||
| address: egressProxy.address, | ||
| ...(egressProxy.username !== undefined | ||
| ? { username: egressProxy.username } | ||
| : {}), | ||
| ...(egressProxy.username != null ? { username: egressProxy.username } : {}), | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -2,7 +2,7 @@ import { afterAll, afterEach, beforeAll, expect, test } from 'vitest' | |||||
| import { http, HttpResponse } from 'msw' | ||||||
| import { setupServer } from 'msw/node' | ||||||
|
|
||||||
| import { Sandbox } from '../../src' | ||||||
| import { InvalidArgumentError, Sandbox } from '../../src' | ||||||
| import { TEST_API_KEY, apiUrl } from '../setup' | ||||||
|
|
||||||
| const sandboxId = 'test-sandbox-id' | ||||||
|
|
@@ -101,14 +101,64 @@ test('Sandbox.create combines the egress proxy with allow and deny lists', async | |||||
| }) | ||||||
| }) | ||||||
|
|
||||||
| test('Sandbox.create omits the egress proxy when not provided', async () => { | ||||||
| test.for([ | ||||||
| ['omitted', { allowOut: ['api.example.com'] }], | ||||||
| // Untyped callers spell "no proxy" as null; Python treats an explicit None | ||||||
| // the same way. | ||||||
| ['null', { egressProxy: null }], | ||||||
| ])( | ||||||
| 'Sandbox.create omits the egress proxy when it is %s', | ||||||
| async ([, network]: [string, Record<string, any>]) => { | ||||||
| await Sandbox.create('base', { | ||||||
| apiKey: TEST_API_KEY, | ||||||
| network, | ||||||
| }) | ||||||
|
|
||||||
| expect(lastCreateBody?.network).toBeDefined() | ||||||
| expect(lastCreateBody?.network).not.toHaveProperty('egressProxy') | ||||||
| } | ||||||
| ) | ||||||
|
|
||||||
| test.for([ | ||||||
| // An empty object is falsy but present — it must not silently disable | ||||||
| // tunneling. Match Python: fail loudly. | ||||||
| ['empty', {}], | ||||||
| ['missing-address', { username: 'proxy-user' }], | ||||||
| ['non-string-address', { address: 1080 }], | ||||||
| ['string', 'proxy.example.com:1080'], | ||||||
| ])( | ||||||
| 'Sandbox.create rejects a %s egress proxy', | ||||||
| async ([, egressProxy]: [string, unknown]) => { | ||||||
| // Rebuilding from the known fields drops an address that isn't there, so | ||||||
| // without this the caller gets an API error about a `{}` they never wrote. | ||||||
| await expect( | ||||||
| Sandbox.create('base', { | ||||||
| apiKey: TEST_API_KEY, | ||||||
| network: { egressProxy } as never, | ||||||
| }) | ||||||
| ).rejects.toThrow(InvalidArgumentError) | ||||||
|
|
||||||
| expect(lastCreateBody).toBeUndefined() | ||||||
| } | ||||||
| ) | ||||||
|
|
||||||
| test('Sandbox.create omits credentials that are null', async () => { | ||||||
| // `{ username: process.env.PROXY_USER }` on an unset variable is the way | ||||||
| // this happens; a JSON null is rejected by the API. | ||||||
| await Sandbox.create('base', { | ||||||
| apiKey: TEST_API_KEY, | ||||||
| network: { allowOut: ['api.example.com'] }, | ||||||
| network: { | ||||||
| egressProxy: { | ||||||
| address: 'proxy.example.com:1080', | ||||||
| username: null, | ||||||
| password: undefined, | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the surviving mutant:
It can't fail either way, in fact: One character fixes it:
Suggested change
Verified both directions: with that change the |
||||||
| } as never, | ||||||
| }, | ||||||
| }) | ||||||
|
|
||||||
| expect(lastCreateBody?.network).toBeDefined() | ||||||
| expect(lastCreateBody?.network).not.toHaveProperty('egressProxy') | ||||||
| expect(lastCreateBody?.network.egressProxy).toEqual({ | ||||||
| address: 'proxy.example.com:1080', | ||||||
| }) | ||||||
| }) | ||||||
|
|
||||||
| test('Sandbox.create strips unknown egress proxy properties', async () => { | ||||||
|
|
@@ -199,6 +249,20 @@ test('getInfo drops a password the API unexpectedly returns', async () => { | |||||
| }) | ||||||
| }) | ||||||
|
|
||||||
| test('getInfo drops a null username', async () => { | ||||||
| // `username?: string` says absence is `undefined`, so a null from the wire | ||||||
| // has to be normalized rather than handed to a consumer. | ||||||
| sandboxNetwork = { | ||||||
| egressProxy: { address: 'proxy.example.com:1080', username: null }, | ||||||
| } | ||||||
|
|
||||||
| const info = await Sandbox.getInfo(sandboxId, { apiKey: TEST_API_KEY }) | ||||||
|
|
||||||
| expect(info.network?.egressProxy).toEqual({ | ||||||
| address: 'proxy.example.com:1080', | ||||||
| }) | ||||||
| }) | ||||||
|
|
||||||
| test.for([ | ||||||
| ['omitted', {}], | ||||||
| ['null', { egressProxy: null }], | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -89,11 +89,11 @@ | |
| console.log(info.network?.egressProxy) | ||
| // { address: 'proxy.example.com:1080', username: 'proxy-user' } | ||
| ``` | ||
|
|
||
| ```python | ||
| info = sandbox.get_info() | ||
| print(info.network["egress_proxy"]) | ||
| print(info.network.get("egress_proxy")) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirming @claude's note here with a live check rather than a reading: the fix trades a sbx = Sandbox.create(timeout=30)
print(sbx.get_info().network) # Noneso |
||
| # {'address': 'proxy.example.com:1080', 'username': 'proxy-user'} | ||
|
Check warning on line 96 in packages/python-sdk/CHANGELOG.md
|
||
|
Comment on lines
92
to
96
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The Python changelog fix for Extended reasoning...What the bug is: This PR's stated goal for the Code path: Why existing code doesn't prevent it: The PR only patched the inner lookup (missing key inside an existing dict), not the outer optionality (the dict itself being absent). These are two independent failure modes stacked on the same line, and the fix addressed only the one that was reported. The adjacent JS half of the same changelog block already handles both: Impact: This is scoped to a documentation code sample in Step-by-step proof:
Fix: Change the Python snippet to Severity: This is a |
||
| ``` | ||
|
|
||
| Egress fails closed: when the proxy is unreachable or does not speak SOCKS5, outbound connections fail rather than falling back to a direct connection. The address is validated server-side when the sandbox is created — a rejected create leaves nothing behind. Available on E2B Cloud and in BYOC deployments; a sandbox that names a proxy on a deployment built from the open source `e2b-dev/infra` repository is rejected as unsupported. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -671,9 +671,12 @@ def _build_egress_proxy( | |
| ) | ||
|
|
||
| body = ClientSandboxEgressProxyConfig(address=egress_proxy["address"]) | ||
| if "username" in egress_proxy: | ||
| # `is not None` so a credential read out of an unset environment variable | ||
| # reads as "no credentials" rather than reaching the wire as JSON null, | ||
| # which the API rejects. Same reasoning as ``"egress_proxy": None`` itself. | ||
| if egress_proxy.get("username") is not None: | ||
| body.username = egress_proxy["username"] | ||
| if "password" in egress_proxy: | ||
| if egress_proxy.get("password") is not None: | ||
| body.password = egress_proxy["password"] | ||
|
|
||
| return body | ||
|
|
@@ -899,15 +902,16 @@ def _from_client_egress_proxy( | |
| ) -> Optional[SandboxEgressProxyInfo]: | ||
| """ | ||
| Map the wire proxy config into the SDK-owned shape: ``password`` is dropped | ||
| because the API never returns it, and the wire's ``None`` for "no proxy" | ||
| becomes an absent key. | ||
| because the API never returns it, and the wire's ``None`` — for "no proxy" | ||
| and for an anonymous proxy's ``username`` alike — becomes an absent key. | ||
| """ | ||
| if not isinstance(egress_proxy, ClientSandboxEgressProxyConfig): | ||
| return None | ||
|
|
||
| result: SandboxEgressProxyInfo = {"address": egress_proxy.address} | ||
| if not isinstance(egress_proxy.username, Unset): | ||
| result["username"] = egress_proxy.username | ||
| username = egress_proxy.username | ||
| if not isinstance(username, Unset) and username is not None: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This line is where the PR's strongest coverage evidence lives. Branch arc Two caveats worth recording so the numbers aren't over-read:
|
||
| result["username"] = username | ||
|
|
||
| return result | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This snippet doesn't type-check under the repo's own checker, while its JS twin above does — worth fixing before it is copied verbatim into both published CHANGELOGs:
process.env.PROXY_USERisstring | undefined, whichusername?: stringaccepts, so the TypeScript example is legal.NotRequired[str]cannot express "explicitly absent", so the Python one isn't. Please don't fix it by widening toOptional[str]— TASTE's "absence isundefined, nevernull" rules that out, and the runtime tolerance is deliberately a safety net for callers who bypass the types, which is what the changeset's own opening sentence says. Either frame the Python snippet as the untyped-caller case (a# type: ignoreor acast, mirroring theas neveryou used in the first example) or pick a form that checks, e.g. building the dict conditionally.