pipecatcloud 0.4.3__tar.gz → 0.4.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/CHANGELOG.md +41 -0
  2. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/PKG-INFO +27 -1
  3. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/README.md +26 -0
  4. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/api.py +4 -1
  5. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/auth.py +61 -7
  6. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/secrets.py +25 -21
  7. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/config.py +29 -10
  8. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/entry_point.py +13 -3
  9. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud.egg-info/PKG-INFO +27 -1
  10. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud.egg-info/SOURCES.txt +1 -0
  11. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_api_client.py +48 -0
  12. pipecatcloud-0.4.4/tests/test_auth.py +303 -0
  13. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/.github/workflows/format.yml +0 -0
  14. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/.github/workflows/publish-pypi.yml +0 -0
  15. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/.github/workflows/publish-test.yml +0 -0
  16. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/.github/workflows/tests.yml +0 -0
  17. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/.gitignore +0 -0
  18. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/.pre-commit-config.yaml +0 -0
  19. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/LICENSE +0 -0
  20. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/pipecat-cloud.png +0 -0
  21. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/pyproject.toml +0 -0
  22. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/pytest.ini +0 -0
  23. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/scripts/fix-ruff.sh +0 -0
  24. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/scripts/pre-commit.sh +0 -0
  25. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/setup.cfg +0 -0
  26. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/__init__.py +0 -0
  27. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/__main__.py +0 -0
  28. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/__version__.py +0 -0
  29. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/_utils/__init__.py +0 -0
  30. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/_utils/async_utils.py +0 -0
  31. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/_utils/auth_utils.py +0 -0
  32. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/_utils/build_utils.py +0 -0
  33. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/_utils/console_utils.py +0 -0
  34. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/_utils/deploy_utils.py +0 -0
  35. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/_utils/regions.py +0 -0
  36. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/agent.py +0 -0
  37. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/__init__.py +0 -0
  38. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/api.py +0 -0
  39. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/__init__.py +0 -0
  40. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/agent.py +0 -0
  41. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/build.py +0 -0
  42. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/deploy.py +0 -0
  43. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/docker.py +0 -0
  44. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/organizations.py +0 -0
  45. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/regions.py +0 -0
  46. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/config.py +0 -0
  47. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/constants.py +0 -0
  48. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/exception.py +0 -0
  49. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/py.typed +0 -0
  50. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/session.py +0 -0
  51. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/smallwebrtc/session_manager.py +0 -0
  52. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud.egg-info/dependency_links.txt +0 -0
  53. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud.egg-info/entry_points.txt +0 -0
  54. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud.egg-info/requires.txt +0 -0
  55. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud.egg-info/top_level.txt +0 -0
  56. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/__init__.py +0 -0
  57. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/conftest.py +0 -0
  58. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_agent_sessions.py +0 -0
  59. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_agent_stop.py +0 -0
  60. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_config_file.py +0 -0
  61. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_deployment_status.py +0 -0
  62. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_docker_commands.py +0 -0
  63. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_krisp_viva.py +0 -0
  64. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_pat_auth.py +0 -0
  65. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_regions.py +0 -0
  66. {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/uv.lock +0 -0
@@ -5,6 +5,47 @@ All notable changes to **Pipecat Cloud** will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.4] - 2026-04-10
9
+
10
+ ### Added
11
+
12
+ - `pcc secrets image-pull-secret` now supports updating existing image pull
13
+ secrets. Previously, updating required deleting and recreating the secret.
14
+ The command prompts for confirmation before overwriting; use `--skip` to
15
+ bypass the prompt in scripts.
16
+
17
+ ### Changed
18
+
19
+ - `--show-cli-config` now redacts `token` and `refresh_token` values in output.
20
+ A type prefix (e.g. `pcc_pat_****`) is shown for diagnostics. Users who need
21
+ raw values can read the config file directly.
22
+
23
+ - `pcc auth use-pat` now reads the token from a secure hidden prompt instead of
24
+ a positional argument. The positional form still works but emits a deprecation
25
+ warning (tokens passed as arguments are visible in shell history and process
26
+ listings).
27
+
28
+ - OAuth callback server now binds to `127.0.0.1` instead of `localhost` to
29
+ prevent DNS rebinding attacks.
30
+
31
+ ### Fixed
32
+
33
+ - `pcc auth logout` no longer claims "session revoked" when server-side
34
+ revocation was not confirmed. It now accurately reports what succeeded.
35
+
36
+ - `pcc auth logout` no longer crashes if the credentials file is already absent.
37
+
38
+ - Fixed `organizations_current()` fallback returning the wrong data shape when
39
+ no specific org is requested.
40
+
41
+ - OIDC discovery metadata is now validated before use: issuer must match
42
+ expected value, endpoints must use HTTPS, and PKCE S256 support is verified
43
+ when advertised.
44
+
45
+ - Credentials are now written atomically (write to temp file, fsync, rename)
46
+ to prevent corruption from interrupted writes. File permissions are
47
+ restrictive from creation.
48
+
8
49
  ## [0.4.3] - 2026-04-02
9
50
 
10
51
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pipecatcloud
3
- Version: 0.4.3
3
+ Version: 0.4.4
4
4
  Summary: Cloud hosting for Pipecat AI applications
5
5
  License: BSD 2-Clause License
6
6
  Project-URL: Website, https://docs.pipecat.daily.co/
@@ -97,6 +97,32 @@ if __name__ == "__main__":
97
97
  asyncio.run(main())
98
98
  ```
99
99
 
100
+ ## Troubleshooting
101
+
102
+ ### SSL certificate errors on macOS
103
+
104
+ If `pcc auth login` fails with an SSL certificate verification error, your Python
105
+ installation may not have access to the macOS system certificate store. This is
106
+ common with Python installed via pyenv, conda, or the python.org installer.
107
+
108
+ To diagnose:
109
+
110
+ ```python
111
+ import ssl, sys, os
112
+ print(ssl.get_default_verify_paths())
113
+ print(os.path.realpath(sys.executable))
114
+ ```
115
+
116
+ To fix, install `certifi` and point Python to its certificates:
117
+
118
+ ```shell
119
+ pip install certifi
120
+ export SSL_CERT_FILE=$(python -c "import certifi; print(certifi.where())")
121
+ ```
122
+
123
+ For the python.org installer, you can also run the bundled
124
+ `Install Certificates.command` script found in `/Applications/Python X.Y/`.
125
+
100
126
  ## 🛠️ Contributing
101
127
 
102
128
  ### Setup Steps
@@ -74,6 +74,32 @@ if __name__ == "__main__":
74
74
  asyncio.run(main())
75
75
  ```
76
76
 
77
+ ## Troubleshooting
78
+
79
+ ### SSL certificate errors on macOS
80
+
81
+ If `pcc auth login` fails with an SSL certificate verification error, your Python
82
+ installation may not have access to the macOS system certificate store. This is
83
+ common with Python installed via pyenv, conda, or the python.org installer.
84
+
85
+ To diagnose:
86
+
87
+ ```python
88
+ import ssl, sys, os
89
+ print(ssl.get_default_verify_paths())
90
+ print(os.path.realpath(sys.executable))
91
+ ```
92
+
93
+ To fix, install `certifi` and point Python to its certificates:
94
+
95
+ ```shell
96
+ pip install certifi
97
+ export SSL_CERT_FILE=$(python -c "import certifi; print(certifi.where())")
98
+ ```
99
+
100
+ For the python.org installer, you can also run the bundled
101
+ `Install Certificates.command` script found in `/Applications/Python X.Y/`.
102
+
77
103
  ## 🛠️ Contributing
78
104
 
79
105
  ### Setup Steps
@@ -233,7 +233,10 @@ class _API:
233
233
  return {"name": o["name"], "verbose_name": o["verboseName"]}
234
234
 
235
235
  # Default to first organization if active_org not found or not specified
236
- return {"name": results[0]["name"], "verbose_name": results[0]["verboseName"]}
236
+ return {
237
+ "name": results["organizations"][0]["name"],
238
+ "verbose_name": results["organizations"][0]["verboseName"],
239
+ }
237
240
 
238
241
  @property
239
242
  def organizations_current(self):
@@ -40,6 +40,7 @@ USER_AGENT = f"PipecatCloudCLI/{_cli_version}"
40
40
  # Ports to try for the localhost OAuth callback server.
41
41
  # Our audience is developers who may have services running on common ports.
42
42
  # We try a range and use the first available.
43
+ CALLBACK_HOST = "127.0.0.1"
43
44
  CALLBACK_PORTS = [8400, 8401, 8402, 8403, 8404]
44
45
  CALLBACK_PATH = "/oauth_callback"
45
46
 
@@ -63,17 +64,49 @@ async def _fetch_oauth_config() -> dict:
63
64
 
64
65
 
65
66
  async def _fetch_oidc_discovery(issuer: str) -> dict:
66
- """Fetch OIDC discovery document from the OAuth issuer (RFC 8414).
67
+ """Fetch and validate OIDC discovery document from the OAuth issuer (RFC 8414).
67
68
 
68
69
  This gives us the authorization and token endpoints without
69
70
  hardcoding any provider-specific URLs.
71
+
72
+ Validates the discovery metadata per RFC 8414 §3.1 and RFC 9207 §2.4:
73
+ - issuer must exactly match the expected value
74
+ - authorization_endpoint and token_endpoint must use HTTPS
75
+ - code_challenge_methods_supported must include S256 (when present)
76
+ - response_types_supported must include "code" (when present)
70
77
  """
71
78
  url = f"{issuer}/.well-known/openid-configuration"
72
79
  async with aiohttp.ClientSession() as session:
73
80
  async with session.get(url, headers={"User-Agent": USER_AGENT}) as resp:
74
81
  if resp.status != 200:
75
82
  raise RuntimeError(f"OIDC discovery failed ({resp.status}) at {url}")
76
- return await resp.json()
83
+ doc = await resp.json()
84
+
85
+ # RFC 8414 §3.1: issuer in metadata MUST exactly match the expected issuer.
86
+ if doc.get("issuer") != issuer:
87
+ raise RuntimeError(f"OIDC issuer mismatch: expected {issuer!r}, got {doc.get('issuer')!r}")
88
+
89
+ # Endpoints must use HTTPS to prevent credential interception.
90
+ for key in ("authorization_endpoint", "token_endpoint"):
91
+ endpoint = doc.get(key, "")
92
+ if not endpoint.startswith("https://"):
93
+ raise RuntimeError(f"OIDC {key} must use HTTPS, got {endpoint!r}")
94
+
95
+ # If the server advertises supported challenge methods, verify S256 is included.
96
+ challenge_methods = doc.get("code_challenge_methods_supported")
97
+ if challenge_methods is not None and "S256" not in challenge_methods:
98
+ raise RuntimeError(
99
+ f"OIDC server does not support S256 PKCE (supported: {challenge_methods})"
100
+ )
101
+
102
+ # If the server advertises supported response types, verify "code" is included.
103
+ response_types = doc.get("response_types_supported")
104
+ if response_types is not None and "code" not in response_types:
105
+ raise RuntimeError(
106
+ f"OIDC server does not support 'code' response type (supported: {response_types})"
107
+ )
108
+
109
+ return doc
77
110
 
78
111
 
79
112
  # ---- Helpers ----
@@ -241,7 +274,7 @@ async def _start_callback_server() -> Tuple[Any, int, "asyncio.Future"]:
241
274
  # so we use a less-common range and try multiple.
242
275
  for port in CALLBACK_PORTS:
243
276
  try:
244
- site = web.TCPSite(runner, "localhost", port)
277
+ site = web.TCPSite(runner, CALLBACK_HOST, port)
245
278
  await site.start()
246
279
  return runner, port, result_future
247
280
  except OSError:
@@ -343,7 +376,7 @@ async def login():
343
376
  console.error(str(e))
344
377
  return
345
378
 
346
- redirect_uri = f"http://localhost:{port}{CALLBACK_PATH}"
379
+ redirect_uri = f"http://{CALLBACK_HOST}:{port}{CALLBACK_PATH}"
347
380
 
348
381
  try:
349
382
  # Generate PKCE verifier + challenge
@@ -442,6 +475,7 @@ async def login():
442
475
  @requires_login
443
476
  async def logout():
444
477
  refresh_token = config.get("refresh_token")
478
+ revocation_succeeded = False
445
479
 
446
480
  # If this is an OAuth session, attempt server-side token revocation (PCC-678).
447
481
  # The /auth/logout endpoint proxies revocation to Clerk with the client_secret.
@@ -464,7 +498,7 @@ async def logout():
464
498
  "Token will expire within 24 hours.[/yellow]"
465
499
  )
466
500
  elif resp.ok:
467
- logger.debug("Server-side token revocation succeeded")
501
+ revocation_succeeded = True
468
502
  else:
469
503
  logger.debug(f"Logout revocation returned {resp.status}")
470
504
  except (aiohttp.ClientError, asyncio.TimeoutError) as e:
@@ -477,8 +511,13 @@ async def logout():
477
511
  with console.status("[dim]Removing credentials...[/dim]", spinner="dots"):
478
512
  remove_user_config()
479
513
 
480
- if refresh_token:
514
+ if revocation_succeeded:
481
515
  console.success("Logged out and session revoked.")
516
+ elif refresh_token:
517
+ console.success(
518
+ "Local credentials removed. Server-side revocation could not be confirmed.\n"
519
+ "[dim]The token will expire automatically within 24 hours.[/dim]"
520
+ )
482
521
  else:
483
522
  console.success(
484
523
  "User credentials for Pipecat Cloud removed. Please sign out via dashboard to fully revoke session.",
@@ -528,8 +567,23 @@ async def _use_pat_impl(token: str):
528
567
  @auth_cli.command(name="use-pat", help="Authenticate with a Personal Access Token")
529
568
  @synchronizer.create_blocking
530
569
  async def use_pat(
531
- token: str = typer.Argument(help="Personal Access Token (pcc_pat_...)"),
570
+ token: Optional[str] = typer.Argument(
571
+ None, help="[deprecated] PAT as argument (leaks to shell history)"
572
+ ),
532
573
  ):
574
+ if token is not None:
575
+ console.print(
576
+ "[yellow]Warning: Passing tokens as arguments is deprecated — they are visible in "
577
+ "shell history and process listings. Next time, omit the argument to use the "
578
+ "secure prompt.[/yellow]"
579
+ )
580
+ else:
581
+ import getpass
582
+
583
+ token = getpass.getpass("Personal Access Token: ")
584
+ if not token:
585
+ console.error("No token provided.")
586
+ return
533
587
  await _use_pat_impl(token)
534
588
 
535
589
 
@@ -15,7 +15,6 @@ import questionary
15
15
  import typer
16
16
  from loguru import logger
17
17
  from rich import box
18
- from rich.live import Live
19
18
  from rich.panel import Panel
20
19
  from rich.table import Table
21
20
 
@@ -492,7 +491,7 @@ async def delete(
492
491
 
493
492
 
494
493
  @secrets_cli.command(
495
- name="image-pull-secret", help="Create an image pull secret for active organization."
494
+ name="image-pull-secret", help="Create or update an image pull secret for active organization."
496
495
  )
497
496
  @synchronizer.create_blocking
498
497
  @requires_login
@@ -509,6 +508,12 @@ async def image_pull_secret(
509
508
  base64encode: bool = typer.Option(
510
509
  True, "--encode", "-e", help="base64 encode credentials for added security"
511
510
  ),
511
+ skip_confirm: boolean = typer.Option(
512
+ False,
513
+ "--skip",
514
+ "-s",
515
+ help="Skip confirmations / force creation or update",
516
+ ),
512
517
  organization: str = typer.Option(
513
518
  None,
514
519
  "--organization",
@@ -556,13 +561,10 @@ async def image_pull_secret(
556
561
  return typer.Exit(1)
557
562
 
558
563
  # Check if secret already exists
559
- used_region = None
560
- with Live(
561
- console.status(
562
- f"[dim]Checking if image pull secret '{name}' already exists[/dim]", spinner="dots"
563
- ),
564
- refresh_per_second=4,
565
- ) as live:
564
+ existing_secret = None
565
+ with console.status(
566
+ f"[dim]Checking if image pull secret '{name}' already exists[/dim]", spinner="dots"
567
+ ):
566
568
  data, error = await API.secrets_list(org=org)
567
569
 
568
570
  if error:
@@ -572,19 +574,20 @@ async def image_pull_secret(
572
574
  existing_secret = next(
573
575
  (s for s in data if s["name"] == name and s["type"] == "imagePullSecret"), None
574
576
  )
575
- if existing_secret:
576
- live.stop()
577
- console.error(
578
- f"Image pull secret '[bold]{name}'[/bold] already exists. Please choose a different name or delete the existing one first."
579
- )
580
- return typer.Exit(1)
581
577
 
582
- live.update(
583
- console.status(
584
- f"[dim]Creating image pull secret [bold]'{name}'[/bold][/dim]", spinner="dots"
585
- )
586
- )
578
+ if existing_secret and not skip_confirm:
579
+ confirm = await questionary.confirm(
580
+ f"Image pull secret '{name}' already exists. Update it?"
581
+ ).ask_async()
582
+ if not confirm:
583
+ console.print("[bold red]Update cancelled[/bold red]")
584
+ return typer.Exit(1)
587
585
 
586
+ used_region = None
587
+ with console.status(
588
+ f"[dim]{'Updating' if existing_secret else 'Creating'} image pull secret [bold]'{name}'[/bold][/dim]",
589
+ spinner="dots",
590
+ ):
588
591
  data, error = await API.secrets_upsert(
589
592
  data={"isImagePullSecret": True, "secretValue": credentials, "host": host},
590
593
  set_name=name,
@@ -600,6 +603,7 @@ async def image_pull_secret(
600
603
  used_region = data["region"]
601
604
 
602
605
  region_info = f" in [bold cyan]{used_region}[/bold cyan]" if used_region else ""
606
+ action = "updated" if existing_secret else "created"
603
607
  console.success(
604
- f"Image pull secret [bold green]'{name}'[/bold green] for [bold green]{host}[/bold green] created successfully{region_info}.",
608
+ f"Image pull secret [bold green]'{name}'[/bold green] for [bold green]{host}[/bold green] {action} successfully{region_info}.",
605
609
  )
@@ -7,6 +7,7 @@
7
7
  import os
8
8
  import stat
9
9
  import sys
10
+ import tempfile
10
11
  from typing import Optional
11
12
 
12
13
  import toml
@@ -80,20 +81,38 @@ user_config = _read_user_config()
80
81
 
81
82
  def _write_user_config(new_config):
82
83
  dir_path = os.path.dirname(user_config_path)
83
- os.makedirs(dir_path, exist_ok=True)
84
+ os.makedirs(dir_path, mode=0o700, exist_ok=True)
84
85
 
85
- with open(user_config_path, "w") as f:
86
- toml.dump(new_config, f)
87
-
88
- # Restrict permissions so only the file owner can access credentials.
89
- # On Windows os.chmod is limited but home directories are already
90
- # protected by ACLs, so this is effectively a Unix-only hardening.
91
- os.chmod(dir_path, stat.S_IRWXU) # 0o700
92
- os.chmod(user_config_path, stat.S_IRUSR | stat.S_IWUSR) # 0o600
86
+ # Atomic write: serialize to a temp file in the same directory, fsync,
87
+ # then rename into place. This prevents a crash mid-write from leaving
88
+ # a truncated or empty credentials file (RFC 6749 §10.3).
89
+ fd = None
90
+ tmp_path = None
91
+ try:
92
+ fd, tmp_path = tempfile.mkstemp(dir=dir_path, prefix=".pipecatcloud_", suffix=".tmp")
93
+ # mkstemp opens with 0o600 by default — restrictive from creation.
94
+ with os.fdopen(fd, "w") as f:
95
+ fd = None # os.fdopen takes ownership of the fd
96
+ toml.dump(new_config, f)
97
+ f.flush()
98
+ os.fsync(f.fileno())
99
+ os.replace(tmp_path, user_config_path)
100
+ tmp_path = None # rename succeeded, nothing to clean up
101
+ finally:
102
+ if fd is not None:
103
+ os.close(fd)
104
+ if tmp_path is not None:
105
+ try:
106
+ os.unlink(tmp_path)
107
+ except OSError:
108
+ pass
93
109
 
94
110
 
95
111
  def remove_user_config():
96
- os.remove(user_config_path)
112
+ try:
113
+ os.remove(user_config_path)
114
+ except FileNotFoundError:
115
+ pass
97
116
 
98
117
 
99
118
  def update_user_config(
@@ -42,8 +42,18 @@ def show_config_callback(value: bool):
42
42
  from pipecatcloud._utils.deploy_utils import load_deploy_config_file
43
43
  from pipecatcloud.cli.config import deploy_config_path
44
44
 
45
- # Print local config
46
- pprint(config.to_dict())
45
+ config_dict = config.to_dict()
46
+
47
+ # Redact credential values — users who need raw values can read the
48
+ # config file directly (~/.config/pipecatcloud/pipecatcloud.toml).
49
+ for key in ("token", "refresh_token"):
50
+ if config_dict.get(key):
51
+ val = str(config_dict[key])
52
+ # Show token type prefix (e.g. "pcc_pat_****") for diagnostics
53
+ prefix = val[:8] if len(val) > 12 else ""
54
+ config_dict[key] = f"{prefix}****"
55
+
56
+ pprint(config_dict)
47
57
 
48
58
  # Check for deploy config
49
59
  try:
@@ -76,7 +86,7 @@ def cli(
76
86
  None,
77
87
  "--show-cli-config",
78
88
  callback=show_config_callback,
79
- help="Show CLI internal configuration (credentials, active org)",
89
+ help="Show CLI internal configuration (credentials redacted)",
80
90
  ),
81
91
  ):
82
92
  pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pipecatcloud
3
- Version: 0.4.3
3
+ Version: 0.4.4
4
4
  Summary: Cloud hosting for Pipecat AI applications
5
5
  License: BSD 2-Clause License
6
6
  Project-URL: Website, https://docs.pipecat.daily.co/
@@ -97,6 +97,32 @@ if __name__ == "__main__":
97
97
  asyncio.run(main())
98
98
  ```
99
99
 
100
+ ## Troubleshooting
101
+
102
+ ### SSL certificate errors on macOS
103
+
104
+ If `pcc auth login` fails with an SSL certificate verification error, your Python
105
+ installation may not have access to the macOS system certificate store. This is
106
+ common with Python installed via pyenv, conda, or the python.org installer.
107
+
108
+ To diagnose:
109
+
110
+ ```python
111
+ import ssl, sys, os
112
+ print(ssl.get_default_verify_paths())
113
+ print(os.path.realpath(sys.executable))
114
+ ```
115
+
116
+ To fix, install `certifi` and point Python to its certificates:
117
+
118
+ ```shell
119
+ pip install certifi
120
+ export SSL_CERT_FILE=$(python -c "import certifi; print(certifi.where())")
121
+ ```
122
+
123
+ For the python.org installer, you can also run the bundled
124
+ `Install Certificates.command` script found in `/Applications/Python X.Y/`.
125
+
100
126
  ## 🛠️ Contributing
101
127
 
102
128
  ### Setup Steps
@@ -55,6 +55,7 @@ tests/conftest.py
55
55
  tests/test_agent_sessions.py
56
56
  tests/test_agent_stop.py
57
57
  tests/test_api_client.py
58
+ tests/test_auth.py
58
59
  tests/test_config_file.py
59
60
  tests/test_deployment_status.py
60
61
  tests/test_docker_commands.py
@@ -345,3 +345,51 @@ class TestAPIProperties:
345
345
  # Verify payload
346
346
  payload = mock_request.call_args[1]["json"]
347
347
  assert payload == {"defaultRegion": "eu-central"}
348
+
349
+
350
+ class TestOrganizationsCurrent:
351
+ """Test organizations_current fallback behavior."""
352
+
353
+ @pytest.fixture
354
+ def api_client(self):
355
+ return _API(token="test-token", is_cli=True)
356
+
357
+ @pytest.mark.asyncio
358
+ async def test_fallback_returns_first_org(self, api_client):
359
+ """Regression: fallback path must index into results['organizations'], not results."""
360
+ with patch.object(api_client, "_base_request", new_callable=AsyncMock) as mock_request:
361
+ mock_request.return_value = {
362
+ "organizations": [
363
+ {"name": "my-org", "verboseName": "My Organization"},
364
+ {"name": "other-org", "verboseName": "Other Org"},
365
+ ]
366
+ }
367
+
368
+ result = await api_client._organizations_current(org=None)
369
+
370
+ assert result == {"name": "my-org", "verbose_name": "My Organization"}
371
+
372
+ @pytest.mark.asyncio
373
+ async def test_matching_org_returned(self, api_client):
374
+ """When org is specified, the matching org should be returned."""
375
+ with patch.object(api_client, "_base_request", new_callable=AsyncMock) as mock_request:
376
+ mock_request.return_value = {
377
+ "organizations": [
378
+ {"name": "first-org", "verboseName": "First Org"},
379
+ {"name": "target-org", "verboseName": "Target Org"},
380
+ ]
381
+ }
382
+
383
+ result = await api_client._organizations_current(org="target-org")
384
+
385
+ assert result == {"name": "target-org", "verbose_name": "Target Org"}
386
+
387
+ @pytest.mark.asyncio
388
+ async def test_no_orgs_returns_none(self, api_client):
389
+ """When the user has no organizations, return None."""
390
+ with patch.object(api_client, "_base_request", new_callable=AsyncMock) as mock_request:
391
+ mock_request.return_value = {"organizations": []}
392
+
393
+ result = await api_client._organizations_current(org=None)
394
+
395
+ assert result is None
@@ -0,0 +1,303 @@
1
+ """
2
+ Unit tests for auth flow internals.
3
+
4
+ Covers PKCE generation, callback server edge cases, token refresh,
5
+ discovery validation, and logout resilience.
6
+ """
7
+
8
+ import asyncio
9
+ from unittest.mock import AsyncMock, MagicMock, patch
10
+
11
+ import pytest
12
+
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
17
+
18
+ from pipecatcloud.cli.commands.auth import (
19
+ _generate_code_challenge,
20
+ _generate_code_verifier,
21
+ _fetch_oidc_discovery,
22
+ _start_callback_server,
23
+ refresh_access_token,
24
+ )
25
+
26
+
27
+ # ---- PKCE (RFC 7636) ----
28
+
29
+
30
+ class TestPKCE:
31
+ """Test PKCE code verifier and challenge generation."""
32
+
33
+ def test_rfc7636_appendix_b_vector(self):
34
+ """RFC 7636 Appendix B reference vector."""
35
+ verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
36
+ expected_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
37
+ assert _generate_code_challenge(verifier) == expected_challenge
38
+
39
+ def test_verifier_length_in_range(self):
40
+ """RFC 7636 §4.1: verifier must be 43-128 characters."""
41
+ verifier = _generate_code_verifier()
42
+ assert 43 <= len(verifier) <= 128
43
+
44
+ def test_verifier_is_url_safe(self):
45
+ """Verifier must only contain unreserved URI characters."""
46
+ import re
47
+
48
+ verifier = _generate_code_verifier()
49
+ assert re.fullmatch(r"[A-Za-z0-9_-]+", verifier)
50
+
51
+ def test_challenge_has_no_padding(self):
52
+ """BASE64URL encoding must strip '=' padding per RFC 7636 §4.2."""
53
+ challenge = _generate_code_challenge("test-verifier-string-that-is-long-enough")
54
+ assert "=" not in challenge
55
+
56
+
57
+ # ---- Callback server ----
58
+
59
+
60
+ class TestCallbackServer:
61
+ """Test the localhost OAuth callback server edge cases."""
62
+
63
+ @pytest.mark.asyncio
64
+ async def test_callback_error_sets_none_result(self):
65
+ """OAuth error in callback should resolve the future with (None, None)."""
66
+ runner, port, result_future = await _start_callback_server()
67
+ try:
68
+ async with __import__("aiohttp").ClientSession() as session:
69
+ await session.get(f"http://127.0.0.1:{port}/oauth_callback?error=access_denied")
70
+ code, state = result_future.result()
71
+ assert code is None
72
+ assert state is None
73
+ finally:
74
+ await runner.cleanup()
75
+
76
+ @pytest.mark.asyncio
77
+ async def test_callback_missing_code_returns_none_code(self):
78
+ """Callback with no code or error should return (None, state)."""
79
+ runner, port, result_future = await _start_callback_server()
80
+ try:
81
+ async with __import__("aiohttp").ClientSession() as session:
82
+ await session.get(f"http://127.0.0.1:{port}/oauth_callback?state=test-state")
83
+ code, state = result_future.result()
84
+ # code is None because query param "code" is absent
85
+ assert code is None
86
+ assert state == "test-state"
87
+ finally:
88
+ await runner.cleanup()
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_callback_success_returns_code_and_state(self):
92
+ """Successful callback should return the code and state."""
93
+ runner, port, result_future = await _start_callback_server()
94
+ try:
95
+ async with __import__("aiohttp").ClientSession() as session:
96
+ await session.get(
97
+ f"http://127.0.0.1:{port}/oauth_callback?code=auth-code-123&state=state-456"
98
+ )
99
+ code, state = result_future.result()
100
+ assert code == "auth-code-123"
101
+ assert state == "state-456"
102
+ finally:
103
+ await runner.cleanup()
104
+
105
+ @pytest.mark.asyncio
106
+ async def test_callback_timeout(self):
107
+ """The future should raise TimeoutError if no callback arrives."""
108
+ runner, _port, result_future = await _start_callback_server()
109
+ try:
110
+ with pytest.raises(asyncio.TimeoutError):
111
+ await asyncio.wait_for(result_future, timeout=0.1)
112
+ finally:
113
+ await runner.cleanup()
114
+
115
+
116
+ # ---- Discovery validation ----
117
+
118
+
119
+ class TestOIDCDiscoveryValidation:
120
+ """Test OIDC discovery metadata validation."""
121
+
122
+ def _make_valid_doc(self, issuer="https://auth.example.com"):
123
+ return {
124
+ "issuer": issuer,
125
+ "authorization_endpoint": "https://auth.example.com/authorize",
126
+ "token_endpoint": "https://auth.example.com/token",
127
+ "code_challenge_methods_supported": ["S256"],
128
+ "response_types_supported": ["code"],
129
+ }
130
+
131
+ def _mock_fetch(self, doc):
132
+ """Create a mock aiohttp response returning the given doc."""
133
+ mock_resp = AsyncMock()
134
+ mock_resp.status = 200
135
+ mock_resp.json = AsyncMock(return_value=doc)
136
+ mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
137
+ mock_resp.__aexit__ = AsyncMock(return_value=False)
138
+
139
+ mock_session = AsyncMock()
140
+ mock_session.get = MagicMock(return_value=mock_resp)
141
+ mock_session.__aenter__ = AsyncMock(return_value=mock_session)
142
+ mock_session.__aexit__ = AsyncMock(return_value=False)
143
+
144
+ return patch(
145
+ "pipecatcloud.cli.commands.auth.aiohttp.ClientSession", return_value=mock_session
146
+ )
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_valid_document_passes(self):
150
+ issuer = "https://auth.example.com"
151
+ doc = self._make_valid_doc(issuer)
152
+ with self._mock_fetch(doc):
153
+ result = await _fetch_oidc_discovery(issuer)
154
+ assert result["authorization_endpoint"] == "https://auth.example.com/authorize"
155
+
156
+ @pytest.mark.asyncio
157
+ async def test_issuer_mismatch_raises(self):
158
+ doc = self._make_valid_doc("https://wrong-issuer.example.com")
159
+ with self._mock_fetch(doc):
160
+ with pytest.raises(RuntimeError, match="issuer mismatch"):
161
+ await _fetch_oidc_discovery("https://auth.example.com")
162
+
163
+ @pytest.mark.asyncio
164
+ async def test_http_endpoint_raises(self):
165
+ doc = self._make_valid_doc()
166
+ doc["token_endpoint"] = "http://auth.example.com/token" # HTTP, not HTTPS
167
+ with self._mock_fetch(doc):
168
+ with pytest.raises(RuntimeError, match="must use HTTPS"):
169
+ await _fetch_oidc_discovery("https://auth.example.com")
170
+
171
+ @pytest.mark.asyncio
172
+ async def test_missing_s256_raises(self):
173
+ doc = self._make_valid_doc()
174
+ doc["code_challenge_methods_supported"] = ["plain"]
175
+ with self._mock_fetch(doc):
176
+ with pytest.raises(RuntimeError, match="S256"):
177
+ await _fetch_oidc_discovery("https://auth.example.com")
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_missing_code_response_type_raises(self):
181
+ doc = self._make_valid_doc()
182
+ doc["response_types_supported"] = ["token"]
183
+ with self._mock_fetch(doc):
184
+ with pytest.raises(RuntimeError, match="code"):
185
+ await _fetch_oidc_discovery("https://auth.example.com")
186
+
187
+ @pytest.mark.asyncio
188
+ async def test_absent_optional_fields_passes(self):
189
+ """When code_challenge_methods_supported / response_types_supported
190
+ are absent, validation should pass (they're optional in the spec)."""
191
+ doc = self._make_valid_doc()
192
+ del doc["code_challenge_methods_supported"]
193
+ del doc["response_types_supported"]
194
+ with self._mock_fetch(doc):
195
+ result = await _fetch_oidc_discovery("https://auth.example.com")
196
+ assert result["issuer"] == "https://auth.example.com"
197
+
198
+
199
+ # ---- Token refresh ----
200
+
201
+
202
+ class TestTokenRefresh:
203
+ """Test refresh_access_token success and failure paths."""
204
+
205
+ def _mock_oauth_and_oidc(self):
206
+ """Patch both discovery fetches to return valid config."""
207
+ oauth_config = {
208
+ "issuer": "https://auth.example.com",
209
+ "client_id": "test-client-id",
210
+ "scopes": "openid profile",
211
+ }
212
+ oidc_doc = {
213
+ "issuer": "https://auth.example.com",
214
+ "authorization_endpoint": "https://auth.example.com/authorize",
215
+ "token_endpoint": "https://auth.example.com/token",
216
+ }
217
+ return (
218
+ patch(
219
+ "pipecatcloud.cli.commands.auth._fetch_oauth_config",
220
+ new_callable=AsyncMock,
221
+ return_value=oauth_config,
222
+ ),
223
+ patch(
224
+ "pipecatcloud.cli.commands.auth._fetch_oidc_discovery",
225
+ new_callable=AsyncMock,
226
+ return_value=oidc_doc,
227
+ ),
228
+ )
229
+
230
+ @pytest.mark.asyncio
231
+ async def test_refresh_success(self):
232
+ mock_oauth, mock_oidc = self._mock_oauth_and_oidc()
233
+ token_response = {
234
+ "access_token": "new-token",
235
+ "refresh_token": "new-refresh",
236
+ "expires_in": 3600,
237
+ }
238
+
239
+ mock_resp = AsyncMock()
240
+ mock_resp.status = 200
241
+ mock_resp.json = AsyncMock(return_value=token_response)
242
+ mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
243
+ mock_resp.__aexit__ = AsyncMock(return_value=False)
244
+
245
+ mock_session = AsyncMock()
246
+ mock_session.post = MagicMock(return_value=mock_resp)
247
+ mock_session.__aenter__ = AsyncMock(return_value=mock_session)
248
+ mock_session.__aexit__ = AsyncMock(return_value=False)
249
+
250
+ with mock_oauth, mock_oidc:
251
+ with patch(
252
+ "pipecatcloud.cli.commands.auth.aiohttp.ClientSession", return_value=mock_session
253
+ ):
254
+ result = await refresh_access_token("old-refresh-token")
255
+
256
+ assert result["access_token"] == "new-token"
257
+
258
+ @pytest.mark.asyncio
259
+ async def test_refresh_failure_returns_none(self):
260
+ mock_oauth, mock_oidc = self._mock_oauth_and_oidc()
261
+
262
+ mock_resp = AsyncMock()
263
+ mock_resp.status = 401
264
+ mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
265
+ mock_resp.__aexit__ = AsyncMock(return_value=False)
266
+
267
+ mock_session = AsyncMock()
268
+ mock_session.post = MagicMock(return_value=mock_resp)
269
+ mock_session.__aenter__ = AsyncMock(return_value=mock_session)
270
+ mock_session.__aexit__ = AsyncMock(return_value=False)
271
+
272
+ with mock_oauth, mock_oidc:
273
+ with patch(
274
+ "pipecatcloud.cli.commands.auth.aiohttp.ClientSession", return_value=mock_session
275
+ ):
276
+ result = await refresh_access_token("bad-refresh-token")
277
+
278
+ assert result is None
279
+
280
+ @pytest.mark.asyncio
281
+ async def test_refresh_config_fetch_failure_returns_none(self):
282
+ with patch(
283
+ "pipecatcloud.cli.commands.auth._fetch_oauth_config",
284
+ new_callable=AsyncMock,
285
+ side_effect=RuntimeError("unreachable"),
286
+ ):
287
+ result = await refresh_access_token("some-refresh-token")
288
+
289
+ assert result is None
290
+
291
+
292
+ # ---- Logout without config file ----
293
+
294
+
295
+ class TestLogoutWithoutConfig:
296
+ """Test that remove_user_config is idempotent."""
297
+
298
+ def test_remove_missing_config_does_not_raise(self):
299
+ from pipecatcloud.cli.config import remove_user_config
300
+
301
+ with patch("pipecatcloud.cli.config.user_config_path", "/nonexistent/path/config.toml"):
302
+ # Should not raise
303
+ remove_user_config()
File without changes
File without changes
File without changes
File without changes
File without changes