licos-platform-cli 0.2.10__tar.gz → 0.2.13__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.
@@ -40,6 +40,8 @@ crates/industrial/bin/
40
40
 
41
41
  /tmp
42
42
  tools/android-sdk-cache/*.zip
43
+ tools/codegraph-cache/*.tgz
44
+ tools/codegraph-cache/*.tgz
43
45
 
44
46
  *.codex-*
45
47
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-platform-cli
3
- Version: 0.2.10
3
+ Version: 0.2.13
4
4
  Summary: LICOS platform CLI
5
5
  Requires-Python: >=3.10
6
- Requires-Dist: licos-platform-sdk>=0.2.9
6
+ Requires-Dist: licos-platform-sdk>=0.3.0
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-platform-cli"
7
- version = "0.2.10"
7
+ version = "0.2.13"
8
8
  description = "LICOS platform CLI"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
11
- "licos-platform-sdk>=0.2.9",
11
+ "licos-platform-sdk>=0.3.0",
12
12
  ]
13
13
 
14
14
  [project.scripts]
@@ -451,10 +451,58 @@ KNOWLEDGE_HELP = {
451
451
  }
452
452
 
453
453
 
454
+ OAUTH_HELP = {
455
+ "topic": "oauth",
456
+ "description": "Manage LICOS OAuth2/OIDC login applications from trusted project runtime code.",
457
+ "runtime": RUNTIME_ENV,
458
+ "rules": [
459
+ "Use only when the user explicitly requests LICOS platform OAuth2/OIDC login.",
460
+ "Before configure-project, confirm auto-apply versus an existing app, login audience, and all callbacks.",
461
+ "Personal users use ALL. Enterprise users explicitly choose ALL or OWNER_TENANT.",
462
+ "Generated config uses PKCE and never contains a client secret.",
463
+ "Do not rotate a secret unless the user explicitly asks for rotation.",
464
+ ],
465
+ "sdk": {
466
+ "python": "from licos_platform_sdk import oauth\napps = oauth.list_apps()\napp = oauth.get_app('app-id')",
467
+ "node": "import { oauth } from '@kmlckj/licos-platform-sdk';\nconst apps = await oauth.listApps();",
468
+ },
469
+ "commands": {
470
+ "apps": {
471
+ "description": "List OAuth2 applications manageable by the current project owner.",
472
+ "parameters": [],
473
+ "example": "licos-platform oauth apps",
474
+ },
475
+ "get": {
476
+ "description": "Get one manageable OAuth2 application.",
477
+ "parameters": [{"name": "app_id", "required": True, "description": "OAuth2 application ID."}],
478
+ "example": "licos-platform oauth get app-id",
479
+ },
480
+ "configure-project": {
481
+ "description": "After explicit confirmation, apply or update one OAuth2 app and write config/licos-oauth.json without a secret.",
482
+ "parameters": [
483
+ {"name": "--auto-apply / --app-id", "required": True, "description": "Explicit application selection mode."},
484
+ {"name": "--name", "required": True, "description": "Application display name."},
485
+ {"name": "--login-audience", "required": True, "description": "ALL or OWNER_TENANT."},
486
+ {"name": "--redirect", "required": False, "description": "Optional explicit callback override; otherwise derive stable dev/prod callbacks from project runtime metadata."},
487
+ {"name": "--public-base-url", "required": False, "description": "Optional public LICOS OAuth2 origin override."},
488
+ ],
489
+ "example": "licos-platform oauth configure-project --auto-apply --name 'Project login' --login-audience ALL",
490
+ },
491
+ "delete": {"description": "Delete an OAuth2 application.", "parameters": [], "example": "licos-platform oauth delete app-id"},
492
+ "enable": {"description": "Enable an OAuth2 application.", "parameters": [], "example": "licos-platform oauth enable app-id"},
493
+ "disable": {"description": "Disable an OAuth2 application.", "parameters": [], "example": "licos-platform oauth disable app-id"},
494
+ "rotate-secret": {"description": "Explicitly rotate a confidential-client secret.", "parameters": [], "example": "licos-platform oauth rotate-secret app-id"},
495
+ "grants": {"description": "List application grants.", "parameters": [], "example": "licos-platform oauth grants app-id"},
496
+ "revoke-grant": {"description": "Revoke one grant.", "parameters": [], "example": "licos-platform oauth revoke-grant grant-id"},
497
+ },
498
+ }
499
+
500
+
454
501
  HELP_TOPICS = {
455
502
  "storage": STORAGE_HELP,
456
503
  "database": DATABASE_HELP,
457
504
  "knowledge": KNOWLEDGE_HELP,
505
+ "oauth": OAUTH_HELP,
458
506
  }
459
507
 
460
508
 
@@ -475,6 +523,8 @@ def _cmd_help(args: argparse.Namespace) -> dict[str, Any]:
475
523
  "licos-platform help database studio-ensure",
476
524
  "licos-platform help knowledge",
477
525
  "licos-platform help knowledge search",
526
+ "licos-platform help oauth",
527
+ "licos-platform help oauth configure-project",
478
528
  ],
479
529
  "topics": sorted(HELP_TOPICS),
480
530
  }
@@ -10,6 +10,7 @@ from licos_platform_sdk import PlatformSdkError
10
10
  from .database_commands import add_database_parser
11
11
  from .help_commands import add_help_parser
12
12
  from .knowledge_commands import add_knowledge_parser
13
+ from .oauth_commands import add_oauth_parser
13
14
  from .storage_commands import add_storage_parser
14
15
 
15
16
 
@@ -32,6 +33,7 @@ def _build_parser() -> argparse.ArgumentParser:
32
33
  add_storage_parser(subparsers)
33
34
  add_database_parser(subparsers)
34
35
  add_knowledge_parser(subparsers)
36
+ add_oauth_parser(subparsers)
35
37
  return parser
36
38
 
37
39
 
@@ -0,0 +1,252 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+ from urllib.parse import quote, urlsplit, urlunsplit
9
+
10
+ from licos_platform_sdk import oauth
11
+
12
+
13
+ Handler = Callable[[argparse.Namespace], Any]
14
+ DEFAULT_SCOPES = ["openid", "profile", "email"]
15
+
16
+
17
+ def _set_handler(parser: argparse.ArgumentParser, handler: Handler) -> None:
18
+ parser.set_defaults(handler=handler)
19
+
20
+
21
+ def _parse_http_url(value: str, *, option: str) -> str:
22
+ candidate = value.strip()
23
+ parsed = urlsplit(candidate)
24
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
25
+ raise argparse.ArgumentTypeError(f"{option} must be an absolute http(s) URL")
26
+ if parsed.fragment:
27
+ raise argparse.ArgumentTypeError(f"{option} must not contain a fragment")
28
+ return candidate
29
+
30
+
31
+ def _parse_redirect(value: str) -> dict[str, str]:
32
+ environment = "prod"
33
+ redirect_uri = value
34
+ prefix, separator, remainder = value.partition("=")
35
+ if separator:
36
+ environment = prefix.strip().lower()
37
+ redirect_uri = remainder
38
+ if environment not in {"dev", "prod"}:
39
+ raise argparse.ArgumentTypeError("redirect environment must be dev or prod")
40
+ return {
41
+ "environment": environment,
42
+ "redirectUri": _parse_http_url(redirect_uri, option="--redirect"),
43
+ }
44
+
45
+
46
+ def _public_base_url(value: str) -> str:
47
+ parsed = urlsplit(_parse_http_url(value, option="--public-base-url"))
48
+ path = parsed.path.rstrip("/")
49
+ if path.endswith("/api/v1"):
50
+ path = path[: -len("/api/v1")]
51
+ return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")).rstrip("/")
52
+
53
+
54
+ def _required_env(name: str) -> str:
55
+ value = (os.environ.get(name) or "").strip()
56
+ if not value:
57
+ raise argparse.ArgumentTypeError(
58
+ f"{name} is required when --redirect is not provided"
59
+ )
60
+ return value
61
+
62
+
63
+ def _automatic_redirects() -> list[dict[str, str]]:
64
+ project_id = _required_env("AGENT_PROJECT_ID")
65
+ encoded_project_id = quote(project_id, safe="")
66
+ platform_public_base = _public_base_url(_required_env("LICOS_PLATFORM_PUBLIC_BASE_URL"))
67
+ deploy_public_base = _public_base_url(_required_env("LICOS_DEPLOY_PUBLIC_BASE_URL"))
68
+ return [
69
+ {
70
+ "environment": "dev",
71
+ "redirectUri": (
72
+ f"{platform_public_base}/agent/api/v1/projects/"
73
+ f"{encoded_project_id}/oauth/callback"
74
+ ),
75
+ },
76
+ {
77
+ "environment": "prod",
78
+ "redirectUri": (
79
+ f"{deploy_public_base}/p/{encoded_project_id}/api/auth/licos/callback"
80
+ ),
81
+ },
82
+ ]
83
+
84
+
85
+ def _project_root() -> Path:
86
+ return Path(os.environ.get("LICOS_PROJECT_PATH") or Path.cwd()).resolve()
87
+
88
+
89
+ def _config_path(output: str) -> Path:
90
+ root = _project_root()
91
+ candidate = Path(output)
92
+ resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
93
+ try:
94
+ resolved.relative_to(root)
95
+ except ValueError as exc:
96
+ raise argparse.ArgumentTypeError("--out must stay inside LICOS_PROJECT_PATH") from exc
97
+ return resolved
98
+
99
+
100
+ def _without_secret(value: Any) -> Any:
101
+ if isinstance(value, dict):
102
+ return {
103
+ key: _without_secret(item)
104
+ for key, item in value.items()
105
+ if "secret" not in key.lower().replace("_", "")
106
+ }
107
+ if isinstance(value, list):
108
+ return [_without_secret(item) for item in value]
109
+ return value
110
+
111
+
112
+ def _cmd_apps(args: argparse.Namespace) -> Any:
113
+ return _without_secret(oauth.list_apps(page=args.page, page_size=args.page_size))
114
+
115
+
116
+ def _cmd_get(args: argparse.Namespace) -> Any:
117
+ return _without_secret(oauth.get_app(args.app_id))
118
+
119
+
120
+ def _cmd_delete(args: argparse.Namespace) -> Any:
121
+ return oauth.delete_app(args.app_id)
122
+
123
+
124
+ def _cmd_set_enabled(args: argparse.Namespace) -> Any:
125
+ return _without_secret(oauth.set_enabled(args.app_id, args.enabled))
126
+
127
+
128
+ def _cmd_rotate_secret(args: argparse.Namespace) -> Any:
129
+ return oauth.rotate_secret(args.app_id)
130
+
131
+
132
+ def _cmd_grants(args: argparse.Namespace) -> Any:
133
+ return oauth.list_grants(args.app_id)
134
+
135
+
136
+ def _cmd_revoke_grant(args: argparse.Namespace) -> Any:
137
+ return oauth.revoke_grant(args.grant_id)
138
+
139
+
140
+ def _cmd_configure_project(args: argparse.Namespace) -> dict[str, Any]:
141
+ public_base_url = (
142
+ args.public_base_url
143
+ or os.environ.get("LICOS_PLATFORM_PUBLIC_BASE_URL")
144
+ or os.environ.get("LICOS_OAUTH_PUBLIC_BASE_URL")
145
+ )
146
+ if not public_base_url:
147
+ raise argparse.ArgumentTypeError(
148
+ "--public-base-url, LICOS_PLATFORM_PUBLIC_BASE_URL, or LICOS_OAUTH_PUBLIC_BASE_URL is required"
149
+ )
150
+ normalized_public_base_url = _public_base_url(public_base_url)
151
+ redirects = args.redirects or _automatic_redirects()
152
+ path = _config_path(args.out)
153
+ app = oauth.ensure_project_app(
154
+ app_id=args.app_id,
155
+ name=args.name,
156
+ description=args.description,
157
+ login_audience=args.login_audience,
158
+ redirect_uri_configs=redirects,
159
+ allowed_scopes=args.scopes or DEFAULT_SCOPES,
160
+ )
161
+ app_id = str(app.get("id") or "").strip()
162
+ if not app_id:
163
+ raise argparse.ArgumentTypeError("platform OAuth2 app response is missing id")
164
+ client_id = str(app.get("clientId") or "").strip()
165
+ if not client_id:
166
+ raise argparse.ArgumentTypeError("platform OAuth2 app response is missing clientId")
167
+
168
+ config = {
169
+ "provider": "licos-oauth2",
170
+ "appId": app_id,
171
+ "appCode": app.get("appCode"),
172
+ "clientId": client_id,
173
+ "loginAudience": args.login_audience,
174
+ "scopes": args.scopes or DEFAULT_SCOPES,
175
+ "redirectUriConfigs": redirects,
176
+ "pkce": True,
177
+ "publicBaseUrl": normalized_public_base_url,
178
+ "endpoints": {
179
+ "authorization": "/oauth2/authorize",
180
+ "token": "/oauth2/token",
181
+ "userinfo": "/oauth2/userinfo",
182
+ "revoke": "/oauth2/revoke",
183
+ "discovery": "/.well-known/openid-configuration",
184
+ "jwks": "/oauth2/jwks",
185
+ },
186
+ }
187
+ path.parent.mkdir(parents=True, exist_ok=True)
188
+ path.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
189
+ return {
190
+ "app": _without_secret(app),
191
+ "configPath": str(path),
192
+ "selectionMode": "existing" if args.app_id else "auto-apply",
193
+ }
194
+
195
+
196
+ def add_oauth_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
197
+ parser = subparsers.add_parser("oauth", help="Manage LICOS platform OAuth2 applications")
198
+ commands = parser.add_subparsers(dest="oauth_command", required=True)
199
+
200
+ apps = commands.add_parser("apps", help="List manageable OAuth2 applications")
201
+ apps.add_argument("--page", type=int, default=1)
202
+ apps.add_argument("--page-size", type=int, default=100)
203
+ _set_handler(apps, _cmd_apps)
204
+
205
+ get_app = commands.add_parser("get", help="Get one OAuth2 application")
206
+ get_app.add_argument("app_id")
207
+ _set_handler(get_app, _cmd_get)
208
+
209
+ configure = commands.add_parser(
210
+ "configure-project",
211
+ help="Configure a confirmed OAuth2 application and write public PKCE project settings",
212
+ )
213
+ selection = configure.add_mutually_exclusive_group(required=True)
214
+ selection.add_argument("--auto-apply", action="store_true", help="Create or update the app for this project ID")
215
+ selection.add_argument("--app-id", default=None, help="Use and update an existing manageable OAuth2 app")
216
+ configure.add_argument("--name", required=True)
217
+ configure.add_argument("--description", default=None)
218
+ configure.add_argument("--login-audience", required=True, choices=["ALL", "OWNER_TENANT"])
219
+ configure.add_argument(
220
+ "--redirect",
221
+ action="append",
222
+ dest="redirects",
223
+ type=_parse_redirect,
224
+ default=None,
225
+ help="Explicit callback override as dev=URL, prod=URL, or URL (defaults to prod); can repeat",
226
+ )
227
+ configure.add_argument("--scope", action="append", dest="scopes", default=None, help="OAuth2 scope; can repeat")
228
+ configure.add_argument("--public-base-url", default=None, help="Public LICOS OAuth2 origin; /api/v1 is removed")
229
+ configure.add_argument("--out", default="config/licos-oauth.json", help="Config path under LICOS_PROJECT_PATH")
230
+ _set_handler(configure, _cmd_configure_project)
231
+
232
+ delete = commands.add_parser("delete", help="Delete an OAuth2 application")
233
+ delete.add_argument("app_id")
234
+ _set_handler(delete, _cmd_delete)
235
+
236
+ for name, enabled in (("enable", True), ("disable", False)):
237
+ command = commands.add_parser(name, help=f"{name.capitalize()} an OAuth2 application")
238
+ command.add_argument("app_id")
239
+ command.set_defaults(enabled=enabled)
240
+ _set_handler(command, _cmd_set_enabled)
241
+
242
+ rotate = commands.add_parser("rotate-secret", help="Explicitly rotate and print a new confidential-client secret")
243
+ rotate.add_argument("app_id")
244
+ _set_handler(rotate, _cmd_rotate_secret)
245
+
246
+ grants = commands.add_parser("grants", help="List grants for an OAuth2 application")
247
+ grants.add_argument("app_id")
248
+ _set_handler(grants, _cmd_grants)
249
+
250
+ revoke = commands.add_parser("revoke-grant", help="Revoke one OAuth2 grant")
251
+ revoke.add_argument("grant_id")
252
+ _set_handler(revoke, _cmd_revoke_grant)
@@ -14,7 +14,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "licos-platform-sdk
14
14
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
15
15
 
16
16
  from licos_platform_cli import main as cli_main
17
- from licos_platform_cli import database_commands, knowledge_commands, storage_commands
17
+ from licos_platform_cli import database_commands, knowledge_commands, oauth_commands, storage_commands
18
18
  from licos_platform_sdk import ApiError
19
19
 
20
20
 
@@ -46,7 +46,7 @@ class PlatformCliTests(unittest.TestCase):
46
46
  self.assertEqual(code, 0)
47
47
  self.assertEqual(stderr, "")
48
48
  payload = json.loads(stdout)
49
- self.assertEqual(payload["topics"], ["database", "knowledge", "storage"])
49
+ self.assertEqual(payload["topics"], ["database", "knowledge", "oauth", "storage"])
50
50
  self.assertIn("licos-platform help storage", payload["usage"])
51
51
 
52
52
  def test_structured_help_storage_command_describes_parameters(self) -> None:
@@ -148,6 +148,244 @@ class PlatformCliTests(unittest.TestCase):
148
148
  self.assertEqual(stdout, "")
149
149
  self.assertIn("platform failed", stderr)
150
150
 
151
+ def test_oauth_configure_project_requires_explicit_app_selection(self) -> None:
152
+ code, stdout, stderr = run_cli(
153
+ "oauth",
154
+ "configure-project",
155
+ "--name",
156
+ "Project login",
157
+ "--login-audience",
158
+ "ALL",
159
+ "--redirect",
160
+ "prod=https://app.example/callback",
161
+ "--public-base-url",
162
+ "https://platform.example",
163
+ )
164
+
165
+ self.assertEqual(code, 2)
166
+ self.assertEqual(stdout, "")
167
+ self.assertIn("--auto-apply", stderr)
168
+ self.assertIn("--app-id", stderr)
169
+
170
+ def test_oauth_configure_project_requires_explicit_audience(self) -> None:
171
+ code, stdout, stderr = run_cli(
172
+ "oauth",
173
+ "configure-project",
174
+ "--auto-apply",
175
+ "--name",
176
+ "Project login",
177
+ "--redirect",
178
+ "prod=https://app.example/callback",
179
+ "--public-base-url",
180
+ "https://platform.example",
181
+ )
182
+
183
+ self.assertEqual(code, 2)
184
+ self.assertEqual(stdout, "")
185
+ self.assertIn("--login-audience", stderr)
186
+
187
+ def test_oauth_list_never_prints_secret_fields(self) -> None:
188
+ result = {
189
+ "list": [
190
+ {
191
+ "id": "app-1",
192
+ "secret": "secret-1",
193
+ "clientSecret": "secret-2",
194
+ "client_secret": "secret-3",
195
+ }
196
+ ]
197
+ }
198
+ with mock.patch.object(oauth_commands.oauth, "list_apps", return_value=result):
199
+ code, stdout, stderr = run_cli("oauth", "apps")
200
+
201
+ self.assertEqual(code, 0)
202
+ self.assertEqual(stderr, "")
203
+ self.assertEqual(json.loads(stdout), {"list": [{"id": "app-1"}]})
204
+
205
+ def test_oauth_configure_existing_app_writes_pkce_config_without_secret(self) -> None:
206
+ app = {
207
+ "id": "app-1",
208
+ "appCode": "project-project-1",
209
+ "clientId": "client-1",
210
+ "secret": "must-not-be-written",
211
+ }
212
+ with tempfile.TemporaryDirectory() as tmp:
213
+ with mock.patch.dict(os.environ, {"LICOS_PROJECT_PATH": tmp}, clear=False):
214
+ with mock.patch.object(oauth_commands.oauth, "ensure_project_app", return_value=app) as ensure:
215
+ code, stdout, stderr = run_cli(
216
+ "oauth",
217
+ "configure-project",
218
+ "--app-id",
219
+ "app-1",
220
+ "--name",
221
+ "Project login",
222
+ "--login-audience",
223
+ "OWNER_TENANT",
224
+ "--redirect",
225
+ "dev=http://localhost:3000/auth/callback",
226
+ "--redirect",
227
+ "prod=https://app.example/auth/callback",
228
+ "--public-base-url",
229
+ "https://platform.example/api/v1",
230
+ )
231
+
232
+ self.assertEqual(code, 0)
233
+ self.assertEqual(stderr, "")
234
+ payload = json.loads(stdout)
235
+ config_path = Path(payload["configPath"])
236
+ config = json.loads(config_path.read_text(encoding="utf-8"))
237
+ self.assertEqual(config["clientId"], "client-1")
238
+ self.assertEqual(config["loginAudience"], "OWNER_TENANT")
239
+ self.assertEqual(config["publicBaseUrl"], "https://platform.example")
240
+ self.assertTrue(config["pkce"])
241
+ self.assertNotIn("secret", config)
242
+ ensure.assert_called_once_with(
243
+ app_id="app-1",
244
+ name="Project login",
245
+ description=None,
246
+ login_audience="OWNER_TENANT",
247
+ redirect_uri_configs=[
248
+ {"environment": "dev", "redirectUri": "http://localhost:3000/auth/callback"},
249
+ {"environment": "prod", "redirectUri": "https://app.example/auth/callback"},
250
+ ],
251
+ allowed_scopes=["openid", "profile", "email"],
252
+ )
253
+
254
+ def test_oauth_configure_auto_apply_uses_environment_public_base(self) -> None:
255
+ app = {
256
+ "id": "app-2",
257
+ "appCode": "project-project-1",
258
+ "clientId": "client-2",
259
+ }
260
+ with tempfile.TemporaryDirectory() as tmp:
261
+ with mock.patch.dict(
262
+ os.environ,
263
+ {"LICOS_PROJECT_PATH": tmp, "LICOS_OAUTH_PUBLIC_BASE_URL": "https://platform.example/api/v1"},
264
+ clear=False,
265
+ ):
266
+ with mock.patch.object(oauth_commands.oauth, "ensure_project_app", return_value=app) as ensure:
267
+ code, stdout, stderr = run_cli(
268
+ "oauth",
269
+ "configure-project",
270
+ "--auto-apply",
271
+ "--name",
272
+ "Project login",
273
+ "--login-audience",
274
+ "ALL",
275
+ "--redirect",
276
+ "https://app.example/auth/callback",
277
+ )
278
+
279
+ self.assertEqual(code, 0)
280
+ self.assertEqual(stderr, "")
281
+ config = json.loads((Path(tmp) / "config" / "licos-oauth.json").read_text(encoding="utf-8"))
282
+ self.assertEqual(config["publicBaseUrl"], "https://platform.example")
283
+ self.assertEqual(config["redirectUriConfigs"][0]["environment"], "prod")
284
+ self.assertIsNone(ensure.call_args.kwargs["app_id"])
285
+ self.assertEqual(json.loads(stdout)["selectionMode"], "auto-apply")
286
+
287
+ def test_oauth_configure_auto_generates_stable_project_callbacks(self) -> None:
288
+ app = {
289
+ "id": "app-3",
290
+ "appCode": "project-project-1",
291
+ "clientId": "client-3",
292
+ }
293
+ with tempfile.TemporaryDirectory() as tmp:
294
+ env = {
295
+ "LICOS_PROJECT_PATH": tmp,
296
+ "AGENT_PROJECT_ID": "project/1",
297
+ "LICOS_PLATFORM_PUBLIC_BASE_URL": "https://platform.example",
298
+ "LICOS_DEPLOY_PUBLIC_BASE_URL": "https://deploy.example",
299
+ }
300
+ with mock.patch.dict(os.environ, env, clear=False):
301
+ with mock.patch.object(oauth_commands.oauth, "ensure_project_app", return_value=app) as ensure:
302
+ code, stdout, stderr = run_cli(
303
+ "oauth",
304
+ "configure-project",
305
+ "--auto-apply",
306
+ "--name",
307
+ "Project login",
308
+ "--login-audience",
309
+ "ALL",
310
+ )
311
+
312
+ self.assertEqual(code, 0)
313
+ self.assertEqual(stderr, "")
314
+ redirects = ensure.call_args.kwargs["redirect_uri_configs"]
315
+ self.assertEqual(
316
+ redirects,
317
+ [
318
+ {
319
+ "environment": "dev",
320
+ "redirectUri": (
321
+ "https://platform.example/agent/api/v1/projects/"
322
+ "project%2F1/oauth/callback"
323
+ ),
324
+ },
325
+ {
326
+ "environment": "prod",
327
+ "redirectUri": (
328
+ "https://deploy.example/p/project%2F1/api/auth/licos/callback"
329
+ ),
330
+ },
331
+ ],
332
+ )
333
+ config = json.loads((Path(tmp) / "config" / "licos-oauth.json").read_text(encoding="utf-8"))
334
+ self.assertEqual(config["redirectUriConfigs"], redirects)
335
+ self.assertEqual(config["publicBaseUrl"], "https://platform.example")
336
+ self.assertEqual(json.loads(stdout)["selectionMode"], "auto-apply")
337
+
338
+ def test_oauth_configure_without_redirect_requires_stable_project_metadata(self) -> None:
339
+ with tempfile.TemporaryDirectory() as tmp:
340
+ env = {
341
+ "LICOS_PROJECT_PATH": tmp,
342
+ "LICOS_PLATFORM_PUBLIC_BASE_URL": "https://platform.example",
343
+ "LICOS_DEPLOY_PUBLIC_BASE_URL": "https://deploy.example",
344
+ "AGENT_PROJECT_ID": "",
345
+ }
346
+ with mock.patch.dict(os.environ, env, clear=False):
347
+ with mock.patch.object(oauth_commands.oauth, "ensure_project_app") as ensure:
348
+ code, stdout, stderr = run_cli(
349
+ "oauth",
350
+ "configure-project",
351
+ "--auto-apply",
352
+ "--name",
353
+ "Project login",
354
+ "--login-audience",
355
+ "ALL",
356
+ )
357
+
358
+ self.assertEqual(code, 2)
359
+ self.assertEqual(stdout, "")
360
+ self.assertIn("AGENT_PROJECT_ID", stderr)
361
+ ensure.assert_not_called()
362
+
363
+ def test_oauth_invalid_output_path_does_not_mutate_platform(self) -> None:
364
+ with tempfile.TemporaryDirectory() as tmp:
365
+ outside = str(Path(tmp).parent / "outside.json")
366
+ with mock.patch.dict(os.environ, {"LICOS_PROJECT_PATH": tmp}, clear=False):
367
+ with mock.patch.object(oauth_commands.oauth, "ensure_project_app") as ensure:
368
+ code, stdout, stderr = run_cli(
369
+ "oauth",
370
+ "configure-project",
371
+ "--auto-apply",
372
+ "--name",
373
+ "Project login",
374
+ "--login-audience",
375
+ "ALL",
376
+ "--redirect",
377
+ "prod=https://app.example/auth/callback",
378
+ "--public-base-url",
379
+ "https://platform.example",
380
+ "--out",
381
+ outside,
382
+ )
383
+
384
+ self.assertEqual(code, 2)
385
+ self.assertEqual(stdout, "")
386
+ self.assertIn("must stay inside", stderr)
387
+ ensure.assert_not_called()
388
+
151
389
  def test_database_query_forwards_runtime_options(self) -> None:
152
390
  with mock.patch.object(database_commands.database, "query", return_value={"rows": []}) as query:
153
391
  code, stdout, stderr = run_cli(