-
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 #1712
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
Author
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. Restated from #1702 (same commit, so nothing here could have changed) — and re-verified against the live API this run. 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: 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
Author
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 guard is well covered: the Two things it reaches that no test does:
|
||
| throw new InvalidArgumentError( | ||
| "network egressProxy must be an object with a string 'address' " + | ||
|
Contributor
Author
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. Restated from #1702. Small vocabulary nit: this 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
Author
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. Restated from #1702. Docs gap, since TASTE treats JSDoc and docstrings as part of the API. Two things this change establishes are written down only in the changeset:
|
||
| ...(egressProxy.password != null ? { password: egressProxy.password } : {}), | ||
|
Contributor
Author
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. Also new. Dropping the two credentials independently means a half-resolved pair quietly becomes a body the API accepts. {"egressProxy": {"address": "proxy.example.com:1080", "password": "p"}}Live, that clears schema validation: the response is What makes it worth raising rather than shrugging at: |
||
| } | ||
| } | ||
|
|
||
|
|
@@ -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 } : {}), | ||
|
Contributor
Author
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. New this pass, and the reason I read this hunk as half-done. The justification for normalizing
This function already returns if (!egressProxy || typeof egressProxy.address !== 'string') {
return undefined
}with |
||
| } | ||
| } | ||
|
|
||
|
|
||
| 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
Author
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 one mutant the suite does not catch. Reverting the Coverage cannot see this. The Python twin does pin it, with
Suggested change
I ran this: with |
||||||
| } 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
Author
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. Restated from #1702, where @claude found it independently — and re-confirmed live this run rather than by reading. This fix trades a
|
||
| # {'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 Extended reasoning...The bug: info = sandbox.get_info()
print(info.network.get("egress_proxy"))
# {'address': 'proxy.example.com:1080', 'username': 'proxy-user'}This PR's own stated purpose for touching this line is to fix a real defect: Why the existing fix doesn't prevent it: the fix only closes the gap between 'network exists but has no egress_proxy key' and 'network exists and has one' — it does nothing for 'network is absent entirely'. The JS example sitting directly above it in the same changelog entry already handles both levels correctly: Concrete walkthrough: 1) A sandbox is created without any The fix: guard the outer On the refutation: one reviewer argued this is out of scope because the example's own output comment shows a proxy configured, which requires a populated |
||
| ``` | ||
|
|
||
| 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
Author
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. For the record, the Python side of the read-path fix is fully pinned: dropping One asymmetry left in this mapper, which the PR does not need to fix but which is the same class of problem: the generated |
||
| 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.
Restated from #1702, with this run's output. This snippet does not 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:
The TypeScript half compiles clean (
tsc --noEmit, exit 0) becauseprocess.env.PROXY_USERisstring | undefinedandusername?: stringaccepts it.NotRequired[str]cannot express "explicitly absent", so the Python one is a type error.Please don't fix it by widening to
Optional[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 neverin the first example) or pick a form that checks, e.g. building the dict conditionally.