From b9f56c28b7076b0302501b68d83e125bdc782775 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 13 Mar 2026 11:10:36 -0700 Subject: [PATCH 1/6] disambiguate permissions issues on git commands --- packages/js-sdk/src/sandbox/git/utils.ts | 5 +- .../tests/sandbox/git/authDetection.test.ts | 55 +++++++++++++++++++ packages/python-sdk/e2b/sandbox/_git/auth.py | 5 +- .../python-sdk/tests/shared/git/test_auth.py | 47 ++++++++++++++++ 4 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 packages/js-sdk/tests/sandbox/git/authDetection.test.ts create mode 100644 packages/python-sdk/tests/shared/git/test_auth.py diff --git a/packages/js-sdk/src/sandbox/git/utils.ts b/packages/js-sdk/src/sandbox/git/utils.ts index ad60018943..66288aa5c6 100644 --- a/packages/js-sdk/src/sandbox/git/utils.ts +++ b/packages/js-sdk/src/sandbox/git/utils.ts @@ -516,8 +516,11 @@ export function isAuthFailure(err: unknown): boolean { 'terminal prompts disabled', 'could not read username', 'invalid username or password', + 'permission denied (publickey', + 'permission denied (keyboard-interactive', + 'permission to ', + 'requested url returned error: 403', 'access denied', - 'permission denied', 'not authorized', ] diff --git a/packages/js-sdk/tests/sandbox/git/authDetection.test.ts b/packages/js-sdk/tests/sandbox/git/authDetection.test.ts new file mode 100644 index 0000000000..0c456e5a1c --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/authDetection.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test, vi } from 'vitest' + +import { GitAuthError } from '../../../src/errors' +import { CommandExitError } from '../../../src/sandbox/commands/commandHandle' +import { Git } from '../../../src/sandbox/git' +import { isAuthFailure } from '../../../src/sandbox/git/utils' + +describe('Git auth detection', () => { + test('does not classify filesystem permission errors as auth failures', () => { + const err = new CommandExitError({ + exitCode: 128, + error: "fatal: could not create work tree dir '/home/workspace': Permission denied", + stdout: '', + stderr: + "fatal: could not create work tree dir '/home/workspace': Permission denied", + }) + + expect(isAuthFailure(err)).toBe(false) + }) + + test('classifies ssh publickey failures as auth failures', () => { + const err = new CommandExitError({ + exitCode: 128, + error: 'git@github.com: Permission denied (publickey).', + stdout: '', + stderr: 'git@github.com: Permission denied (publickey).', + }) + + expect(isAuthFailure(err)).toBe(true) + }) + + test('clone preserves path permission failures instead of raising GitAuthError', async () => { + const err = new CommandExitError({ + exitCode: 128, + error: "fatal: could not create work tree dir '/home/workspace': Permission denied", + stdout: '', + stderr: + "fatal: could not create work tree dir '/home/workspace': Permission denied", + }) + const git = new Git({ + run: vi.fn().mockRejectedValue(err), + } as any) + + await expect( + git.clone('https://github.com/e2b-dev/e2b.git', { + path: '/home/workspace', + }) + ).rejects.not.toBeInstanceOf(GitAuthError) + await expect( + git.clone('https://github.com/e2b-dev/e2b.git', { + path: '/home/workspace', + }) + ).rejects.toBe(err) + }) +}) diff --git a/packages/python-sdk/e2b/sandbox/_git/auth.py b/packages/python-sdk/e2b/sandbox/_git/auth.py index a93511f48d..9db1ab8cd5 100644 --- a/packages/python-sdk/e2b/sandbox/_git/auth.py +++ b/packages/python-sdk/e2b/sandbox/_git/auth.py @@ -67,8 +67,11 @@ def is_auth_failure(err: Exception) -> bool: "terminal prompts disabled", "could not read username", "invalid username or password", + "permission denied (publickey", + "permission denied (keyboard-interactive", + "permission to ", + "requested url returned error: 403", "access denied", - "permission denied", "not authorized", ] return any(snippet in message for snippet in auth_snippets) diff --git a/packages/python-sdk/tests/shared/git/test_auth.py b/packages/python-sdk/tests/shared/git/test_auth.py new file mode 100644 index 0000000000..80121b9e87 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_auth.py @@ -0,0 +1,47 @@ +import pytest + +from e2b.exceptions import GitAuthException +from e2b.sandbox._git.auth import is_auth_failure +from e2b.sandbox.commands.command_handle import CommandExitException +from e2b.sandbox_sync.git import Git + + +def _command_exit(stderr: str): + return CommandExitException( + stderr=stderr, + stdout="", + exit_code=128, + error=stderr, + ) + + +def test_is_auth_failure_ignores_filesystem_permission_errors(): + err = _command_exit( + "fatal: could not create work tree dir '/home/workspace': Permission denied" + ) + + assert is_auth_failure(err) is False + + +def test_is_auth_failure_detects_ssh_publickey_errors(): + err = _command_exit("git@github.com: Permission denied (publickey).") + + assert is_auth_failure(err) is True + + +def test_clone_preserves_path_permission_failures(): + err = _command_exit( + "fatal: could not create work tree dir '/home/workspace': Permission denied" + ) + + class FailingCommands: + def run(self, *args, **kwargs): + raise err + + git = Git(FailingCommands()) + + with pytest.raises(CommandExitException) as exc: + git.clone("https://github.com/e2b-dev/e2b.git", "/home/workspace") + + assert exc.value is err + assert not isinstance(exc.value, GitAuthException) From 92a6b0b6c71285b85fe09c48a9279690a6553be6 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 13 Mar 2026 11:28:15 -0700 Subject: [PATCH 2/6] try different permissions issues --- packages/js-sdk/src/errors.ts | 10 +++ packages/js-sdk/src/index.ts | 1 + packages/js-sdk/src/sandbox/git/index.ts | 12 +++ packages/js-sdk/src/sandbox/git/utils.ts | 46 ++++++++++ .../tests/sandbox/git/authDetection.test.ts | 89 +++++++++++++++---- packages/python-sdk/e2b/__init__.py | 2 + packages/python-sdk/e2b/exceptions.py | 8 ++ .../python-sdk/e2b/sandbox/_git/__init__.py | 4 + packages/python-sdk/e2b/sandbox/_git/auth.py | 52 +++++++++++ packages/python-sdk/e2b/sandbox_async/git.py | 15 ++++ packages/python-sdk/e2b/sandbox_sync/git.py | 15 ++++ .../python-sdk/tests/shared/git/test_auth.py | 55 +++++++++--- 12 files changed, 279 insertions(+), 30 deletions(-) diff --git a/packages/js-sdk/src/errors.ts b/packages/js-sdk/src/errors.ts index ad51c1f2a4..7b6c033f7b 100644 --- a/packages/js-sdk/src/errors.ts +++ b/packages/js-sdk/src/errors.ts @@ -88,6 +88,16 @@ export class GitAuthError extends AuthenticationError { } } +/** + * Thrown when git fails because the repository path is not writable. + */ +export class GitPermissionError extends SandboxError { + constructor(message: string, stackTrace?: string) { + super(message, stackTrace) + this.name = 'GitPermissionError' + } +} + /** * Thrown when git upstream tracking is missing. */ diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 17d4a97527..2ab94c47b1 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -6,6 +6,7 @@ export type { ConnectionOpts, Username } from './connectionConfig' export { AuthenticationError, GitAuthError, + GitPermissionError, GitUpstreamError, InvalidArgumentError, NotEnoughSpaceError, diff --git a/packages/js-sdk/src/sandbox/git/index.ts b/packages/js-sdk/src/sandbox/git/index.ts index c0ec3a17ea..0bfa7d678b 100644 --- a/packages/js-sdk/src/sandbox/git/index.ts +++ b/packages/js-sdk/src/sandbox/git/index.ts @@ -1,5 +1,6 @@ import { GitAuthError, + GitPermissionError, GitUpstreamError, InvalidArgumentError, } from '../../errors' @@ -9,6 +10,7 @@ import { Commands } from '../commands' import { buildAuthErrorMessage, buildGitCommand, + buildPermissionErrorMessage, buildPushArgs, buildUpstreamErrorMessage, GitBranches, @@ -17,6 +19,7 @@ import { getRepoPathForScope, getScopeFlag, isAuthFailure, + isPermissionFailure, isMissingUpstream, parseGitBranches, parseGitStatus, @@ -360,6 +363,9 @@ export class Git { buildAuthErrorMessage('clone', Boolean(username) && !password) ) } + if (isPermissionFailure(err)) { + throw new GitPermissionError(buildPermissionErrorMessage('clone')) + } throw err } } @@ -723,6 +729,9 @@ export class Git { buildAuthErrorMessage('push', Boolean(username) && !password) ) } + if (isPermissionFailure(err)) { + throw new GitPermissionError(buildPermissionErrorMessage('push')) + } if (isMissingUpstream(err)) { throw new GitUpstreamError(buildUpstreamErrorMessage('push')) } @@ -784,6 +793,9 @@ export class Git { buildAuthErrorMessage('pull', Boolean(username) && !password) ) } + if (isPermissionFailure(err)) { + throw new GitPermissionError(buildPermissionErrorMessage('pull')) + } if (isMissingUpstream(err)) { throw new GitUpstreamError(buildUpstreamErrorMessage('pull')) } diff --git a/packages/js-sdk/src/sandbox/git/utils.ts b/packages/js-sdk/src/sandbox/git/utils.ts index 66288aa5c6..85ec2a6435 100644 --- a/packages/js-sdk/src/sandbox/git/utils.ts +++ b/packages/js-sdk/src/sandbox/git/utils.ts @@ -527,6 +527,52 @@ export function isAuthFailure(err: unknown): boolean { return authSnippets.some((snippet) => message.includes(snippet)) } +export function isPermissionFailure(err: unknown): boolean { + if (!(err instanceof CommandExitError)) { + return false + } + + const message = `${err.stderr}\n${err.stdout}`.toLowerCase() + const permissionSignals = [ + 'permission denied', + 'operation not permitted', + 'read-only file system', + ] + const filesystemContexts = [ + 'could not create work tree dir', + 'repository database', + '.git/index.lock', + '.git/config.lock', + '.git/fetch_head', + '.git/head.lock', + ] + const directSnippets = [ + 'insufficient permission for adding an object to repository database', + ] + + return ( + directSnippets.some((snippet) => message.includes(snippet)) || + (permissionSignals.some((signal) => message.includes(signal)) && + filesystemContexts.some((context) => message.includes(context))) + ) +} + +export function buildPermissionErrorMessage( + action: 'clone' | 'push' | 'pull' +): string { + if (action === 'clone') { + return ( + 'Git clone failed because the target path is not writable by the current user. ' + + 'Try using a writable path or running the command as the user that owns that path.' + ) + } + + return ( + `Git ${action} failed because the repository path is not writable by the current user. ` + + 'Try using a writable path or running the command as the user that owns the repository files.' + ) +} + export function getScopeFlag(scope: GitConfigScope): `--${GitConfigScope}` { if (scope !== 'global' && scope !== 'local' && scope !== 'system') { throw new InvalidArgumentError( diff --git a/packages/js-sdk/tests/sandbox/git/authDetection.test.ts b/packages/js-sdk/tests/sandbox/git/authDetection.test.ts index 0c456e5a1c..8718832f3a 100644 --- a/packages/js-sdk/tests/sandbox/git/authDetection.test.ts +++ b/packages/js-sdk/tests/sandbox/git/authDetection.test.ts @@ -1,23 +1,40 @@ import { describe, expect, test, vi } from 'vitest' -import { GitAuthError } from '../../../src/errors' +import { GitAuthError, GitPermissionError } from '../../../src/errors' import { CommandExitError } from '../../../src/sandbox/commands/commandHandle' import { Git } from '../../../src/sandbox/git' -import { isAuthFailure } from '../../../src/sandbox/git/utils' +import { + buildPermissionErrorMessage, + isAuthFailure, + isPermissionFailure, +} from '../../../src/sandbox/git/utils' + +function createFilesystemPermissionError(stderr: string) { + return new CommandExitError({ + exitCode: 128, + error: stderr, + stdout: '', + stderr, + }) +} describe('Git auth detection', () => { test('does not classify filesystem permission errors as auth failures', () => { - const err = new CommandExitError({ - exitCode: 128, - error: "fatal: could not create work tree dir '/home/workspace': Permission denied", - stdout: '', - stderr: - "fatal: could not create work tree dir '/home/workspace': Permission denied", - }) + const err = createFilesystemPermissionError( + "fatal: could not create work tree dir '/home/workspace': Permission denied" + ) expect(isAuthFailure(err)).toBe(false) }) + test('classifies filesystem permission errors separately from auth failures', () => { + const err = createFilesystemPermissionError( + "fatal: cannot open .git/FETCH_HEAD: Permission denied" + ) + + expect(isPermissionFailure(err)).toBe(true) + }) + test('classifies ssh publickey failures as auth failures', () => { const err = new CommandExitError({ exitCode: 128, @@ -29,14 +46,10 @@ describe('Git auth detection', () => { expect(isAuthFailure(err)).toBe(true) }) - test('clone preserves path permission failures instead of raising GitAuthError', async () => { - const err = new CommandExitError({ - exitCode: 128, - error: "fatal: could not create work tree dir '/home/workspace': Permission denied", - stdout: '', - stderr: - "fatal: could not create work tree dir '/home/workspace': Permission denied", - }) + test('clone raises GitPermissionError for path permission failures', async () => { + const err = createFilesystemPermissionError( + "fatal: could not create work tree dir '/home/workspace': Permission denied" + ) const git = new Git({ run: vi.fn().mockRejectedValue(err), } as any) @@ -45,11 +58,49 @@ describe('Git auth detection', () => { git.clone('https://github.com/e2b-dev/e2b.git', { path: '/home/workspace', }) - ).rejects.not.toBeInstanceOf(GitAuthError) + ).rejects.toBeInstanceOf(GitPermissionError) await expect( git.clone('https://github.com/e2b-dev/e2b.git', { path: '/home/workspace', }) - ).rejects.toBe(err) + ).rejects.toMatchObject({ + message: buildPermissionErrorMessage('clone'), + }) + }) + + test('push raises GitPermissionError for repository write failures', async () => { + const err = createFilesystemPermissionError( + "error: unable to create '.git/index.lock': Permission denied" + ) + const git = new Git({ + run: vi.fn().mockRejectedValue(err), + } as any) + + await expect( + git.push('/repo', { remote: 'origin', branch: 'main' }) + ).rejects.toBeInstanceOf(GitPermissionError) + await expect( + git.push('/repo', { remote: 'origin', branch: 'main' }) + ).rejects.toMatchObject({ + message: buildPermissionErrorMessage('push'), + }) + }) + + test('pull raises GitPermissionError for repository write failures', async () => { + const err = createFilesystemPermissionError( + "error: cannot open .git/FETCH_HEAD: Permission denied" + ) + const git = new Git({ + run: vi.fn().mockRejectedValue(err), + } as any) + + await expect( + git.pull('/repo', { remote: 'origin', branch: 'main' }) + ).rejects.toBeInstanceOf(GitPermissionError) + await expect( + git.pull('/repo', { remote: 'origin', branch: 'main' }) + ).rejects.toMatchObject({ + message: buildPermissionErrorMessage('pull'), + }) }) }) diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index 264fd5deee..c43615ccbe 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -36,6 +36,7 @@ from .exceptions import ( AuthenticationException, GitAuthException, + GitPermissionException, GitUpstreamException, BuildException, FileUploadException, @@ -124,6 +125,7 @@ "NotFoundException", "AuthenticationException", "GitAuthException", + "GitPermissionException", "GitUpstreamException", "InvalidArgumentException", "NotEnoughSpaceException", diff --git a/packages/python-sdk/e2b/exceptions.py b/packages/python-sdk/e2b/exceptions.py index 75f5f6abf0..7bbcf35867 100644 --- a/packages/python-sdk/e2b/exceptions.py +++ b/packages/python-sdk/e2b/exceptions.py @@ -79,6 +79,14 @@ class GitAuthException(AuthenticationException): pass +class GitPermissionException(SandboxException): + """ + Raised when git cannot write to the target path or repository. + """ + + pass + + class GitUpstreamException(SandboxException): """ Raised when git upstream tracking is missing. diff --git a/packages/python-sdk/e2b/sandbox/_git/__init__.py b/packages/python-sdk/e2b/sandbox/_git/__init__.py index 8c005e0f62..f289b88921 100644 --- a/packages/python-sdk/e2b/sandbox/_git/__init__.py +++ b/packages/python-sdk/e2b/sandbox/_git/__init__.py @@ -23,8 +23,10 @@ ) from e2b.sandbox._git.auth import ( build_auth_error_message, + build_permission_error_message, build_upstream_error_message, is_auth_failure, + is_permission_failure, is_missing_upstream, strip_credentials, with_credentials, @@ -41,6 +43,7 @@ __all__ = [ "build_add_args", "build_auth_error_message", + "build_permission_error_message", "build_branches_args", "build_checkout_branch_args", "build_clone_plan", @@ -63,6 +66,7 @@ "build_upstream_error_message", "derive_repo_dir_from_url", "is_auth_failure", + "is_permission_failure", "is_missing_upstream", "parse_git_branches", "parse_git_status", diff --git a/packages/python-sdk/e2b/sandbox/_git/auth.py b/packages/python-sdk/e2b/sandbox/_git/auth.py index 9db1ab8cd5..10b9b29052 100644 --- a/packages/python-sdk/e2b/sandbox/_git/auth.py +++ b/packages/python-sdk/e2b/sandbox/_git/auth.py @@ -77,6 +77,39 @@ def is_auth_failure(err: Exception) -> bool: return any(snippet in message for snippet in auth_snippets) +def is_permission_failure(err: Exception) -> bool: + """ + Check whether a git command failed because the target path is not writable. + + :param err: Exception raised by a git command + :return: True when the error matches common filesystem permission failures + """ + if not isinstance(err, CommandExitException): + return False + + message = f"{err.stderr}\n{err.stdout}".lower() + permission_signals = [ + "permission denied", + "operation not permitted", + "read-only file system", + ] + filesystem_contexts = [ + "could not create work tree dir", + "repository database", + ".git/index.lock", + ".git/config.lock", + ".git/fetch_head", + ".git/head.lock", + ] + direct_snippets = [ + "insufficient permission for adding an object to repository database", + ] + return any(snippet in message for snippet in direct_snippets) or ( + any(signal in message for signal in permission_signals) + and any(context in message for context in filesystem_contexts) + ) + + def is_missing_upstream(err: Exception) -> bool: """ Check whether a git command failed due to missing upstream tracking. @@ -114,6 +147,25 @@ def build_auth_error_message(action: str, missing_password: bool) -> str: return f"Git {action} requires credentials for private repositories." +def build_permission_error_message(action: str) -> str: + """ + Build a git filesystem permission error message for the given action. + + :param action: Git action name + :return: Error message string + """ + if action == "clone": + return ( + "Git clone failed because the target path is not writable by the current user. " + "Try using a writable path or running the command as the user that owns that path." + ) + + return ( + f"Git {action} failed because the repository path is not writable by the current user. " + "Try using a writable path or running the command as the user that owns the repository files." + ) + + def build_upstream_error_message(action: str) -> str: """ Build a git upstream tracking error message for the given action. diff --git a/packages/python-sdk/e2b/sandbox_async/git.py b/packages/python-sdk/e2b/sandbox_async/git.py index 9b9f3a686e..e339ce2f75 100644 --- a/packages/python-sdk/e2b/sandbox_async/git.py +++ b/packages/python-sdk/e2b/sandbox_async/git.py @@ -2,6 +2,7 @@ from e2b.exceptions import ( GitAuthException, + GitPermissionException, GitUpstreamException, InvalidArgumentException, ) @@ -11,6 +12,7 @@ GitStatus, build_add_args, build_auth_error_message, + build_permission_error_message, build_branches_args, build_checkout_branch_args, build_clone_plan, @@ -32,6 +34,7 @@ build_status_args, build_upstream_error_message, is_auth_failure, + is_permission_failure, is_missing_upstream, parse_git_branches, parse_git_status, @@ -323,6 +326,10 @@ async def attempt_clone( raise GitAuthException( build_auth_error_message("clone", bool(username) and not password) ) from err + if is_permission_failure(err): + raise GitPermissionException( + build_permission_error_message("clone") + ) from err raise async def init( @@ -807,6 +814,10 @@ async def push( raise GitAuthException( build_auth_error_message("push", bool(username) and not password) ) from err + if is_permission_failure(err): + raise GitPermissionException( + build_permission_error_message("push") + ) from err if is_missing_upstream(err): raise GitUpstreamException( build_upstream_error_message("push") @@ -893,6 +904,10 @@ async def pull( raise GitAuthException( build_auth_error_message("pull", bool(username) and not password) ) from err + if is_permission_failure(err): + raise GitPermissionException( + build_permission_error_message("pull") + ) from err if is_missing_upstream(err): raise GitUpstreamException( build_upstream_error_message("pull") diff --git a/packages/python-sdk/e2b/sandbox_sync/git.py b/packages/python-sdk/e2b/sandbox_sync/git.py index 96e7628e5a..b512bb9f7c 100644 --- a/packages/python-sdk/e2b/sandbox_sync/git.py +++ b/packages/python-sdk/e2b/sandbox_sync/git.py @@ -5,6 +5,7 @@ GitStatus, build_add_args, build_auth_error_message, + build_permission_error_message, build_branches_args, build_checkout_branch_args, build_clone_plan, @@ -26,6 +27,7 @@ build_status_args, build_upstream_error_message, is_auth_failure, + is_permission_failure, is_missing_upstream, parse_git_branches, parse_git_status, @@ -35,6 +37,7 @@ ) from e2b.exceptions import ( GitAuthException, + GitPermissionException, GitUpstreamException, InvalidArgumentException, ) @@ -321,6 +324,10 @@ def attempt_clone(auth_username: Optional[str], auth_password: Optional[str]): raise GitAuthException( build_auth_error_message("clone", bool(username) and not password) ) from err + if is_permission_failure(err): + raise GitPermissionException( + build_permission_error_message("clone") + ) from err raise def init( @@ -789,6 +796,10 @@ def push( raise GitAuthException( build_auth_error_message("push", bool(username) and not password) ) from err + if is_permission_failure(err): + raise GitPermissionException( + build_permission_error_message("push") + ) from err if is_missing_upstream(err): raise GitUpstreamException( build_upstream_error_message("push") @@ -872,6 +883,10 @@ def pull( raise GitAuthException( build_auth_error_message("pull", bool(username) and not password) ) from err + if is_permission_failure(err): + raise GitPermissionException( + build_permission_error_message("pull") + ) from err if is_missing_upstream(err): raise GitUpstreamException( build_upstream_error_message("pull") diff --git a/packages/python-sdk/tests/shared/git/test_auth.py b/packages/python-sdk/tests/shared/git/test_auth.py index 80121b9e87..633e503f74 100644 --- a/packages/python-sdk/tests/shared/git/test_auth.py +++ b/packages/python-sdk/tests/shared/git/test_auth.py @@ -1,7 +1,11 @@ import pytest -from e2b.exceptions import GitAuthException -from e2b.sandbox._git.auth import is_auth_failure +from e2b.exceptions import GitAuthException, GitPermissionException +from e2b.sandbox._git.auth import ( + build_permission_error_message, + is_auth_failure, + is_permission_failure, +) from e2b.sandbox.commands.command_handle import CommandExitException from e2b.sandbox_sync.git import Git @@ -23,25 +27,54 @@ def test_is_auth_failure_ignores_filesystem_permission_errors(): assert is_auth_failure(err) is False +def test_is_permission_failure_detects_git_filesystem_errors(): + err = _command_exit("error: cannot open .git/FETCH_HEAD: Permission denied") + + assert is_permission_failure(err) is True + + def test_is_auth_failure_detects_ssh_publickey_errors(): err = _command_exit("git@github.com: Permission denied (publickey).") assert is_auth_failure(err) is True -def test_clone_preserves_path_permission_failures(): +class FailingCommands: + def __init__(self, err: CommandExitException): + self.err = err + + def run(self, *args, **kwargs): + raise self.err + + +def test_clone_raises_permission_exception_for_path_permission_failures(): err = _command_exit( "fatal: could not create work tree dir '/home/workspace': Permission denied" ) + git = Git(FailingCommands(err)) - class FailingCommands: - def run(self, *args, **kwargs): - raise err - - git = Git(FailingCommands()) - - with pytest.raises(CommandExitException) as exc: + with pytest.raises(GitPermissionException) as exc: git.clone("https://github.com/e2b-dev/e2b.git", "/home/workspace") - assert exc.value is err + assert str(exc.value) == build_permission_error_message("clone") assert not isinstance(exc.value, GitAuthException) + + +def test_push_raises_permission_exception_for_repository_write_failures(): + err = _command_exit("error: unable to create '.git/index.lock': Permission denied") + git = Git(FailingCommands(err)) + + with pytest.raises(GitPermissionException) as exc: + git.push("/repo", remote="origin", branch="main") + + assert str(exc.value) == build_permission_error_message("push") + + +def test_pull_raises_permission_exception_for_repository_write_failures(): + err = _command_exit("error: cannot open .git/FETCH_HEAD: Permission denied") + git = Git(FailingCommands(err)) + + with pytest.raises(GitPermissionException) as exc: + git.pull("/repo", remote="origin", branch="main") + + assert str(exc.value) == build_permission_error_message("pull") From b60200eb2013d68388c4499c873e03269e477dc9 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 13 Mar 2026 11:34:15 -0700 Subject: [PATCH 3/6] linter --- packages/js-sdk/tests/sandbox/git/authDetection.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/js-sdk/tests/sandbox/git/authDetection.test.ts b/packages/js-sdk/tests/sandbox/git/authDetection.test.ts index 8718832f3a..72ac8a54a6 100644 --- a/packages/js-sdk/tests/sandbox/git/authDetection.test.ts +++ b/packages/js-sdk/tests/sandbox/git/authDetection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, vi } from 'vitest' -import { GitAuthError, GitPermissionError } from '../../../src/errors' +import { GitPermissionError } from '../../../src/errors' import { CommandExitError } from '../../../src/sandbox/commands/commandHandle' import { Git } from '../../../src/sandbox/git' import { @@ -29,7 +29,7 @@ describe('Git auth detection', () => { test('classifies filesystem permission errors separately from auth failures', () => { const err = createFilesystemPermissionError( - "fatal: cannot open .git/FETCH_HEAD: Permission denied" + 'fatal: cannot open .git/FETCH_HEAD: Permission denied' ) expect(isPermissionFailure(err)).toBe(true) @@ -88,7 +88,7 @@ describe('Git auth detection', () => { test('pull raises GitPermissionError for repository write failures', async () => { const err = createFilesystemPermissionError( - "error: cannot open .git/FETCH_HEAD: Permission denied" + 'error: cannot open .git/FETCH_HEAD: Permission denied' ) const git = new Git({ run: vi.fn().mockRejectedValue(err), From 2dbfa4a6cfb9763cee9352d796ce31f579f7f879 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 13 Mar 2026 14:30:06 -0700 Subject: [PATCH 4/6] changeset --- .changeset/khaki-birds-yell.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/khaki-birds-yell.md diff --git a/.changeset/khaki-birds-yell.md b/.changeset/khaki-birds-yell.md new file mode 100644 index 0000000000..df4f11bfef --- /dev/null +++ b/.changeset/khaki-birds-yell.md @@ -0,0 +1,6 @@ +--- +'@e2b/python-sdk': minor +'e2b': minor +--- + +improve distinction for different git permission failures From e6ee3979595895322469aadc9d51bee610a3d7e4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Mar 2026 21:40:03 +0000 Subject: [PATCH 5/6] Fix SSH auth detection for password methods --- packages/js-sdk/src/sandbox/git/utils.ts | 1 + .../js-sdk/tests/sandbox/git/authDetection.test.ts | 11 +++++++++++ packages/python-sdk/e2b/sandbox/_git/auth.py | 1 + packages/python-sdk/tests/shared/git/test_auth.py | 6 ++++++ 4 files changed, 19 insertions(+) diff --git a/packages/js-sdk/src/sandbox/git/utils.ts b/packages/js-sdk/src/sandbox/git/utils.ts index 85ec2a6435..88a2c07a38 100644 --- a/packages/js-sdk/src/sandbox/git/utils.ts +++ b/packages/js-sdk/src/sandbox/git/utils.ts @@ -516,6 +516,7 @@ export function isAuthFailure(err: unknown): boolean { 'terminal prompts disabled', 'could not read username', 'invalid username or password', + 'permission denied (', 'permission denied (publickey', 'permission denied (keyboard-interactive', 'permission to ', diff --git a/packages/js-sdk/tests/sandbox/git/authDetection.test.ts b/packages/js-sdk/tests/sandbox/git/authDetection.test.ts index 72ac8a54a6..8f934b45ac 100644 --- a/packages/js-sdk/tests/sandbox/git/authDetection.test.ts +++ b/packages/js-sdk/tests/sandbox/git/authDetection.test.ts @@ -46,6 +46,17 @@ describe('Git auth detection', () => { expect(isAuthFailure(err)).toBe(true) }) + test('classifies ssh password failures as auth failures', () => { + const err = new CommandExitError({ + exitCode: 128, + error: 'git@github.com: Permission denied (password).', + stdout: '', + stderr: 'git@github.com: Permission denied (password).', + }) + + expect(isAuthFailure(err)).toBe(true) + }) + test('clone raises GitPermissionError for path permission failures', async () => { const err = createFilesystemPermissionError( "fatal: could not create work tree dir '/home/workspace': Permission denied" diff --git a/packages/python-sdk/e2b/sandbox/_git/auth.py b/packages/python-sdk/e2b/sandbox/_git/auth.py index 10b9b29052..380adde06b 100644 --- a/packages/python-sdk/e2b/sandbox/_git/auth.py +++ b/packages/python-sdk/e2b/sandbox/_git/auth.py @@ -67,6 +67,7 @@ def is_auth_failure(err: Exception) -> bool: "terminal prompts disabled", "could not read username", "invalid username or password", + "permission denied (", "permission denied (publickey", "permission denied (keyboard-interactive", "permission to ", diff --git a/packages/python-sdk/tests/shared/git/test_auth.py b/packages/python-sdk/tests/shared/git/test_auth.py index 633e503f74..ce2a2edc5e 100644 --- a/packages/python-sdk/tests/shared/git/test_auth.py +++ b/packages/python-sdk/tests/shared/git/test_auth.py @@ -39,6 +39,12 @@ def test_is_auth_failure_detects_ssh_publickey_errors(): assert is_auth_failure(err) is True +def test_is_auth_failure_detects_ssh_password_errors(): + err = _command_exit("git@github.com: Permission denied (password).") + + assert is_auth_failure(err) is True + + class FailingCommands: def __init__(self, err: CommandExitException): self.err = err From 615dea747af84ab37254e19f430155f7860084e2 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Fri, 13 Mar 2026 14:47:15 -0700 Subject: [PATCH 6/6] typecheck --- packages/python-sdk/tests/shared/git/test_auth.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/python-sdk/tests/shared/git/test_auth.py b/packages/python-sdk/tests/shared/git/test_auth.py index 633e503f74..b6230c79b0 100644 --- a/packages/python-sdk/tests/shared/git/test_auth.py +++ b/packages/python-sdk/tests/shared/git/test_auth.py @@ -1,3 +1,5 @@ +from typing import cast + import pytest from e2b.exceptions import GitAuthException, GitPermissionException @@ -7,6 +9,7 @@ is_permission_failure, ) from e2b.sandbox.commands.command_handle import CommandExitException +from e2b.sandbox_sync.commands.command import Commands from e2b.sandbox_sync.git import Git @@ -51,7 +54,7 @@ def test_clone_raises_permission_exception_for_path_permission_failures(): err = _command_exit( "fatal: could not create work tree dir '/home/workspace': Permission denied" ) - git = Git(FailingCommands(err)) + git = Git(cast(Commands, FailingCommands(err))) with pytest.raises(GitPermissionException) as exc: git.clone("https://github.com/e2b-dev/e2b.git", "/home/workspace") @@ -62,7 +65,7 @@ def test_clone_raises_permission_exception_for_path_permission_failures(): def test_push_raises_permission_exception_for_repository_write_failures(): err = _command_exit("error: unable to create '.git/index.lock': Permission denied") - git = Git(FailingCommands(err)) + git = Git(cast(Commands, FailingCommands(err))) with pytest.raises(GitPermissionException) as exc: git.push("/repo", remote="origin", branch="main") @@ -72,7 +75,7 @@ def test_push_raises_permission_exception_for_repository_write_failures(): def test_pull_raises_permission_exception_for_repository_write_failures(): err = _command_exit("error: cannot open .git/FETCH_HEAD: Permission denied") - git = Git(FailingCommands(err)) + git = Git(cast(Commands, FailingCommands(err))) with pytest.raises(GitPermissionException) as exc: git.pull("/repo", remote="origin", branch="main")