@oneciel-ai/ciel-runtime 0.1.1 → 0.2.0

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 (287) hide show
  1. package/README.md +132 -0
  2. package/ciel-runtime-menu.py +56 -6
  3. package/ciel_runtime.py +9379 -35909
  4. package/ciel_runtime_support/advisor_client.py +193 -0
  5. package/ciel_runtime_support/advisor_policy.py +320 -0
  6. package/ciel_runtime_support/advisor_refinement.py +160 -0
  7. package/ciel_runtime_support/advisor_request_builder.py +261 -0
  8. package/ciel_runtime_support/agy_installer.py +169 -0
  9. package/ciel_runtime_support/agy_mcp_restore.py +182 -0
  10. package/ciel_runtime_support/anthropic_model_policy.py +186 -0
  11. package/ciel_runtime_support/anthropic_response_writer.py +255 -0
  12. package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
  13. package/ciel_runtime_support/api_key_cooldown.py +159 -0
  14. package/ciel_runtime_support/architecture.py +488 -1
  15. package/ciel_runtime_support/architecture_budget.py +42 -0
  16. package/ciel_runtime_support/channel_backlog.py +90 -0
  17. package/ciel_runtime_support/channel_cli.py +119 -0
  18. package/ciel_runtime_support/channel_compact_injection.py +82 -0
  19. package/ciel_runtime_support/channel_compact_poll.py +67 -0
  20. package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
  21. package/ciel_runtime_support/channel_config_service.py +281 -0
  22. package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
  23. package/ciel_runtime_support/channel_connection_registry.py +128 -0
  24. package/ciel_runtime_support/channel_connection_worker.py +284 -0
  25. package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
  26. package/ciel_runtime_support/channel_cursor_repository.py +89 -0
  27. package/ciel_runtime_support/channel_cursor_service.py +178 -0
  28. package/ciel_runtime_support/channel_event_identity.py +212 -0
  29. package/ciel_runtime_support/channel_event_projection.py +315 -0
  30. package/ciel_runtime_support/channel_inflight.py +127 -0
  31. package/ciel_runtime_support/channel_injection.py +115 -0
  32. package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
  33. package/ciel_runtime_support/channel_launch_policy.py +180 -0
  34. package/ciel_runtime_support/channel_llm_context.py +156 -0
  35. package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
  36. package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
  37. package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
  38. package/ciel_runtime_support/channel_mcp_tools.py +240 -0
  39. package/ciel_runtime_support/channel_mcp_transport.py +394 -0
  40. package/ciel_runtime_support/channel_message_dedupe.py +65 -0
  41. package/ciel_runtime_support/channel_message_policy.py +256 -0
  42. package/ciel_runtime_support/channel_message_prompt.py +305 -0
  43. package/ciel_runtime_support/channel_message_repository.py +234 -0
  44. package/ciel_runtime_support/channel_notification_projection.py +217 -0
  45. package/ciel_runtime_support/channel_panel.py +162 -0
  46. package/ciel_runtime_support/channel_pending_injection.py +209 -0
  47. package/ciel_runtime_support/channel_pending_poll.py +109 -0
  48. package/ciel_runtime_support/channel_probe_cache.py +433 -0
  49. package/ciel_runtime_support/channel_probe_report.py +101 -0
  50. package/ciel_runtime_support/channel_runtime_environment.py +181 -0
  51. package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
  52. package/ciel_runtime_support/channel_session_repository.py +90 -0
  53. package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
  54. package/ciel_runtime_support/channel_terminal_input.py +277 -0
  55. package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
  56. package/ciel_runtime_support/channel_tool_context.py +166 -0
  57. package/ciel_runtime_support/channel_transcript.py +414 -0
  58. package/ciel_runtime_support/channel_transcript_repository.py +96 -0
  59. package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
  60. package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
  61. package/ciel_runtime_support/chat_files.py +138 -0
  62. package/ciel_runtime_support/chat_http_controller.py +235 -0
  63. package/ciel_runtime_support/claude_environment.py +375 -0
  64. package/ciel_runtime_support/claude_router.py +247 -193
  65. package/ciel_runtime_support/cli_dispatch.py +792 -0
  66. package/ciel_runtime_support/cli_parser.py +165 -0
  67. package/ciel_runtime_support/cli_usage.py +100 -0
  68. package/ciel_runtime_support/codex_app_server.py +20 -5
  69. package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
  70. package/ciel_runtime_support/codex_cli.py +42 -6
  71. package/ciel_runtime_support/codex_config.py +323 -0
  72. package/ciel_runtime_support/codex_launch_configuration.py +240 -0
  73. package/ciel_runtime_support/codex_launch_policy.py +66 -0
  74. package/ciel_runtime_support/codex_mcp_integration.py +195 -0
  75. package/ciel_runtime_support/codex_mcp_restore.py +304 -0
  76. package/ciel_runtime_support/codex_model_catalog.py +133 -0
  77. package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
  78. package/ciel_runtime_support/codex_router.py +147 -1
  79. package/ciel_runtime_support/codex_session_repository.py +115 -0
  80. package/ciel_runtime_support/codex_session_selection.py +114 -0
  81. package/ciel_runtime_support/command_asset_installer.py +103 -0
  82. package/ciel_runtime_support/compatibility_probe.py +295 -0
  83. package/ciel_runtime_support/compatibility_protocol.py +251 -0
  84. package/ciel_runtime_support/compatibility_runtime.py +166 -0
  85. package/ciel_runtime_support/compatibility_test.py +370 -0
  86. package/ciel_runtime_support/config_migrations.py +307 -0
  87. package/ciel_runtime_support/config_repository.py +175 -0
  88. package/ciel_runtime_support/config_value_codec.py +64 -0
  89. package/ciel_runtime_support/configuration_cli.py +374 -0
  90. package/ciel_runtime_support/context_compaction.py +280 -0
  91. package/ciel_runtime_support/context_setup.py +208 -0
  92. package/ciel_runtime_support/context_summary_policy.py +392 -0
  93. package/ciel_runtime_support/credential_cli.py +104 -0
  94. package/ciel_runtime_support/credential_management.py +261 -0
  95. package/ciel_runtime_support/credentials.py +269 -0
  96. package/ciel_runtime_support/executable_discovery.py +141 -0
  97. package/ciel_runtime_support/github_copilot_oauth.py +335 -0
  98. package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
  99. package/ciel_runtime_support/header_forwarding.py +73 -0
  100. package/ciel_runtime_support/headless_config.py +221 -0
  101. package/ciel_runtime_support/http_response.py +129 -0
  102. package/ciel_runtime_support/install_diagnostics.py +149 -0
  103. package/ciel_runtime_support/kimi_identity.py +123 -0
  104. package/ciel_runtime_support/launch_diagnostics.py +204 -0
  105. package/ciel_runtime_support/launch_state.py +127 -0
  106. package/ciel_runtime_support/live_api_key_controller.py +58 -0
  107. package/ciel_runtime_support/llm_config_http.py +148 -0
  108. package/ciel_runtime_support/llm_option_config.py +259 -0
  109. package/ciel_runtime_support/llm_presentation_data.py +447 -0
  110. package/ciel_runtime_support/llm_presets.py +773 -0
  111. package/ciel_runtime_support/lm_studio_runtime.py +401 -0
  112. package/ciel_runtime_support/managed_mcp_config.py +144 -0
  113. package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
  114. package/ciel_runtime_support/managed_service_cleanup.py +89 -0
  115. package/ciel_runtime_support/mcp_config_reader.py +230 -0
  116. package/ciel_runtime_support/mcp_http_proxy.py +607 -0
  117. package/ciel_runtime_support/mcp_inventory.py +59 -0
  118. package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
  119. package/ciel_runtime_support/mcp_probe_codec.py +136 -0
  120. package/ciel_runtime_support/mcp_probe_transport.py +328 -0
  121. package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
  122. package/ciel_runtime_support/mcp_proxy_config.py +107 -0
  123. package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
  124. package/ciel_runtime_support/mcp_proxy_process.py +560 -0
  125. package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
  126. package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
  127. package/ciel_runtime_support/mcp_transport.py +146 -0
  128. package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
  129. package/ciel_runtime_support/model_catalog_projection.py +61 -0
  130. package/ciel_runtime_support/model_context_hints.py +109 -0
  131. package/ciel_runtime_support/model_panel.py +147 -0
  132. package/ciel_runtime_support/model_registry_repository.py +231 -0
  133. package/ciel_runtime_support/npm_runtime.py +191 -0
  134. package/ciel_runtime_support/ollama_catalog.py +462 -0
  135. package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
  136. package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
  137. package/ciel_runtime_support/ollama_context_sync.py +87 -0
  138. package/ciel_runtime_support/ollama_forwarding.py +449 -0
  139. package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
  140. package/ciel_runtime_support/openai_chat_router.py +64 -0
  141. package/ciel_runtime_support/openai_forwarding.py +194 -0
  142. package/ciel_runtime_support/openai_responses_router.py +291 -0
  143. package/ciel_runtime_support/openai_responses_stream.py +135 -0
  144. package/ciel_runtime_support/output_budget.py +89 -0
  145. package/ciel_runtime_support/package_lifecycle.py +217 -0
  146. package/ciel_runtime_support/plan_artifact_controller.py +104 -0
  147. package/ciel_runtime_support/prelaunch.py +959 -0
  148. package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
  149. package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
  150. package/ciel_runtime_support/prelaunch_terminal.py +764 -0
  151. package/ciel_runtime_support/process_control.py +708 -0
  152. package/ciel_runtime_support/prompt_compaction.py +322 -0
  153. package/ciel_runtime_support/prompt_injection.py +176 -0
  154. package/ciel_runtime_support/protocols/__init__.py +24 -0
  155. package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
  156. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
  157. package/ciel_runtime_support/protocols/chat_projection.py +315 -0
  158. package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
  159. package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
  160. package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
  161. package/ciel_runtime_support/protocols/ollama_response.py +231 -0
  162. package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
  163. package/ciel_runtime_support/protocols/openai_responses.py +271 -0
  164. package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
  165. package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
  166. package/ciel_runtime_support/provider_adapters.py +160 -0
  167. package/ciel_runtime_support/provider_catalog_sources.py +316 -0
  168. package/ciel_runtime_support/provider_choice.py +201 -0
  169. package/ciel_runtime_support/provider_compatibility.py +165 -0
  170. package/ciel_runtime_support/provider_config_mutations.py +361 -0
  171. package/ciel_runtime_support/provider_configuration_service.py +162 -0
  172. package/ciel_runtime_support/provider_context.py +308 -0
  173. package/ciel_runtime_support/provider_contract_projection.py +73 -0
  174. package/ciel_runtime_support/provider_descriptor.py +82 -0
  175. package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
  176. package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
  177. package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
  178. package/ciel_runtime_support/provider_limits.py +457 -0
  179. package/ciel_runtime_support/provider_model_identity.py +139 -0
  180. package/ciel_runtime_support/provider_model_selection.py +431 -0
  181. package/ciel_runtime_support/provider_model_specs.py +142 -0
  182. package/ciel_runtime_support/provider_models.py +263 -0
  183. package/ciel_runtime_support/provider_network.py +176 -0
  184. package/ciel_runtime_support/provider_option_cli.py +238 -0
  185. package/ciel_runtime_support/provider_option_panel.py +275 -0
  186. package/ciel_runtime_support/provider_option_status.py +192 -0
  187. package/ciel_runtime_support/provider_policy.py +101 -0
  188. package/ciel_runtime_support/provider_query_policy.py +67 -0
  189. package/ciel_runtime_support/provider_readiness.py +112 -0
  190. package/ciel_runtime_support/provider_request_access.py +131 -0
  191. package/ciel_runtime_support/provider_request_builder.py +250 -0
  192. package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
  193. package/ciel_runtime_support/provider_runtime_info.py +113 -0
  194. package/ciel_runtime_support/provider_runtime_modes.py +150 -0
  195. package/ciel_runtime_support/provider_sampling_policy.py +46 -0
  196. package/ciel_runtime_support/provider_status.py +145 -0
  197. package/ciel_runtime_support/provider_timeout_policy.py +184 -0
  198. package/ciel_runtime_support/provider_tool_policy.py +145 -0
  199. package/ciel_runtime_support/providers/__init__.py +65 -0
  200. package/ciel_runtime_support/providers/anthropic.py +160 -0
  201. package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
  202. package/ciel_runtime_support/providers/base.py +232 -0
  203. package/ciel_runtime_support/providers/catalog.py +326 -0
  204. package/ciel_runtime_support/providers/cloud.py +194 -0
  205. package/ciel_runtime_support/providers/constants.py +54 -0
  206. package/ciel_runtime_support/providers/deepseek.py +115 -0
  207. package/ciel_runtime_support/providers/fireworks.py +158 -0
  208. package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
  209. package/ciel_runtime_support/providers/kimi.py +297 -0
  210. package/ciel_runtime_support/providers/lm_studio.py +76 -0
  211. package/ciel_runtime_support/providers/meta.py +257 -0
  212. package/ciel_runtime_support/providers/native.py +194 -0
  213. package/ciel_runtime_support/providers/nim.py +69 -0
  214. package/ciel_runtime_support/providers/nvidia.py +158 -0
  215. package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
  216. package/ciel_runtime_support/providers/ollama.py +181 -0
  217. package/ciel_runtime_support/providers/ollama_context.py +195 -0
  218. package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
  219. package/ciel_runtime_support/providers/opencode.py +220 -0
  220. package/ciel_runtime_support/providers/opencode_go.py +37 -0
  221. package/ciel_runtime_support/providers/openrouter.py +57 -0
  222. package/ciel_runtime_support/providers/vllm.py +65 -0
  223. package/ciel_runtime_support/providers/zai.py +119 -0
  224. package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
  225. package/ciel_runtime_support/rate_limit_policy.py +117 -0
  226. package/ciel_runtime_support/rate_limit_repository.py +154 -0
  227. package/ciel_runtime_support/registry.py +46 -0
  228. package/ciel_runtime_support/request_shortcuts.py +253 -0
  229. package/ciel_runtime_support/request_trace.py +323 -0
  230. package/ciel_runtime_support/response_collection.py +209 -0
  231. package/ciel_runtime_support/router_access.py +238 -0
  232. package/ciel_runtime_support/router_client_lifecycle.py +366 -0
  233. package/ciel_runtime_support/router_health_policy.py +101 -0
  234. package/ciel_runtime_support/router_http.py +513 -0
  235. package/ciel_runtime_support/router_process_lifecycle.py +401 -0
  236. package/ciel_runtime_support/router_rate_limit_service.py +285 -0
  237. package/ciel_runtime_support/router_server_runtime.py +103 -0
  238. package/ciel_runtime_support/router_shortcuts.py +201 -0
  239. package/ciel_runtime_support/routing_fallback.py +73 -0
  240. package/ciel_runtime_support/runtime_activity_repository.py +143 -0
  241. package/ciel_runtime_support/runtime_adapters.py +104 -0
  242. package/ciel_runtime_support/runtime_command_factory.py +73 -0
  243. package/ciel_runtime_support/runtime_compatibility.py +50 -0
  244. package/ciel_runtime_support/runtime_constants.py +178 -0
  245. package/ciel_runtime_support/runtime_launch.py +1602 -0
  246. package/ciel_runtime_support/runtime_llm_options.py +312 -0
  247. package/ciel_runtime_support/runtime_logging.py +161 -0
  248. package/ciel_runtime_support/runtime_paths.py +157 -0
  249. package/ciel_runtime_support/runtime_restart.py +84 -0
  250. package/ciel_runtime_support/runtime_upgrade.py +149 -0
  251. package/ciel_runtime_support/secure_json_repository.py +55 -0
  252. package/ciel_runtime_support/session_import.py +356 -0
  253. package/ciel_runtime_support/settings_repository.py +8 -0
  254. package/ciel_runtime_support/slash_command_assets.py +211 -0
  255. package/ciel_runtime_support/sse_stream.py +57 -0
  256. package/ciel_runtime_support/sse_trace.py +225 -0
  257. package/ciel_runtime_support/statusline_script.py +593 -0
  258. package/ciel_runtime_support/statusline_settings.py +53 -0
  259. package/ciel_runtime_support/stream_chunk_policy.py +18 -0
  260. package/ciel_runtime_support/streaming_anthropic.py +1955 -0
  261. package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
  262. package/ciel_runtime_support/terminal_platform_io.py +127 -0
  263. package/ciel_runtime_support/timeout_profile.py +196 -0
  264. package/ciel_runtime_support/tool_dialects.py +85 -0
  265. package/ciel_runtime_support/tool_exposure_policy.py +63 -0
  266. package/ciel_runtime_support/tool_guard_hooks.py +218 -0
  267. package/ciel_runtime_support/tool_request_projection.py +96 -0
  268. package/ciel_runtime_support/tool_schema.py +483 -0
  269. package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
  270. package/ciel_runtime_support/ui_text.py +266 -0
  271. package/ciel_runtime_support/upstream_error_policy.py +104 -0
  272. package/ciel_runtime_support/upstream_retry.py +419 -0
  273. package/ciel_runtime_support/upstream_stream_io.py +106 -0
  274. package/ciel_runtime_support/usage_events.py +96 -0
  275. package/ciel_runtime_support/visible_stream_filters.py +130 -0
  276. package/ciel_runtime_support/web_endpoints.py +447 -0
  277. package/ciel_runtime_support/web_ui.py +915 -0
  278. package/ciel_runtime_support/web_ui_controller.py +189 -0
  279. package/ciel_runtime_support/windows_console_input.py +137 -0
  280. package/ciel_runtime_support/windows_console_mode.py +112 -0
  281. package/docs/Architecture.md +54 -0
  282. package/docs/Configuration.md +17 -0
  283. package/docs/Module-Map.md +1093 -26
  284. package/docs/Providers.md +46 -1
  285. package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
  286. package/npm-bin/run-ciel-runtime.js +22 -2
  287. package/package.json +9 -2
@@ -0,0 +1,141 @@
1
+ """Cross-platform executable and runtime helper discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import os
7
+ import shlex
8
+ import shutil
9
+ import site
10
+ import sys
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Callable
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class ExecutableDiscovery:
18
+ home: Path
19
+ source_file: Path
20
+ platform_path: Callable[[str], Path]
21
+ ciel_user_bin: Callable[[], Path]
22
+ agy_user_bin: Callable[[], Path]
23
+
24
+ @staticmethod
25
+ def candidates(name: str) -> list[str]:
26
+ if os.name == "nt" and not Path(name).suffix:
27
+ return [f"{name}.exe", f"{name}.cmd", f"{name}.bat", name]
28
+ return [name]
29
+
30
+ def extra_dirs(self) -> list[Path]:
31
+ directories = [self.ciel_user_bin(), self.agy_user_bin()]
32
+ for variable in ("UV_INSTALL_DIR", "CARGO_HOME"):
33
+ if root := os.environ.get(variable):
34
+ path = Path(root)
35
+ directories.append(path if path.name == "bin" else path / "bin")
36
+ if os.name == "nt":
37
+ if appdata := os.environ.get("APPDATA"):
38
+ directories.append(self.platform_path(appdata) / "npm")
39
+ if local_appdata := os.environ.get("LOCALAPPDATA"):
40
+ directories.append(self.platform_path(local_appdata) / "Programs" / "nodejs")
41
+ python_version = f"Python{sys.version_info.major}{sys.version_info.minor}"
42
+ for variable in ("APPDATA", "LOCALAPPDATA"):
43
+ if root := os.environ.get(variable):
44
+ directories.append(self.platform_path(root) / "Python" / python_version / "Scripts")
45
+ try:
46
+ directories.append(self.platform_path(site.getuserbase()) / "Scripts")
47
+ except Exception:
48
+ pass
49
+ directories.append(self.platform_path(sys.executable).parent / "Scripts")
50
+ else:
51
+ directories.extend(
52
+ (
53
+ self.home / ".local" / "bin",
54
+ self.home / ".cargo" / "bin",
55
+ self.home / ".npm-global" / "bin",
56
+ self.home / ".bun" / "bin",
57
+ Path(sys.executable).resolve().parent,
58
+ Path("/usr/local/bin"),
59
+ Path("/usr/bin"),
60
+ Path("/bin"),
61
+ Path("/opt/homebrew/bin"),
62
+ )
63
+ )
64
+ result: list[Path] = []
65
+ seen: set[str] = set()
66
+ for directory in directories:
67
+ key = str(directory)
68
+ if key and key not in seen:
69
+ seen.add(key)
70
+ result.append(directory)
71
+ return result
72
+
73
+ def find(self, name: str) -> str | None:
74
+ for candidate in self.candidates(name):
75
+ if found := shutil.which(candidate):
76
+ return found
77
+ for directory in self.extra_dirs():
78
+ for candidate in self.candidates(name):
79
+ path = directory / candidate
80
+ if path.exists():
81
+ return str(path)
82
+ return None
83
+
84
+ def resolve(self, command: str) -> str:
85
+ command = str(command or "").strip()
86
+ if not command:
87
+ return command
88
+ pathish = Path(command).is_absolute() or os.sep in command or bool(
89
+ os.altsep and os.altsep in command
90
+ )
91
+ return command if pathish else self.find(command) or command
92
+
93
+ def resolve_mcp_process(
94
+ self,
95
+ command: str,
96
+ arguments: list[str],
97
+ finder: Callable[[str], str | None] | None = None,
98
+ ) -> tuple[str, list[str]]:
99
+ command = str(command or "").strip()
100
+ find = finder or self.find
101
+ pathish = Path(command).is_absolute() or os.sep in command or bool(
102
+ os.altsep and os.altsep in command
103
+ )
104
+ resolved = command if pathish else find(command) or command
105
+ if resolved == command and Path(command).name.lower() in ("uvx", "uvx.exe", "uvx.cmd", "uvx.bat"):
106
+ if uv := find("uv"):
107
+ return uv, ["tool", "run", *arguments]
108
+ if importlib.util.find_spec("uv") is not None:
109
+ return sys.executable, ["-m", "uv", "tool", "run", *arguments]
110
+ return resolved, arguments
111
+
112
+ @staticmethod
113
+ def shell_command(arguments: list[str]) -> str:
114
+ if os.name == "nt":
115
+ normalized: list[str] = []
116
+ for argument in arguments:
117
+ path_like = "\\" in argument and (
118
+ (len(argument) >= 2 and argument[1] == ":")
119
+ or argument.startswith("\\\\")
120
+ or argument.endswith((".py", ".exe", ".cmd", ".bat", ".ps1"))
121
+ )
122
+ normalized.append(shlex.quote(argument.replace("\\", "/") if path_like else argument))
123
+ return " ".join(normalized)
124
+ return " ".join(shlex.quote(argument) for argument in arguments)
125
+
126
+ def find_tool_guard(
127
+ self,
128
+ finder: Callable[[str], str | None] | None = None,
129
+ ) -> Path | None:
130
+ candidates = [
131
+ self.source_file.resolve().with_name("ciel-runtime-tool-guard.py"),
132
+ self.ciel_user_bin() / "ciel-runtime-tool-guard.py",
133
+ self.ciel_user_bin() / "ciel-runtime-tool-guard",
134
+ self.home / ".local" / "bin" / "ciel-runtime-tool-guard.py",
135
+ self.home / ".local" / "bin" / "ciel-runtime-tool-guard",
136
+ ]
137
+ find = finder or self.find
138
+ for name in ("ciel-runtime-tool-guard", "ciel-runtime-tool-guard.py"):
139
+ if found := find(name):
140
+ candidates.append(Path(found))
141
+ return next((path for path in candidates if path.exists()), None)
@@ -0,0 +1,335 @@
1
+ """GitHub Device OAuth and short-lived Copilot token management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Mapping
14
+
15
+
16
+ DEFAULT_GITHUB_COPILOT_CLIENT_ID = "Iv1.b507a08c87ecfe98"
17
+ GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"
18
+ GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
19
+ GITHUB_USER_URL = "https://api.github.com/user"
20
+ GITHUB_COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"
21
+ GITHUB_API_VERSION = "2022-11-28"
22
+ COPILOT_API_VERSION = "2025-04-01"
23
+ COPILOT_VSCODE_VERSION = "1.110.0"
24
+ COPILOT_CHAT_VERSION = "0.38.0"
25
+ COPILOT_USER_AGENT = f"GitHubCopilotChat/{COPILOT_CHAT_VERSION}"
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class GitHubCopilotOAuthCredentials:
30
+ github_access_token: str = ""
31
+ copilot_token: str = ""
32
+ copilot_token_expires_at: int = 0
33
+ github_login: str = ""
34
+
35
+ @classmethod
36
+ def from_mapping(
37
+ cls, value: Mapping[str, Any] | None
38
+ ) -> GitHubCopilotOAuthCredentials:
39
+ value = value or {}
40
+ try:
41
+ expires_at = int(value.get("copilot_token_expires_at") or 0)
42
+ except (TypeError, ValueError):
43
+ expires_at = 0
44
+ return cls(
45
+ github_access_token=str(value.get("github_access_token") or ""),
46
+ copilot_token=str(value.get("copilot_token") or ""),
47
+ copilot_token_expires_at=expires_at,
48
+ github_login=str(value.get("github_login") or ""),
49
+ )
50
+
51
+ def as_mapping(self) -> dict[str, Any]:
52
+ return {
53
+ "github_access_token": self.github_access_token,
54
+ "copilot_token": self.copilot_token,
55
+ "copilot_token_expires_at": self.copilot_token_expires_at,
56
+ "github_login": self.github_login,
57
+ }
58
+
59
+ def copilot_token_is_fresh(
60
+ self, now: float, refresh_lead_seconds: int = 300
61
+ ) -> bool:
62
+ return bool(
63
+ self.copilot_token
64
+ and self.copilot_token_expires_at > int(now) + refresh_lead_seconds
65
+ )
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class GitHubCopilotOAuthRepository:
70
+ path: Path
71
+
72
+ def load(self) -> GitHubCopilotOAuthCredentials:
73
+ try:
74
+ value = json.loads(self.path.read_text(encoding="utf-8"))
75
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
76
+ return GitHubCopilotOAuthCredentials()
77
+ return GitHubCopilotOAuthCredentials.from_mapping(
78
+ value if isinstance(value, dict) else {}
79
+ )
80
+
81
+ def save(self, credentials: GitHubCopilotOAuthCredentials) -> None:
82
+ self.path.parent.mkdir(parents=True, exist_ok=True)
83
+ temporary = self.path.with_suffix(self.path.suffix + ".tmp")
84
+ temporary.write_text(
85
+ json.dumps(credentials.as_mapping(), ensure_ascii=False),
86
+ encoding="utf-8",
87
+ )
88
+ os.chmod(temporary, 0o600)
89
+ temporary.replace(self.path)
90
+ os.chmod(self.path, 0o600)
91
+
92
+ def clear(self) -> bool:
93
+ if not self.path.exists():
94
+ return False
95
+ self.path.unlink()
96
+ return True
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class GitHubCopilotOAuthHttp:
101
+ urlopen: Callable[..., Any] = urllib.request.urlopen
102
+ timeout_seconds: float = 30.0
103
+
104
+ def post_form(self, url: str, values: Mapping[str, Any]) -> dict[str, Any]:
105
+ request = urllib.request.Request(
106
+ url,
107
+ data=urllib.parse.urlencode(values).encode("utf-8"),
108
+ headers={
109
+ "Accept": "application/json",
110
+ "Content-Type": "application/x-www-form-urlencoded",
111
+ },
112
+ method="POST",
113
+ )
114
+ return self._json(request)
115
+
116
+ def get_json(
117
+ self, url: str, headers: Mapping[str, str]
118
+ ) -> dict[str, Any]:
119
+ return self._json(
120
+ urllib.request.Request(url, headers=dict(headers), method="GET")
121
+ )
122
+
123
+ def _json(self, request: urllib.request.Request) -> dict[str, Any]:
124
+ try:
125
+ with self.urlopen(request, timeout=self.timeout_seconds) as response:
126
+ raw = response.read().decode("utf-8", errors="replace")
127
+ except urllib.error.HTTPError as exc:
128
+ raw = exc.read().decode("utf-8", errors="replace")
129
+ raise RuntimeError(
130
+ f"GitHub OAuth HTTP {exc.code}: {raw[:500]}"
131
+ ) from exc
132
+ value = json.loads(raw)
133
+ if not isinstance(value, dict):
134
+ raise RuntimeError("GitHub OAuth returned a non-object response.")
135
+ return value
136
+
137
+
138
+ @dataclass(frozen=True, slots=True)
139
+ class GitHubCopilotOAuthClient:
140
+ http: GitHubCopilotOAuthHttp
141
+ client_id: str = DEFAULT_GITHUB_COPILOT_CLIENT_ID
142
+ sleep: Callable[[float], None] = time.sleep
143
+ now: Callable[[], float] = time.time
144
+
145
+ def request_device_code(self) -> dict[str, Any]:
146
+ response = self.http.post_form(
147
+ GITHUB_DEVICE_CODE_URL,
148
+ {"client_id": self.client_id, "scope": "read:user"},
149
+ )
150
+ self._raise_oauth_error(response)
151
+ for field in ("device_code", "user_code", "verification_uri"):
152
+ if not str(response.get(field) or "").strip():
153
+ raise RuntimeError(
154
+ f"GitHub device authorization omitted {field}."
155
+ )
156
+ return response
157
+
158
+ def poll_access_token(
159
+ self,
160
+ device: Mapping[str, Any],
161
+ on_pending: Callable[[int], None] | None = None,
162
+ ) -> str:
163
+ device_code = str(device.get("device_code") or "")
164
+ interval = max(1, int(device.get("interval") or 5))
165
+ expires_in = max(interval, int(device.get("expires_in") or 900))
166
+ deadline = self.now() + expires_in
167
+ attempt = 0
168
+ while self.now() < deadline:
169
+ self.sleep(interval)
170
+ attempt += 1
171
+ response = self.http.post_form(
172
+ GITHUB_ACCESS_TOKEN_URL,
173
+ {
174
+ "client_id": self.client_id,
175
+ "device_code": device_code,
176
+ "grant_type": (
177
+ "urn:ietf:params:oauth:grant-type:device_code"
178
+ ),
179
+ },
180
+ )
181
+ access_token = str(response.get("access_token") or "").strip()
182
+ if access_token:
183
+ return access_token
184
+ error = str(response.get("error") or "").strip()
185
+ if error == "authorization_pending":
186
+ if on_pending is not None:
187
+ on_pending(attempt)
188
+ continue
189
+ if error == "slow_down":
190
+ interval += 5
191
+ continue
192
+ self._raise_oauth_error(response)
193
+ raise RuntimeError("GitHub device authorization expired.")
194
+
195
+ def exchange_copilot_token(
196
+ self, github_access_token: str
197
+ ) -> tuple[str, int]:
198
+ response = self.http.get_json(
199
+ GITHUB_COPILOT_TOKEN_URL,
200
+ {
201
+ "Authorization": f"token {github_access_token}",
202
+ "Accept": "application/json",
203
+ "User-Agent": COPILOT_USER_AGENT,
204
+ "Editor-Version": f"vscode/{COPILOT_VSCODE_VERSION}",
205
+ "Editor-Plugin-Version": (
206
+ f"copilot-chat/{COPILOT_CHAT_VERSION}"
207
+ ),
208
+ "X-GitHub-Api-Version": COPILOT_API_VERSION,
209
+ },
210
+ )
211
+ token = str(response.get("token") or "").strip()
212
+ try:
213
+ expires_at = int(response.get("expires_at") or 0)
214
+ except (TypeError, ValueError):
215
+ expires_at = 0
216
+ if not token or not expires_at:
217
+ raise RuntimeError(
218
+ "GitHub did not return a usable Copilot token. "
219
+ "Confirm that this account has Copilot access."
220
+ )
221
+ return token, expires_at
222
+
223
+ def github_login(self, github_access_token: str) -> str:
224
+ response = self.http.get_json(
225
+ GITHUB_USER_URL,
226
+ {
227
+ "Authorization": f"Bearer {github_access_token}",
228
+ "Accept": "application/json",
229
+ "User-Agent": COPILOT_USER_AGENT,
230
+ "X-GitHub-Api-Version": GITHUB_API_VERSION,
231
+ },
232
+ )
233
+ return str(response.get("login") or "").strip()
234
+
235
+ @staticmethod
236
+ def _raise_oauth_error(response: Mapping[str, Any]) -> None:
237
+ error = str(response.get("error") or "").strip()
238
+ if not error:
239
+ return
240
+ detail = str(response.get("error_description") or error)
241
+ raise RuntimeError(f"GitHub OAuth failed: {detail}")
242
+
243
+
244
+ @dataclass(frozen=True, slots=True)
245
+ class GitHubCopilotOAuthService:
246
+ repository: GitHubCopilotOAuthRepository
247
+ client: GitHubCopilotOAuthClient
248
+ now: Callable[[], float] = time.time
249
+
250
+ def current_token(self) -> str:
251
+ credentials = self.repository.load()
252
+ if credentials.copilot_token_is_fresh(self.now()):
253
+ return credentials.copilot_token
254
+ if not credentials.github_access_token:
255
+ return ""
256
+ token, expires_at = self.client.exchange_copilot_token(
257
+ credentials.github_access_token
258
+ )
259
+ refreshed = GitHubCopilotOAuthCredentials(
260
+ github_access_token=credentials.github_access_token,
261
+ copilot_token=token,
262
+ copilot_token_expires_at=expires_at,
263
+ github_login=credentials.github_login,
264
+ )
265
+ self.repository.save(refreshed)
266
+ return token
267
+
268
+ def force_refresh(self) -> str:
269
+ credentials = self.repository.load()
270
+ if not credentials.github_access_token:
271
+ return ""
272
+ token, expires_at = self.client.exchange_copilot_token(
273
+ credentials.github_access_token
274
+ )
275
+ self.repository.save(
276
+ GitHubCopilotOAuthCredentials(
277
+ github_access_token=credentials.github_access_token,
278
+ copilot_token=token,
279
+ copilot_token_expires_at=expires_at,
280
+ github_login=credentials.github_login,
281
+ )
282
+ )
283
+ return token
284
+
285
+ def login(
286
+ self,
287
+ show_device: Callable[[str, str], None],
288
+ on_pending: Callable[[int], None] | None = None,
289
+ ) -> GitHubCopilotOAuthCredentials:
290
+ device = self.client.request_device_code()
291
+ show_device(
292
+ str(device["verification_uri"]),
293
+ str(device["user_code"]),
294
+ )
295
+ access_token = self.client.poll_access_token(device, on_pending)
296
+ copilot_token, expires_at = self.client.exchange_copilot_token(
297
+ access_token
298
+ )
299
+ login = self.client.github_login(access_token)
300
+ credentials = GitHubCopilotOAuthCredentials(
301
+ github_access_token=access_token,
302
+ copilot_token=copilot_token,
303
+ copilot_token_expires_at=expires_at,
304
+ github_login=login,
305
+ )
306
+ self.repository.save(credentials)
307
+ return credentials
308
+
309
+ def status(self) -> tuple[bool, str, int]:
310
+ credentials = self.repository.load()
311
+ return (
312
+ bool(credentials.github_access_token),
313
+ credentials.github_login,
314
+ credentials.copilot_token_expires_at,
315
+ )
316
+
317
+ def logout(self) -> bool:
318
+ return self.repository.clear()
319
+
320
+
321
+ __all__ = [
322
+ "COPILOT_API_VERSION",
323
+ "COPILOT_CHAT_VERSION",
324
+ "COPILOT_USER_AGENT",
325
+ "COPILOT_VSCODE_VERSION",
326
+ "DEFAULT_GITHUB_COPILOT_CLIENT_ID",
327
+ "GITHUB_ACCESS_TOKEN_URL",
328
+ "GITHUB_COPILOT_TOKEN_URL",
329
+ "GITHUB_DEVICE_CODE_URL",
330
+ "GitHubCopilotOAuthClient",
331
+ "GitHubCopilotOAuthCredentials",
332
+ "GitHubCopilotOAuthHttp",
333
+ "GitHubCopilotOAuthRepository",
334
+ "GitHubCopilotOAuthService",
335
+ ]
@@ -0,0 +1,213 @@
1
+ """Runtime application service for the GitHub Copilot OAuth provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+ import webbrowser
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any, Callable, Mapping
13
+
14
+ from .github_copilot_oauth import (
15
+ DEFAULT_GITHUB_COPILOT_CLIENT_ID,
16
+ GitHubCopilotOAuthClient,
17
+ GitHubCopilotOAuthHttp,
18
+ GitHubCopilotOAuthRepository,
19
+ GitHubCopilotOAuthService,
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class GitHubCopilotOAuthRuntimePorts:
25
+ clear_model_cache: Callable[[], None]
26
+ log: Callable[[str, str], Any]
27
+ provider_headers: Callable[[str, dict[str, Any]], dict[str, str]]
28
+ network_open: Callable[..., Any]
29
+
30
+
31
+ class GitHubCopilotOAuthRuntime:
32
+ provider = "github-copilot-oauth"
33
+
34
+ def __init__(
35
+ self,
36
+ config_dir: Path,
37
+ ports: GitHubCopilotOAuthRuntimePorts,
38
+ environ: Mapping[str, str] | None = None,
39
+ ) -> None:
40
+ self._path = config_dir / "github-copilot-oauth.json"
41
+ self._ports = ports
42
+ self._environ = os.environ if environ is None else environ
43
+
44
+ def service(self) -> GitHubCopilotOAuthService:
45
+ client_id = str(
46
+ self._environ.get("CIEL_RUNTIME_GITHUB_COPILOT_CLIENT_ID")
47
+ or DEFAULT_GITHUB_COPILOT_CLIENT_ID
48
+ ).strip()
49
+ return GitHubCopilotOAuthService(
50
+ repository=GitHubCopilotOAuthRepository(self._path),
51
+ client=GitHubCopilotOAuthClient(
52
+ http=GitHubCopilotOAuthHttp(),
53
+ client_id=client_id,
54
+ ),
55
+ )
56
+
57
+ def token(self) -> str:
58
+ try:
59
+ return self.service().current_token()
60
+ except Exception as exc:
61
+ self._ports.log(
62
+ "WARN",
63
+ "github_copilot_oauth_token_unavailable "
64
+ f"error={type(exc).__name__}: {exc}",
65
+ )
66
+ return ""
67
+
68
+ def force_refresh(self) -> str:
69
+ try:
70
+ return self.service().force_refresh()
71
+ except Exception as exc:
72
+ self._ports.log(
73
+ "WARN",
74
+ "github_copilot_oauth_force_refresh_failed "
75
+ f"error={type(exc).__name__}: {exc}",
76
+ )
77
+ return ""
78
+
79
+ def open(
80
+ self,
81
+ request: urllib.request.Request,
82
+ timeout: float,
83
+ provider: str | None,
84
+ config: dict[str, Any] | None,
85
+ ) -> Any:
86
+ try:
87
+ return self._ports.network_open(
88
+ request, timeout, provider, config, self._ports.log
89
+ )
90
+ except urllib.error.HTTPError as exc:
91
+ if (
92
+ provider != self.provider
93
+ or exc.code not in (401, 403)
94
+ or config is None
95
+ ):
96
+ raise
97
+ if not self.force_refresh():
98
+ raise
99
+ headers = dict(request.header_items())
100
+ headers.update(self._ports.provider_headers(provider, config))
101
+ retry = urllib.request.Request(
102
+ request.full_url,
103
+ data=request.data,
104
+ headers=headers,
105
+ method=request.get_method(),
106
+ )
107
+ self._ports.log(
108
+ "INFO",
109
+ "github_copilot_oauth_token_refreshed_after_auth_error "
110
+ f"status={exc.code}",
111
+ )
112
+ return self._ports.network_open(
113
+ retry, timeout, provider, config, self._ports.log
114
+ )
115
+
116
+ def action(self, action: str) -> list[str]:
117
+ service = self.service()
118
+ action = str(action or "status").strip().lower()
119
+ if action == "logout":
120
+ removed = service.logout()
121
+ self._ports.clear_model_cache()
122
+ return [
123
+ "GitHub Copilot OAuth credentials cleared."
124
+ if removed
125
+ else "GitHub Copilot OAuth credentials were not stored."
126
+ ]
127
+ if action == "status":
128
+ connected, login, expires_at = service.status()
129
+ if not connected:
130
+ return [
131
+ "GitHub Copilot OAuth: not connected. "
132
+ "Run: ciel-runtimectl copilot-oauth login"
133
+ ]
134
+ account = f" ({login})" if login else ""
135
+ expiry = (
136
+ time.strftime(
137
+ "%Y-%m-%d %H:%M:%S %Z",
138
+ time.localtime(expires_at),
139
+ )
140
+ if expires_at
141
+ else "unknown"
142
+ )
143
+ return [
144
+ f"GitHub Copilot OAuth: connected{account}; "
145
+ f"Copilot token expires {expiry}."
146
+ ]
147
+ credentials = service.login(
148
+ self._show_device,
149
+ on_pending=lambda attempt: (
150
+ print("Waiting for GitHub authorization...", flush=True)
151
+ if attempt % 6 == 0
152
+ else None
153
+ ),
154
+ )
155
+ self._ports.clear_model_cache()
156
+ account = (
157
+ f" as {credentials.github_login}"
158
+ if credentials.github_login
159
+ else ""
160
+ )
161
+ return [
162
+ f"GitHub Copilot OAuth connected{account}. "
163
+ "Select provider: ciel-runtimectl provider github-copilot-oauth"
164
+ ]
165
+
166
+ def panel_rows(
167
+ self, provider: str
168
+ ) -> tuple[list[str], list[str]] | None:
169
+ if provider != self.provider:
170
+ return None
171
+ connected, login, _expires_at = self.service().status()
172
+ status = (
173
+ f"Connected as {login}"
174
+ if connected and login
175
+ else "Connected"
176
+ if connected
177
+ else "Not connected"
178
+ )
179
+ return (
180
+ [
181
+ f"OAuth status: {status}",
182
+ "Login with GitHub Device Code",
183
+ "Refresh OAuth status",
184
+ "Logout and clear OAuth credentials",
185
+ "Back",
186
+ ],
187
+ [
188
+ "oauth-status",
189
+ "oauth-login",
190
+ "oauth-status",
191
+ "oauth-logout",
192
+ "back",
193
+ ],
194
+ )
195
+
196
+ @staticmethod
197
+ def _show_device(verification_uri: str, user_code: str) -> None:
198
+ print(
199
+ "GitHub Copilot OAuth device login\n"
200
+ f" Open: {verification_uri}\n"
201
+ f" Code: {user_code}",
202
+ flush=True,
203
+ )
204
+ try:
205
+ webbrowser.open(verification_uri)
206
+ except Exception:
207
+ pass
208
+
209
+
210
+ __all__ = [
211
+ "GitHubCopilotOAuthRuntime",
212
+ "GitHubCopilotOAuthRuntimePorts",
213
+ ]