Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/khaki-birds-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@e2b/python-sdk': minor
'e2b': minor
---

improve distinction for different git permission failures
10 changes: 10 additions & 0 deletions packages/js-sdk/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/js-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type { ConnectionOpts, Username } from './connectionConfig'
export {
AuthenticationError,
GitAuthError,
GitPermissionError,
GitUpstreamError,
InvalidArgumentError,
NotEnoughSpaceError,
Expand Down
12 changes: 12 additions & 0 deletions packages/js-sdk/src/sandbox/git/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
GitAuthError,
GitPermissionError,
GitUpstreamError,
InvalidArgumentError,
} from '../../errors'
Expand All @@ -9,6 +10,7 @@ import { Commands } from '../commands'
import {
buildAuthErrorMessage,
buildGitCommand,
buildPermissionErrorMessage,
buildPushArgs,
buildUpstreamErrorMessage,
GitBranches,
Expand All @@ -17,6 +19,7 @@ import {
getRepoPathForScope,
getScopeFlag,
isAuthFailure,
isPermissionFailure,
isMissingUpstream,
parseGitBranches,
parseGitStatus,
Expand Down Expand Up @@ -360,6 +363,9 @@ export class Git {
buildAuthErrorMessage('clone', Boolean(username) && !password)
)
}
if (isPermissionFailure(err)) {
throw new GitPermissionError(buildPermissionErrorMessage('clone'))
}
throw err
}
}
Expand Down Expand Up @@ -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'))
}
Expand Down Expand Up @@ -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'))
}
Expand Down
52 changes: 51 additions & 1 deletion packages/js-sdk/src/sandbox/git/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,14 +516,64 @@ 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 ',
'requested url returned error: 403',
'access denied',
'permission denied',
'not authorized',
]

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(
Expand Down
117 changes: 117 additions & 0 deletions packages/js-sdk/tests/sandbox/git/authDetection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, test, vi } from 'vitest'

import { GitPermissionError } from '../../../src/errors'
import { CommandExitError } from '../../../src/sandbox/commands/commandHandle'
import { Git } from '../../../src/sandbox/git'
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 = 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,
error: 'git@github.com: Permission denied (publickey).',
stdout: '',
stderr: 'git@github.com: Permission denied (publickey).',
})

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"
)
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.toBeInstanceOf(GitPermissionError)
await expect(
git.clone('https://github.com/e2b-dev/e2b.git', {
path: '/home/workspace',
})
).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'),
})
})
})
2 changes: 2 additions & 0 deletions packages/python-sdk/e2b/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from .exceptions import (
AuthenticationException,
GitAuthException,
GitPermissionException,
GitUpstreamException,
BuildException,
FileUploadException,
Expand Down Expand Up @@ -124,6 +125,7 @@
"NotFoundException",
"AuthenticationException",
"GitAuthException",
"GitPermissionException",
"GitUpstreamException",
"InvalidArgumentException",
"NotEnoughSpaceException",
Expand Down
8 changes: 8 additions & 0 deletions packages/python-sdk/e2b/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions packages/python-sdk/e2b/sandbox/_git/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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",
Expand Down
Loading
Loading