Skip to content

Commit 771434c

Browse files
committed
feat(auth): Add SCM authentication support for repository access
1 parent 3fb199d commit 771434c

10 files changed

+231
-44
lines changed

examples/anthropic_tool_use.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
#!/usr/bin/env python
1+
# EXPORT ANTHROPIC_API_KEY=...
2+
# python -m examples.anthropic_tool_use
23

34
from __future__ import annotations
45

@@ -11,6 +12,8 @@
1112
from gitpod import AsyncGitpod
1213
from gitpod.types.environment_initializer_param import Spec
1314

15+
from .scm_auth import verify_context_url # type: ignore
16+
1417
gpclient = AsyncGitpod()
1518
llmclient = Anthropic()
1619

@@ -43,6 +46,9 @@ async def create_environment(args: dict[str, str], cleanup: util.Disposables) ->
4346
raise Exception("No environment class found. Please create one first.")
4447
env_class_id = env_class.id
4548
assert env_class_id is not None
49+
50+
assert env_class.runner_id is not None
51+
await verify_context_url(gpclient, args["context_url"], env_class.runner_id)
4652

4753
environment = (await gpclient.environments.create(
4854
spec={
@@ -63,7 +69,7 @@ async def create_environment(args: dict[str, str], cleanup: util.Disposables) ->
6369
cleanup.add(lambda: asyncio.run(gpclient.environments.delete(environment_id=environment_id)))
6470

6571
print(f"\nCreated environment: {environment_id} - waiting for it to be ready...")
66-
await util.wait_for_environment_ready(gpclient, environment_id)
72+
await util.wait_for_environment_running(gpclient, environment_id)
6773
print(f"\nEnvironment is ready: {environment_id}")
6874
return environment_id
6975

examples/fs_access.py

+6-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
#!/usr/bin/env python
2-
31
import sys
42
import asyncio
53
from io import StringIO
@@ -11,10 +9,12 @@
119
from gitpod.types.environment_spec_param import EnvironmentSpecParam
1210
from gitpod.types.environment_initializer_param import Spec
1311

12+
from .scm_auth import verify_context_url # type: ignore
13+
1414

1515
# Examples:
16-
# - ./examples/fs_access.py
17-
# - ./examples/fs_access.py https://github.com/gitpod-io/empty
16+
# - python -m examples.fs_access
17+
# - python -m examples.fs_access https://github.com/gitpod-io/empty
1818
async def main(cleanup: util.Disposables) -> None:
1919
client = AsyncGitpod()
2020

@@ -46,6 +46,8 @@ async def main(cleanup: util.Disposables) -> None:
4646
}]
4747
}
4848
if context_url:
49+
assert env_class.runner_id is not None
50+
await verify_context_url(client, context_url, env_class.runner_id)
4951
spec["content"] = {
5052
"initializer": {"specs": [Spec(
5153
context_url={

examples/run_command.py

+7-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
#!/usr/bin/env python
2-
31
import sys
42
import asyncio
53

@@ -8,10 +6,12 @@
86
from gitpod.types.environment_spec_param import EnvironmentSpecParam
97
from gitpod.types.environment_initializer_param import Spec
108

9+
from .scm_auth import verify_context_url # type: ignore
10+
1111

1212
# Examples:
13-
# - ./examples/run_command.py 'echo "Hello World!"'
14-
# - ./examples/run_command.py 'echo "Hello World!"' https://github.com/gitpod-io/empty
13+
# - python -m examples.run_command 'echo "Hello World!"'
14+
# - python -m examples.run_command 'echo "Hello World!"' https://github.com/gitpod-io/empty
1515
async def main(cleanup: util.Disposables) -> None:
1616
client = AsyncGitpod()
1717

@@ -35,6 +35,8 @@ async def main(cleanup: util.Disposables) -> None:
3535
"machine": {"class": env_class_id},
3636
}
3737
if context_url:
38+
assert env_class.runner_id is not None
39+
await verify_context_url(client, context_url, env_class.runner_id)
3840
spec["content"] = {
3941
"initializer": {"specs": [Spec(
4042
context_url={
@@ -51,7 +53,7 @@ async def main(cleanup: util.Disposables) -> None:
5153
cleanup.add(lambda: asyncio.run(client.environments.delete(environment_id=environment_id)))
5254

5355
print("Waiting for environment to be ready")
54-
await util.wait_for_environment_ready(client, environment_id)
56+
await util.wait_for_environment_running(client, environment_id)
5557

5658
print("Running command")
5759
lines = await util.run_command(client, environment_id, command)

examples/run_service.py

+19-12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
#!/usr/bin/env python
2-
1+
import os
32
import sys
43
import asyncio
54

@@ -8,16 +7,23 @@
87
from gitpod.types.environment_spec_param import EnvironmentSpecParam
98
from gitpod.types.environment_initializer_param import Spec
109

10+
from .scm_auth import verify_context_url # type: ignore
11+
1112

1213
# Examples:
13-
# - ./examples/run_service.py
14-
# - ./examples/run_service.py https://github.com/gitpod-io/empty
14+
# - python -m examples.run_service
15+
# - python -m examples.run_service https://github.com/gitpod-io/empty
1516
async def main(cleanup: util.Disposables) -> None:
1617
client = AsyncGitpod()
1718

1819
context_url = sys.argv[1] if len(sys.argv) > 1 else None
20+
env_class_id = os.getenv("GITPOD_ENV_CLASS_ID")
1921

20-
env_class = await util.find_most_used_environment_class(client)
22+
if not env_class_id:
23+
env_class = await util.find_most_used_environment_class(client)
24+
else:
25+
env_class = await util.find_environment_class_by_id(client, env_class_id)
26+
2127
if not env_class:
2228
print("Error: No environment class found. Please create one first.")
2329
sys.exit(1)
@@ -36,15 +42,16 @@ async def main(cleanup: util.Disposables) -> None:
3642
}]
3743
}
3844
if context_url:
45+
assert env_class.runner_id is not None
46+
await verify_context_url(client, context_url, env_class.runner_id)
3947
spec["content"] = {
4048
"initializer": {"specs": [Spec(
41-
context_url={
42-
"url": context_url
43-
}
44-
)]}
45-
}
49+
context_url={
50+
"url": context_url
51+
}
52+
)]}
53+
}
4654

47-
print("Creating environment")
4855
environment = (await client.environments.create(spec=spec)).environment
4956
assert environment is not None
5057
environment_id = environment.id
@@ -77,4 +84,4 @@ async def main(cleanup: util.Disposables) -> None:
7784
if __name__ == "__main__":
7885
disposables = util.Disposables()
7986
with disposables:
80-
asyncio.run(main(disposables))
87+
asyncio.run(main(disposables))

examples/scm_auth.py

+136
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import sys
2+
from urllib.parse import urlparse
3+
4+
import gitpod
5+
import gitpod.lib as util
6+
from gitpod import AsyncGitpod
7+
from gitpod.types.runner_check_authentication_for_host_response import SupportsPat
8+
9+
10+
async def handle_pat_auth(client: AsyncGitpod, user_id: str, runner_id: str, host: str, supports_pat: SupportsPat) -> None:
11+
print("\nTo create a Personal Access Token:")
12+
create_url = supports_pat.create_url
13+
14+
if create_url:
15+
print(f"1. Visit: {create_url}")
16+
else:
17+
print(f"1. Go to {host} > Settings > Developer Settings")
18+
19+
if supports_pat.required_scopes and len(supports_pat.required_scopes) > 0:
20+
required_scopes = ", ".join(supports_pat.required_scopes)
21+
print(f"2. Create a new token with the following scopes: {required_scopes}")
22+
else:
23+
print(f"2. Create a new token")
24+
25+
if supports_pat.example:
26+
print(f"3. Copy the generated token (example: {supports_pat.example})")
27+
else:
28+
print(f"3. Copy the generated token")
29+
30+
if supports_pat.docs_url:
31+
print(f"\nFor detailed instructions, visit: {supports_pat.docs_url}")
32+
33+
pat = input("\nEnter your Personal Access Token: ").strip()
34+
if not pat:
35+
return
36+
37+
await util.set_scm_pat(client, user_id, runner_id, host, pat)
38+
39+
async def verify_context_url(client: AsyncGitpod, context_url: str, runner_id: str) -> None:
40+
"""Verify and handle authentication for a repository context URL.
41+
42+
This function checks if the user has access to the specified repository and manages
43+
the authentication process if needed. Git access to the repository is required for
44+
environments to function properly.
45+
46+
As an alternative, you can authenticate once via the Gitpod dashboard:
47+
1. Start a new environment
48+
2. Complete the browser-based authentication flow
49+
50+
See https://www.gitpod.io/docs/flex/source-control for more details.
51+
"""
52+
host = urlparse(context_url).hostname
53+
if host is None:
54+
print("Error: Invalid context URL")
55+
sys.exit(1)
56+
57+
resp = await client.users.get_authenticated_user()
58+
user = resp.user
59+
assert user is not None
60+
user_id = user.id
61+
assert user_id is not None
62+
63+
# Main authentication loop
64+
first_attempt = True
65+
while True:
66+
try:
67+
# Try to access the context URL
68+
await client.runners.parse_context_url(context_url=context_url, runner_id=runner_id)
69+
print("\n✓ Authentication verified successfully")
70+
return
71+
72+
except gitpod.APIError as e:
73+
if e.code != "failed_precondition":
74+
raise e
75+
76+
# Show authentication required message only on first attempt
77+
if first_attempt:
78+
print(f"\nAuthentication required for {host}")
79+
first_attempt = False
80+
81+
# Get authentication options for the host
82+
auth_resp = await client.runners.check_authentication_for_host(
83+
host=host,
84+
runner_id=runner_id
85+
)
86+
87+
# Handle re-authentication case
88+
if auth_resp.authenticated and not first_attempt:
89+
print("\nIt looks like you are already authenticated.")
90+
if input("Would you like to re-authenticate? (y/n): ").lower().strip() != 'y':
91+
print("\nAuthentication cancelled")
92+
sys.exit(1)
93+
else:
94+
print("\nRetrying authentication...")
95+
continue
96+
97+
auth_methods: list[tuple[str, str]] = []
98+
if auth_resp.supports_oauth2:
99+
auth_methods.append(("OAuth", "Recommended"))
100+
if auth_resp.supports_pat:
101+
auth_methods.append(("Personal Access Token (PAT)", ""))
102+
103+
if not auth_methods:
104+
print(f"\nError: No authentication method available for {host}")
105+
sys.exit(1)
106+
107+
# Present authentication options
108+
if len(auth_methods) > 1:
109+
print("\nAvailable authentication methods:")
110+
for i, (method, note) in enumerate(auth_methods, 1):
111+
note_text = f" ({note})" if note else ""
112+
print(f"{i}. {method}{note_text}")
113+
114+
choice = input(f"\nChoose authentication method (1-{len(auth_methods)}): ").strip()
115+
try:
116+
method_index = int(choice) - 1
117+
if not 0 <= method_index < len(auth_methods):
118+
raise ValueError()
119+
except ValueError:
120+
method_index = 0 # Default to OAuth if invalid input
121+
else:
122+
method_index = 0
123+
124+
# Handle chosen authentication method
125+
chosen_method = auth_methods[method_index][0]
126+
if chosen_method == "Personal Access Token (PAT)":
127+
assert auth_resp.supports_pat
128+
await handle_pat_auth(client, user_id, runner_id, host, auth_resp.supports_pat)
129+
else:
130+
assert auth_resp.supports_oauth2
131+
print(f"\nPlease visit the following URL to authenticate:")
132+
print(f"{auth_resp.supports_oauth2.auth_url}")
133+
if auth_resp.supports_oauth2.docs_url:
134+
print(f"\nFor detailed instructions, visit: {auth_resp.supports_oauth2.docs_url}")
135+
print("\nWaiting for authentication to complete...")
136+
input("Press Enter after completing authentication in your browser...")

requirements-dev.lock

-1
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@ packaging==23.2
7373
# via nox
7474
# via pytest
7575
paramiko==3.5.1
76-
# via gitpod-sdk
7776
platformdirs==3.11.0
7877
# via virtualenv
7978
pluggy==1.5.0

requirements.lock

-13
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,9 @@ annotated-types==0.6.0
1414
anyio==4.4.0
1515
# via gitpod-sdk
1616
# via httpx
17-
bcrypt==4.2.1
18-
# via paramiko
1917
certifi==2023.7.22
2018
# via httpcore
2119
# via httpx
22-
cffi==1.17.1
23-
# via cryptography
24-
# via pynacl
25-
cryptography==44.0.1
26-
# via paramiko
2720
distro==1.8.0
2821
# via gitpod-sdk
2922
exceptiongroup==1.2.2
@@ -37,16 +30,10 @@ httpx==0.28.1
3730
idna==3.4
3831
# via anyio
3932
# via httpx
40-
paramiko==3.5.1
41-
# via gitpod-sdk
42-
pycparser==2.22
43-
# via cffi
4433
pydantic==2.10.3
4534
# via gitpod-sdk
4635
pydantic-core==2.27.1
4736
# via pydantic
48-
pynacl==1.5.0
49-
# via paramiko
5037
sniffio==1.3.0
5138
# via anyio
5239
# via gitpod-sdk

src/gitpod/lib/__init__.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1+
from .runner import set_scm_pat
12
from .automation import run_command, run_service
23
from .disposables import Disposables
3-
from .environment import EnvironmentState, wait_for_environment_ready, find_most_used_environment_class
4+
from .environment import (
5+
EnvironmentState,
6+
find_environment_class_by_id,
7+
wait_for_environment_running,
8+
find_most_used_environment_class,
9+
)
410

511
__all__ = [
612
'find_most_used_environment_class',
713
'run_command',
814
'run_service',
915
'EnvironmentState',
1016
'Disposables',
11-
'wait_for_environment_ready',
17+
'wait_for_environment_running',
18+
'find_environment_class_by_id',
19+
'set_scm_pat',
1220
]

0 commit comments

Comments
 (0)