Skip to content

Commit 9a0e94f

Browse files
committed
feat(auth): Add SCM authentication support for repository access
1 parent c16c250 commit 9a0e94f

10 files changed

+206
-34
lines changed

examples/anthropic_tool_use.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import gitpod.lib as util
1111
from gitpod import AsyncGitpod
1212
from gitpod.types.environment_initializer_param import Spec
13+
from gitpod.lib.cli_scm_auth import verify_context_url
1314

1415
gpclient = AsyncGitpod()
1516
llmclient = Anthropic()
@@ -43,6 +44,9 @@ async def create_environment(args: dict[str, str], cleanup: util.Disposables) ->
4344
raise Exception("No environment class found. Please create one first.")
4445
env_class_id = env_class.id
4546
assert env_class_id is not None
47+
48+
assert env_class.runner_id is not None
49+
await verify_context_url(gpclient, args["context_url"], env_class.runner_id)
4650

4751
environment = (await gpclient.environments.create(
4852
spec={
@@ -63,7 +67,7 @@ async def create_environment(args: dict[str, str], cleanup: util.Disposables) ->
6367
cleanup.add(lambda: asyncio.run(gpclient.environments.delete(environment_id=environment_id)))
6468

6569
print(f"\nCreated environment: {environment_id} - waiting for it to be ready...")
66-
await util.wait_for_environment_ready(gpclient, environment_id)
70+
await util.wait_for_environment_running(gpclient, environment_id)
6771
print(f"\nEnvironment is ready: {environment_id}")
6872
return environment_id
6973

examples/fs_access.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from gitpod import AsyncGitpod
1111
from gitpod.types.environment_spec_param import EnvironmentSpecParam
1212
from gitpod.types.environment_initializer_param import Spec
13-
13+
from gitpod.lib.cli_scm_auth import verify_context_url
1414

1515
# Examples:
1616
# - ./examples/fs_access.py
@@ -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

+4-2
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
import sys
44
import asyncio
55

6+
from gitpod.lib.cli_scm_auth import verify_context_url
67
import gitpod.lib as util
78
from gitpod import AsyncGitpod
89
from gitpod.types.environment_spec_param import EnvironmentSpecParam
910
from gitpod.types.environment_initializer_param import Spec
1011

11-
1212
# Examples:
1313
# - ./examples/run_command.py 'echo "Hello World!"'
1414
# - ./examples/run_command.py 'echo "Hello World!"' https://github.com/gitpod-io/empty
@@ -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

+16-9
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
#!/usr/bin/env python
22

3+
import os
34
import sys
45
import asyncio
56

67
import gitpod.lib as util
78
from gitpod import AsyncGitpod
89
from gitpod.types.environment_spec_param import EnvironmentSpecParam
910
from gitpod.types.environment_initializer_param import Spec
10-
11+
from gitpod.lib.cli_scm_auth import verify_context_url
1112

1213
# Examples:
1314
# - ./examples/run_service.py
@@ -16,8 +17,13 @@ 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))

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

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
from .automation import run_command, run_service
22
from .disposables import Disposables
3-
from .environment import EnvironmentState, wait_for_environment_ready, find_most_used_environment_class
3+
from .environment import EnvironmentState, wait_for_environment_running, find_most_used_environment_class, find_environment_class_by_id
4+
from .runner import set_scm_pat
45

56
__all__ = [
67
'find_most_used_environment_class',
78
'run_command',
89
'run_service',
910
'EnvironmentState',
1011
'Disposables',
11-
'wait_for_environment_ready',
12+
'wait_for_environment_running',
13+
'find_environment_class_by_id',
14+
'set_scm_pat',
1215
]

src/gitpod/lib/cli_scm_auth.py

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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+
8+
async def handle_pat_auth(client: AsyncGitpod, user_id: str, runner_id: str, host: str) -> None:
9+
scm_info = {
10+
"github.com": {
11+
"create_url": f"https://{host}/settings/tokens/new?scopes=repo,workflow,read:user,user:email&description=gitpod",
12+
"docs_url": "https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens",
13+
"scopes": "repo, workflow, read:user, user:email"
14+
},
15+
"gitlab.com": {
16+
"create_url": f"https://{host}/-/user_settings/personal_access_tokens?scopes=api,read_user,read_repository&name=gitpod",
17+
"docs_url": "https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html",
18+
"scopes": "api, read_user, read_repository"
19+
},
20+
"dev.azure.com": {
21+
"create_url": None,
22+
"docs_url": "https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate",
23+
"scopes": "Code (Read & Write)"
24+
}
25+
}.get(host, {
26+
"create_url": None,
27+
"docs_url": "https://www.gitpod.io/docs/flex/source-control",
28+
"scopes": "repository access"
29+
})
30+
31+
print("\nTo create a Personal Access Token:")
32+
if scm_info["create_url"]:
33+
print(f"1. Visit: {scm_info['create_url']}")
34+
else:
35+
print(f"1. Go to {host} > Settings > Developer Settings")
36+
print(f"2. Create a new token with the following scopes: {scm_info['scopes']}")
37+
print(f"3. Copy the generated token")
38+
print(f"\nFor detailed instructions, visit: {scm_info['docs_url']}")
39+
40+
pat = input("\nEnter your Personal Access Token: ").strip()
41+
if not pat:
42+
return
43+
44+
await util.set_scm_pat(client, user_id, runner_id, host, pat)
45+
46+
async def verify_context_url(client: AsyncGitpod, context_url: str, runner_id: str) -> None:
47+
"""Verify and handle authentication for a repository context URL."""
48+
host = urlparse(context_url).hostname
49+
if host is None:
50+
print("Error: Invalid context URL")
51+
sys.exit(1)
52+
53+
resp = await client.users.get_authenticated_user()
54+
assert resp is not None
55+
user = resp.user
56+
assert user is not None
57+
user_id = user.id
58+
assert user_id is not None
59+
60+
# Main authentication loop
61+
first_attempt = True
62+
while True:
63+
try:
64+
# Try to access the context URL
65+
await client.runners.parse_context_url(context_url=context_url, runner_id=runner_id)
66+
print("\n✓ Authentication verified successfully")
67+
return
68+
69+
except gitpod.APIError as e:
70+
if e.code != "failed_precondition":
71+
raise e
72+
73+
# Show authentication required message only on first attempt
74+
if first_attempt:
75+
print(f"\nAuthentication required for {host}")
76+
first_attempt = False
77+
78+
# Get authentication options for the host
79+
auth_resp = await client.runners.check_authentication_for_host(
80+
host=host,
81+
runner_id=runner_id
82+
)
83+
84+
# Handle re-authentication case
85+
if auth_resp.authenticated and not first_attempt:
86+
print("\nIt looks like you are already authenticated.")
87+
if input("Would you like to re-authenticate? (y/n): ").lower().strip() != 'y':
88+
print("\nAuthentication cancelled")
89+
sys.exit(1)
90+
else:
91+
print("\nRetrying authentication...")
92+
continue
93+
94+
auth_methods: list[tuple[str, str]] = []
95+
if auth_resp.authentication_url:
96+
auth_methods.append(("OAuth", "Recommended"))
97+
if auth_resp.pat_supported:
98+
auth_methods.append(("Personal Access Token (PAT)", ""))
99+
100+
if not auth_methods:
101+
print(f"\nError: No authentication method available for {host}")
102+
sys.exit(1)
103+
104+
# Present authentication options
105+
if len(auth_methods) > 1:
106+
print("\nAvailable authentication methods:")
107+
for i, (method, note) in enumerate(auth_methods, 1):
108+
note_text = f" ({note})" if note else ""
109+
print(f"{i}. {method}{note_text}")
110+
111+
choice = input(f"\nChoose authentication method (1-{len(auth_methods)}): ").strip()
112+
try:
113+
method_index = int(choice) - 1
114+
if not 0 <= method_index < len(auth_methods):
115+
raise ValueError()
116+
except ValueError:
117+
method_index = 0 # Default to OAuth if invalid input
118+
else:
119+
method_index = 0
120+
121+
# Handle chosen authentication method
122+
chosen_method = auth_methods[method_index][0]
123+
if chosen_method == "Personal Access Token (PAT)":
124+
await handle_pat_auth(client, user_id, runner_id, host)
125+
else:
126+
print(f"\nPlease visit the following URL to authenticate:")
127+
print(f"{auth_resp.authentication_url}")
128+
print("\nWaiting for authentication to complete...")
129+
input("Press Enter after completing authentication in your browser...")

src/gitpod/lib/environment.py

+8-5
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def listener(env: Environment) -> None:
135135
await event.wait()
136136
if result is None:
137137
raise RuntimeError("wait_until completed but result is None")
138-
return result
138+
return result # type: ignore[unreachable]
139139
finally:
140140
self._listeners.remove(listener)
141141

@@ -144,11 +144,11 @@ def is_running(self, env: Environment) -> bool:
144144
if not env.status:
145145
return False
146146

147-
if env.status.phase in ["ENVIRONMENT_PHASE_STOPPING", "ENVIRONMENT_PHASE_STOPPED",
147+
if env.status.failure_message:
148+
raise RuntimeError(f"Environment {env.id} failed: {'; '.join(env.status.failure_message)}")
149+
elif env.status.phase in ["ENVIRONMENT_PHASE_STOPPING", "ENVIRONMENT_PHASE_STOPPED",
148150
"ENVIRONMENT_PHASE_DELETING", "ENVIRONMENT_PHASE_DELETED"]:
149151
raise RuntimeError(f"Environment {env.id} is in unexpected phase: {env.status.phase}")
150-
elif env.status.failure_message:
151-
raise RuntimeError(f"Environment {env.id} failed: {'; '.join(env.status.failure_message)}")
152152

153153
return env.status.phase == "ENVIRONMENT_PHASE_RUNNING"
154154

@@ -212,7 +212,7 @@ def check_key(env: Environment) -> Optional[bool]:
212212
return True if self.check_ssh_key_applied(env, key_id, key_value) else None
213213
await self.wait_until(check_key)
214214

215-
async def wait_for_environment_ready(client: AsyncGitpod, environment_id: str) -> None:
215+
async def wait_for_environment_running(client: AsyncGitpod, environment_id: str) -> None:
216216
env = EnvironmentState(client, environment_id)
217217
try:
218218
await env.wait_until_running()
@@ -240,6 +240,9 @@ async def find_most_used_environment_class(client: AsyncGitpod) -> Optional[Envi
240240
if not environment_class_id:
241241
return None
242242

243+
return await find_environment_class_by_id(client, environment_class_id)
244+
245+
async def find_environment_class_by_id(client: AsyncGitpod, environment_class_id: str) -> Optional[EnvironmentClass]:
243246
classes_resp = await client.environments.classes.list(filter={"can_create_environments": True})
244247
while classes_resp:
245248
for cls in classes_resp.environment_classes:

0 commit comments

Comments
 (0)