Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/egress-proxy-untyped-callers.md
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"),

Copy link
Copy Markdown
Contributor Author

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:

$ uv run ty check snippet.py
error[invalid-argument-type]: Argument to bound method `create` is incorrect
  Expected `SandboxNetworkOpts | None`,
  found `dict[… , … | dict[… , … | str | None]]`

The TypeScript half compiles clean (tsc --noEmit, exit 0) because process.env.PROXY_USER is string | undefined and username?: string accepts 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 is undefined, never null" 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: ignore or a cast, mirroring the as never in the first example) or pick a form that checks, e.g. building the dict conditionally.

},
},
)
```

`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.
2 changes: 1 addition & 1 deletion packages/js-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@

```python
info = sandbox.get_info()
print(info.network["egress_proxy"])
print(info.network.get("egress_proxy"))
# {'address': 'proxy.example.com:1080', 'username': 'proxy-user'}
```

Expand Down
35 changes: 22 additions & 13 deletions packages/js-sdk/src/sandbox/sandboxApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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:

POST /sandboxes  {"network":{"egressProxy":{}}}
400 validation error: … Error at "/network/egressProxy": … Error at "/address": property "address" is missing

POST /sandboxes  {"network":{"egressProxy":{"address":1080}}}
400 … Error at "/address": value must be a string

What the guard actually buys is enough on its own: the failure is local instead of a round trip, it is an InvalidArgumentError the caller can catch by type, and it matches _build_egress_proxy's InvalidArgumentException on identical input — which is the point of the PR. The one input where the API message really is misleading is the bare string, where the rebuilt {} yields "property address is missing" for a caller who never wrote an object at all; that narrower claim would be accurate.

if (typeof egressProxy.address !== 'string') {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard is well covered: the if is evaluated 11 times, the throw on 1011 fires 4 times — once per parametrized malformed case — and deleting the whole block fails exactly those 4 tests and nothing else. That last part matters: the four failures are all in the new parametrization, so the guard is not accidentally load-bearing for any pre-existing test.

Two things it reaches that no test does:

  1. buildNetworkEgress is also called from buildNetworkUpdateBody, so Sandbox.updateNetwork(id, { egressProxy: 'host:1080' as never }) now throws too. Python behaves identically via build_network_update_body_build_network_egress. Genuinely useful, and shared code so the risk is low, but the changeset only mentions create — worth one sentence there.
  2. The test asserts the error class, not the message. Python asserts match="egress_proxy". Since the argument for this change is that the API error "names neither the option the caller typed nor the mistake", a toThrow(/egressProxy/) would pin the property the change actually adds.

throw new InvalidArgumentError(
"network egressProxy must be an object with a string 'address' " +

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 network key for the caller to have typed. Verified in both SDKs:

Sandbox.updateNetwork(id, { egressProxy: 'proxy.example.com:1080' })
-> InvalidArgumentError: network egressProxy must be an object with a string 'address' (…)

build_network_update_body({"egress_proxy": "proxy.example.com:1080"})
-> InvalidArgumentException: network egress_proxy must be a dict with a string 'address' (…)

TASTE asks for the rule stated "in the option names the user typed". Dropping the leading network is accurate from both call sites, and buildIamBody's message just below already names its own path exactly (iam token 'x' must have …).

"(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 } : {}),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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:

  1. The new accepted spelling. SandboxEgressProxyOpts.username / .password still say only "Omit it for a proxy that takes no credentials" — but username: process.env.PROXY_USER on an unset variable is now equally supported, and that is the headline use case. Same for the Python docstrings in sandbox_api.py (~262 and ~268).
  2. The new failure mode. A malformed egressProxy now throws InvalidArgumentError / raises InvalidArgumentException, and nothing on the public surface says so. There is precedent in this very file — SandboxNetworkTransformContext.iam.tokens (~70) documents its {@link InvalidArgumentError} in prose, and iam.ts:30 uses @throws {@link InvalidArgumentError} — plus a natural home in SandboxNetworkOpts.egressProxy (~280) and the Python egress_proxy field docstring.

...(egressProxy.password != null ? { password: egressProxy.password } : {}),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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. PROXY_USER unset with PROXY_PASS set — the same environment-lookup pattern the changeset uses to motivate the change — now sends:

{"egressProxy": {"address": "proxy.example.com:1080", "password": "p"}}

Live, that clears schema validation: the response is 403 Egress proxy (network.egressProxy) is not enabled for this team, i.e. the team gate rather than a 400. I can't see past the gate with this key, so I can't say what the dialer does with a password and no username — only that the request is no longer rejected. Before this change the explicit-null spelling got a loud 400 … Value is not nullable.

What makes it worth raising rather than shrugging at: password's own JSDoc says "Only valid together with {@link SandboxEgressProxyOpts.username}", and neither SDK enforces that. Either that sentence should stop asserting a rule nothing checks, or the pair belongs in the same "invalid input would otherwise fail opaquely" carve-out this PR's address guard already relies on — one credential without the other is a constraint violation, statable in the option names the caller typed. The undefined spelling has always behaved this way, so this is a pre-existing shape the PR widens rather than one it introduces.

}
}

Expand Down Expand Up @@ -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
Expand All @@ -1066,9 +1077,7 @@ function fromApiEgressProxy(

return {
address: egressProxy.address,
...(egressProxy.username !== undefined
? { username: egressProxy.username }
: {}),
...(egressProxy.username != null ? { username: egressProxy.username } : {}),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 username is that a server contradicting the spec must not hand a null to a consumer whose type says otherwise — but address, the required field one line up, has no such guard. Probed on this head:

wire: { egressProxy: { address: null, username: 'u' } }
info.network.egressProxy  ->  { "address": null, "username": "u" }

wire: { egressProxy: {} }
info.network.egressProxy  ->  {}

SandboxEgressProxyInfo.address is string, so both are the same class of lie the username line was just added to prevent. Python matches on the first input ({'address': None}) and is worse on the second: the generated client raises a bare KeyError: 'address' from SandboxEgressProxyConfigType0.from_dict before _from_client_egress_proxy runs, so get_info() fails with an untyped error rather than anything in the SandboxException hierarchy.

This function already returns undefined for a proxy that isn't there, and that is the honest answer here too — a config whose address isn't a string is not a proxy the caller can act on:

if (!egressProxy || typeof egressProxy.address !== 'string') {
  return undefined
}

with _from_client_egress_proxy returning None on the same condition. Non-blocking: it needs a server that contradicts the spec — exactly as the username case does.

}
}

Expand Down
74 changes: 69 additions & 5 deletions packages/js-sdk/tests/sandbox/egressProxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the one mutant the suite does not catch. Reverting the password clause in buildEgressProxyBody from != null back to !== undefined leaves all 19 tests in this file green, because password: undefined reads the same either way. Only null distinguishes them, and no test supplies it.

Coverage cannot see this. sandboxApi.ts:1023 reports both cond-expr arms taken (1 hit on the include arm, 6 on the omit arm) and the line as 100% covered — the arms are exercised by 'proxy-password' and by absence, never by null.

The Python twin does pin it, with "password": None alongside "username": None in test_create_omits_credentials_that_are_none. Matching it is one word:

Suggested change
password: undefined,
password: null,

I ran this: with password: null the file still passes 19/19 on head, and the reverted-clause mutant fails with expected { …(2) } to deeply equal { address: 'proxy.example.com:1080' }. Keeping username: null and password: null together also mirrors the os.environ.get(...) motivation in the changeset, which applies to both credentials equally.

} 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 () => {
Expand Down Expand Up @@ -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 }],
Expand Down
2 changes: 1 addition & 1 deletion packages/python-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 KeyError for an AttributeError on exactly the input it was meant to handle. SandboxDetail.network is not required in the spec and really is absent for an ordinary sandbox:

POST /sandboxes {"templateID":"base"}  -> 201
GET  /sandboxes/<id>                   -> 200, no "network" key in the body

from_client_network_config(UNSET) returns None, so SandboxInfo.network is None and info.network.get("egress_proxy") raises AttributeError: 'NoneType' object has no attribute 'get' for "any sandbox without a proxy" — the case the description cites. The JS line above survives because info.network?.egressProxy guards both levels; the Python mirror needs (info.network or {}).get("egress_proxy"). The same snippet is duplicated at packages/js-sdk/CHANGELOG.md:95, since both changelogs carry the full shared changeset text.

# {'address': 'proxy.example.com:1080', 'username': 'proxy-user'}

Check warning on line 96 in packages/python-sdk/CHANGELOG.md

View check run for this annotation

Claude / Claude Code Review

Python get_info CHANGELOG example still raises when info.network is None

The Python `get_info` CHANGELOG snippet still raises if a sandbox has no network config at all: `info.network.get("egress_proxy")` fixes the KeyError case but `info.network` can itself be `None` (`from_client_network_config()` returns `None` when the API omits `network`), and `None.get(...)` raises `AttributeError`. The JS snippet right above guards both levels with `info.network?.egressProxy`; using `(info.network or {}).get("egress_proxy")` would match it.
Comment on lines 92 to 96

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The Python get_info CHANGELOG snippet still raises if a sandbox has no network config at all: info.network.get("egress_proxy") fixes the KeyError case but info.network can itself be None (from_client_network_config() returns None when the API omits network), and None.get(...) raises AttributeError. The JS snippet right above guards both levels with info.network?.egressProxy; using (info.network or {}).get("egress_proxy") would match it.

Extended reasoning...

The bug: packages/python-sdk/CHANGELOG.md (and the duplicate in packages/js-sdk/CHANGELOG.md) publishes this example for 2.41.0's get_info:

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: info.network["egress_proxy"] raised KeyError on any sandbox without a proxy configured, because SandboxNetworkInfo is total=False. Switching the inner access to .get(...) fixes that case. But SandboxInfo.network itself is typed Optional[SandboxNetworkInfo], and from_client_network_config() in packages/python-sdk/e2b/sandbox/sandbox_api.py returns None whenever the wire's network field is Unset (i.e. the API omits the network block entirely for that sandbox). _from_sandbox_detail passes that None straight through into SandboxInfo.network. So on a real get_info() call where the sandbox has no network configuration at all, info.network is None, and None.get("egress_proxy") raises AttributeError: 'NoneType' object has no attribute 'get' — a different, still-uncaught crash, just one level up from the one this PR fixes.

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: info.network?.egressProxy optional-chains through a possibly-undefined network as well as a possibly-missing egressProxy. The Python fix is therefore not at parity with its JS sibling, even though PR-wide parity between the two SDKs is explicitly the point of this PR ('match JS and Python...').

Concrete walkthrough: 1) A sandbox is created without any network option — the common case. 2) The API's sandbox-detail response omits the network field (Unset). 3) from_client_network_config(sandbox_detail.network) sees isinstance(network, Unset) and returns None. 4) SandboxInfo.network is set to that None. 5) A user follows the published changelog snippet verbatim: print(info.network.get("egress_proxy")). 6) Python raises AttributeError, not the graceful 'nothing printed' or 'None printed' a reader would expect from an example meant to show safe access.

The fix: guard the outer None the same way JS guards undefined, e.g. print((info.network or {}).get("egress_proxy")), or show the example against a sandbox where network is present (as the sample output already implies) while still guarding — either restores JS/Python parity for this snippet.

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 network, so the depicted scenario never hits the None branch. That's true for the literal happy path the sample output represents, but the code line itself is presented as reusable, copy-pasteable guidance independent of that specific fictitious sandbox, and a reader who copies it against their own (proxy-less, or even network-config-less) sandbox will hit exactly the AttributeError this PR's own goal ('match JS and Python', 'fix the broken snippet') was meant to eliminate. That said, I agree this is a documentation example rather than a runtime code path, so it does not block merge — filing as a nit for parity/correctness, matching all three verifiers' conclusion.

```

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.
Expand Down
16 changes: 10 additions & 6 deletions packages/python-sdk/e2b/sandbox/sandbox_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 and username is not None fails test_get_info_drops_a_none_username, and both arcs (913→914 and 913→916) are taken. The JS twin at sandboxApi.ts:1080 is caught too.

One asymmetry left in this mapper, which the PR does not need to fix but which is the same class of problem: the generated SandboxNetworkConfig._parse_egress_proxy swallows a parse failure in a bare except: pass and hands the raw dict through, so a wire egressProxy missing the spec-required address fails the isinstance check above and is reported as no proxy at all. JS's fromApiEgressProxy returns { address: undefined } for the same input, contradicting its own address: string type. Both need a server that violates the spec — exactly the premise this PR already accepted for username: null — so if you want the mappers to be airtight it is the remaining case, and neither SDK has a test for it.

result["username"] = username

return result

Expand Down
35 changes: 35 additions & 0 deletions packages/python-sdk/tests/shared/sandbox/test_egress_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ def test_create_rejects_a_malformed_egress_proxy(egress_proxy):
build_network_config(cast(Any, {"egress_proxy": egress_proxy}))


def test_create_omits_credentials_that_are_none():
# ``{"username": os.environ.get("PROXY_USER")}`` on an unset variable is the
# way this happens; a JSON null is rejected by the API.
body = build_network_config(
cast(
Any,
{
"egress_proxy": {
"address": "proxy.example.com:1080",
"username": None,
"password": None,
},
},
)
)
assert body is not None
assert body["egress_proxy"].to_dict() == {"address": "proxy.example.com:1080"}


def test_create_strips_unknown_egress_proxy_keys():
# An untyped caller can copy an extra key out of a config file; the API
# rejects unknown properties.
Expand Down Expand Up @@ -145,6 +164,22 @@ def test_get_info_reports_the_active_egress_proxy_without_the_password():
}


def test_get_info_drops_a_none_username():
# ``username`` is ``NotRequired[str]``, so absence is a missing key — a None
# from the wire has to be normalized rather than handed to a caller.
info = from_client_network_config(
SandboxNetworkConfig(
egress_proxy=ClientSandboxEgressProxyConfig(
address="proxy.example.com:1080",
username=cast(Any, None),
)
)
)

assert info is not None
assert info["egress_proxy"] == {"address": "proxy.example.com:1080"}


@pytest.mark.parametrize(
"egress_proxy",
[
Expand Down
Loading