agento-core 0.1.4__py3-none-any.whl

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 (192) hide show
  1. agento/__init__.py +0 -0
  2. agento/framework/__init__.py +0 -0
  3. agento/framework/agent_config_writer.py +162 -0
  4. agento/framework/agent_manager/__init__.py +36 -0
  5. agento/framework/agent_manager/active.py +62 -0
  6. agento/framework/agent_manager/auth.py +131 -0
  7. agento/framework/agent_manager/config.py +22 -0
  8. agento/framework/agent_manager/models.py +55 -0
  9. agento/framework/agent_manager/rotator.py +101 -0
  10. agento/framework/agent_manager/runner.py +182 -0
  11. agento/framework/agent_manager/token_resolver.py +45 -0
  12. agento/framework/agent_manager/token_store.py +145 -0
  13. agento/framework/agent_manager/usage_store.py +92 -0
  14. agento/framework/agent_view_runtime.py +96 -0
  15. agento/framework/agent_view_worker.py +120 -0
  16. agento/framework/bootstrap.py +312 -0
  17. agento/framework/channels/__init__.py +3 -0
  18. agento/framework/channels/base.py +80 -0
  19. agento/framework/channels/registry.py +26 -0
  20. agento/framework/channels/test.py +22 -0
  21. agento/framework/cli/__init__.py +145 -0
  22. agento/framework/cli/__main__.py +3 -0
  23. agento/framework/cli/_env.py +56 -0
  24. agento/framework/cli/_output.py +41 -0
  25. agento/framework/cli/_project.py +45 -0
  26. agento/framework/cli/compose.py +129 -0
  27. agento/framework/cli/config.py +377 -0
  28. agento/framework/cli/doctor.py +154 -0
  29. agento/framework/cli/init.py +151 -0
  30. agento/framework/cli/module.py +204 -0
  31. agento/framework/cli/runtime.py +315 -0
  32. agento/framework/cli/templates/docker-compose.yml +93 -0
  33. agento/framework/cli/templates/env.example +2 -0
  34. agento/framework/cli/templates/gitignore +14 -0
  35. agento/framework/cli/templates/secrets.env.example +10 -0
  36. agento/framework/cli/terminal.py +101 -0
  37. agento/framework/cli/token.py +350 -0
  38. agento/framework/commands.py +73 -0
  39. agento/framework/config_resolver.py +227 -0
  40. agento/framework/consumer.py +524 -0
  41. agento/framework/consumer_config.py +40 -0
  42. agento/framework/contracts/__init__.py +88 -0
  43. agento/framework/core_config.py +246 -0
  44. agento/framework/cron.json +9 -0
  45. agento/framework/crontab.py +132 -0
  46. agento/framework/crypto.py +56 -0
  47. agento/framework/data_patch.py +176 -0
  48. agento/framework/database_config.py +34 -0
  49. agento/framework/db.py +47 -0
  50. agento/framework/dependency_resolver.py +142 -0
  51. agento/framework/e2e.py +209 -0
  52. agento/framework/encryptor.py +44 -0
  53. agento/framework/event_manager.py +70 -0
  54. agento/framework/events.py +244 -0
  55. agento/framework/ingress_identity.py +88 -0
  56. agento/framework/job_models.py +105 -0
  57. agento/framework/lock.py +32 -0
  58. agento/framework/log.py +63 -0
  59. agento/framework/migrate.py +161 -0
  60. agento/framework/module_loader.py +162 -0
  61. agento/framework/module_scaffold.py +127 -0
  62. agento/framework/module_status.py +80 -0
  63. agento/framework/module_validator.py +179 -0
  64. agento/framework/onboarding.py +40 -0
  65. agento/framework/publisher.py +68 -0
  66. agento/framework/replay.py +103 -0
  67. agento/framework/retry_policy.py +54 -0
  68. agento/framework/router.py +138 -0
  69. agento/framework/router_registry.py +25 -0
  70. agento/framework/run_dir.py +35 -0
  71. agento/framework/runner.py +39 -0
  72. agento/framework/runner_factory.py +52 -0
  73. agento/framework/scoped_config.py +309 -0
  74. agento/framework/setup.py +191 -0
  75. agento/framework/sql/001_create_tables.sql +41 -0
  76. agento/framework/sql/002_generalize_jobs.sql +6 -0
  77. agento/framework/sql/003_rename_queued_to_todo.sql +8 -0
  78. agento/framework/sql/004_add_followup_type.sql +8 -0
  79. agento/framework/sql/005_agent_manager.sql +29 -0
  80. agento/framework/sql/007_model_and_tracking.sql +12 -0
  81. agento/framework/sql/008_job_prompt_output.sql +3 -0
  82. agento/framework/sql/009_add_blank_type.sql +3 -0
  83. agento/framework/sql/010_core_config_data.sql +10 -0
  84. agento/framework/sql/011_module_migrations.sql +3 -0
  85. agento/framework/sql/012_data_patches_table.sql +9 -0
  86. agento/framework/sql/013_singular_table_names.sql +4 -0
  87. agento/framework/sql/014_workspace_agent_view.sql +38 -0
  88. agento/framework/sql/015_ingress_identity.sql +13 -0
  89. agento/framework/sql/016_job_priority.sql +6 -0
  90. agento/framework/workflows/__init__.py +26 -0
  91. agento/framework/workflows/base.py +55 -0
  92. agento/framework/workflows/blank.py +18 -0
  93. agento/framework/workspace.py +85 -0
  94. agento/modules/agent_view/config.json +4 -0
  95. agento/modules/agent_view/events.json +5 -0
  96. agento/modules/agent_view/module.json +7 -0
  97. agento/modules/agent_view/src/__init__.py +0 -0
  98. agento/modules/agent_view/src/instruction_writer.py +50 -0
  99. agento/modules/agent_view/src/observers.py +46 -0
  100. agento/modules/agent_view/system.json +7 -0
  101. agento/modules/claude/di.json +8 -0
  102. agento/modules/claude/module.json +7 -0
  103. agento/modules/claude/src/__init__.py +0 -0
  104. agento/modules/claude/src/auth.py +43 -0
  105. agento/modules/claude/src/output_parser.py +50 -0
  106. agento/modules/claude/src/runner.py +35 -0
  107. agento/modules/codex/di.json +8 -0
  108. agento/modules/codex/module.json +7 -0
  109. agento/modules/codex/src/__init__.py +0 -0
  110. agento/modules/codex/src/auth.py +45 -0
  111. agento/modules/codex/src/runner.py +68 -0
  112. agento/modules/core/config.json +7 -0
  113. agento/modules/core/data_patch.json +5 -0
  114. agento/modules/core/di.json +10 -0
  115. agento/modules/core/module.json +7 -0
  116. agento/modules/core/src/commands/__init__.py +0 -0
  117. agento/modules/core/src/commands/ingress_bind.py +41 -0
  118. agento/modules/core/src/commands/ingress_list.py +54 -0
  119. agento/modules/core/src/commands/ingress_unbind.py +38 -0
  120. agento/modules/core/src/patches/seed_workspace.py +19 -0
  121. agento/modules/core/src/routers/__init__.py +0 -0
  122. agento/modules/core/src/routers/identity_router.py +26 -0
  123. agento/modules/core/system.json +14 -0
  124. agento/modules/core/toolbox/browser.js +367 -0
  125. agento/modules/core/toolbox/email.js +122 -0
  126. agento/modules/core/toolbox/schedule.js +97 -0
  127. agento/modules/crypt/events.json +5 -0
  128. agento/modules/crypt/module.json +6 -0
  129. agento/modules/crypt/src/aes_cbc_backend.py +13 -0
  130. agento/modules/crypt/src/observers.py +21 -0
  131. agento/modules/jira/config.json +4 -0
  132. agento/modules/jira/cron.json +6 -0
  133. agento/modules/jira/di.json +15 -0
  134. agento/modules/jira/module.json +7 -0
  135. agento/modules/jira/src/__init__.py +0 -0
  136. agento/modules/jira/src/channel.py +289 -0
  137. agento/modules/jira/src/commands/__init__.py +0 -0
  138. agento/modules/jira/src/commands/exec_todo.py +48 -0
  139. agento/modules/jira/src/commands/publish.py +76 -0
  140. agento/modules/jira/src/config.py +41 -0
  141. agento/modules/jira/src/mention_detector.py +40 -0
  142. agento/modules/jira/src/models.py +38 -0
  143. agento/modules/jira/src/onboarding.py +171 -0
  144. agento/modules/jira/src/task_list.py +103 -0
  145. agento/modules/jira/src/toolbox_client.py +57 -0
  146. agento/modules/jira/src/workflows/__init__.py +0 -0
  147. agento/modules/jira/src/workflows/followup.py +52 -0
  148. agento/modules/jira/src/workflows/todo.py +84 -0
  149. agento/modules/jira/system.json +12 -0
  150. agento/modules/jira/toolbox/api.js +113 -0
  151. agento/modules/jira/toolbox/jira-proxy.js +67 -0
  152. agento/modules/jira/toolbox/jira.js +666 -0
  153. agento/modules/jira_periodic_tasks/config.json +12 -0
  154. agento/modules/jira_periodic_tasks/cron.json +5 -0
  155. agento/modules/jira_periodic_tasks/di.json +11 -0
  156. agento/modules/jira_periodic_tasks/module.json +7 -0
  157. agento/modules/jira_periodic_tasks/src/__init__.py +0 -0
  158. agento/modules/jira_periodic_tasks/src/commands/__init__.py +0 -0
  159. agento/modules/jira_periodic_tasks/src/commands/exec_cron.py +36 -0
  160. agento/modules/jira_periodic_tasks/src/commands/sync.py +47 -0
  161. agento/modules/jira_periodic_tasks/src/config.py +19 -0
  162. agento/modules/jira_periodic_tasks/src/crontab.py +91 -0
  163. agento/modules/jira_periodic_tasks/src/onboarding.py +316 -0
  164. agento/modules/jira_periodic_tasks/src/sync.py +162 -0
  165. agento/modules/jira_periodic_tasks/src/workflows/__init__.py +0 -0
  166. agento/modules/jira_periodic_tasks/src/workflows/cron.py +37 -0
  167. agento/modules/jira_periodic_tasks/system.json +5 -0
  168. agento/toolbox/adapters/index.js +38 -0
  169. agento/toolbox/adapters/mssql.js +116 -0
  170. agento/toolbox/adapters/mysql.js +112 -0
  171. agento/toolbox/adapters/opensearch.js +94 -0
  172. agento/toolbox/adapters/sql-timeout.js +12 -0
  173. agento/toolbox/config-loader.js +410 -0
  174. agento/toolbox/crypto.js +51 -0
  175. agento/toolbox/db.js +18 -0
  176. agento/toolbox/eslint.config.js +33 -0
  177. agento/toolbox/log.js +35 -0
  178. agento/toolbox/package-lock.json +5296 -0
  179. agento/toolbox/package.json +24 -0
  180. agento/toolbox/playwright-client.js +89 -0
  181. agento/toolbox/server.js +198 -0
  182. agento/toolbox/tests/config-loader.test.js +797 -0
  183. agento/toolbox/tests/crypto.test.js +61 -0
  184. agento/toolbox/tests/healthcheck.test.js +198 -0
  185. agento/toolbox/tests/jira-proxy.test.js +120 -0
  186. agento/toolbox/tests/log.test.js +46 -0
  187. agento/toolbox/tests/sql-timeout.test.js +129 -0
  188. agento_core-0.1.4.dist-info/METADATA +160 -0
  189. agento_core-0.1.4.dist-info/RECORD +192 -0
  190. agento_core-0.1.4.dist-info/WHEEL +4 -0
  191. agento_core-0.1.4.dist-info/entry_points.txt +8 -0
  192. agento_core-0.1.4.dist-info/licenses/LICENSE +21 -0
agento/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,162 @@
1
+ """Generate agent CLI config files from resolved scoped config.
2
+
3
+ Before each worker run, generates native config files that agent CLIs expect:
4
+ - .claude.json (Claude Code project config)
5
+ - .claude/settings.json (Claude Code user settings)
6
+ - .mcp.json (MCP server configuration)
7
+ - .codex/config.toml (Codex CLI config)
8
+
9
+ Config field paths follow the convention:
10
+ agent/claude/model -> model for Claude CLI
11
+ agent/claude/personality -> system prompt / personality
12
+ agent/mcp/servers -> MCP server definitions (JSON)
13
+ agent/codex/model -> model for Codex CLI
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Config path prefix for agent CLI settings
25
+ AGENT_CONFIG_PREFIX = "agent/"
26
+
27
+
28
+ def _get_agent_config(resolved_config: dict[str, tuple[str, bool]]) -> dict[str, str]:
29
+ """Extract agent/* paths from resolved DB overrides into a flat dict.
30
+
31
+ Returns {relative_path: value}, e.g. {"claude/model": "opus-4"}.
32
+ """
33
+ result = {}
34
+ for path, (value, _encrypted) in resolved_config.items():
35
+ if path.startswith(AGENT_CONFIG_PREFIX) and value is not None:
36
+ relative = path[len(AGENT_CONFIG_PREFIX):]
37
+ result[relative] = value
38
+ return result
39
+
40
+
41
+ def generate_claude_config(working_dir: Path, agent_config: dict[str, str]) -> None:
42
+ """Generate .claude.json and .claude/settings.json in the working directory."""
43
+ # .claude.json — project-level config
44
+ claude_json: dict[str, Any] = {}
45
+
46
+ model = agent_config.get("claude/model")
47
+ if model:
48
+ claude_json["model"] = model
49
+
50
+ personality = agent_config.get("claude/personality")
51
+ if personality:
52
+ claude_json["systemPrompt"] = personality
53
+
54
+ permissions = agent_config.get("claude/permissions")
55
+ if permissions:
56
+ try:
57
+ claude_json["permissions"] = json.loads(permissions)
58
+ except (json.JSONDecodeError, TypeError):
59
+ logger.warning("Invalid JSON in agent/claude/permissions, skipping")
60
+
61
+ if claude_json:
62
+ config_path = working_dir / ".claude.json"
63
+ config_path.write_text(json.dumps(claude_json, indent=2) + "\n")
64
+ logger.debug("Generated %s", config_path)
65
+
66
+ # .claude/settings.json — user-level settings
67
+ settings: dict[str, Any] = {}
68
+
69
+ trust_level = agent_config.get("claude/trust_level")
70
+ if trust_level:
71
+ settings["permissions"] = {"dangerouslySkipPermissions": trust_level == "full"}
72
+
73
+ if settings:
74
+ settings_dir = working_dir / ".claude"
75
+ settings_dir.mkdir(parents=True, exist_ok=True)
76
+ settings_path = settings_dir / "settings.json"
77
+ settings_path.write_text(json.dumps(settings, indent=2) + "\n")
78
+ logger.debug("Generated %s", settings_path)
79
+
80
+
81
+ def _inject_agent_view_id(servers: dict, agent_view_id: int) -> dict:
82
+ """Append ?agent_view_id=N to toolbox SSE/MCP URLs in mcpServers config."""
83
+ for server_cfg in servers.values():
84
+ url = server_cfg.get("url", "")
85
+ if "/sse" in url or "/mcp" in url:
86
+ sep = "&" if "?" in url else "?"
87
+ server_cfg["url"] = f"{url}{sep}agent_view_id={agent_view_id}"
88
+ return servers
89
+
90
+
91
+ def generate_mcp_config(
92
+ working_dir: Path,
93
+ agent_config: dict[str, str],
94
+ *,
95
+ agent_view_id: int | None = None,
96
+ ) -> None:
97
+ """Generate .mcp.json in the working directory."""
98
+ servers_raw = agent_config.get("mcp/servers")
99
+ if not servers_raw:
100
+ return
101
+
102
+ try:
103
+ servers = json.loads(servers_raw)
104
+ except (json.JSONDecodeError, TypeError):
105
+ logger.warning("Invalid JSON in agent/mcp/servers, skipping .mcp.json generation")
106
+ return
107
+
108
+ if agent_view_id is not None:
109
+ servers = _inject_agent_view_id(servers, agent_view_id)
110
+
111
+ mcp_config = {"mcpServers": servers}
112
+ config_path = working_dir / ".mcp.json"
113
+ config_path.write_text(json.dumps(mcp_config, indent=2) + "\n")
114
+ logger.debug("Generated %s", config_path)
115
+
116
+
117
+ def generate_codex_config(working_dir: Path, agent_config: dict[str, str]) -> None:
118
+ """Generate .codex/config.toml in the working directory."""
119
+ lines: list[str] = []
120
+
121
+ model = agent_config.get("codex/model")
122
+ if model:
123
+ lines.append(f'model = "{model}"')
124
+
125
+ approval_mode = agent_config.get("codex/approval_mode")
126
+ if approval_mode:
127
+ lines.append(f'approval_mode = "{approval_mode}"')
128
+
129
+ if not lines:
130
+ return
131
+
132
+ codex_dir = working_dir / ".codex"
133
+ codex_dir.mkdir(parents=True, exist_ok=True)
134
+ config_path = codex_dir / "config.toml"
135
+ config_path.write_text("\n".join(lines) + "\n")
136
+ logger.debug("Generated %s", config_path)
137
+
138
+
139
+ def populate_agent_configs(
140
+ working_dir: str | Path,
141
+ scoped_overrides: dict[str, tuple[str, bool]],
142
+ *,
143
+ agent_view_id: int | None = None,
144
+ ) -> None:
145
+ """Generate all agent CLI config files from scoped DB overrides.
146
+
147
+ Called before each worker run with the merged (agent_view -> workspace -> global) overrides.
148
+ """
149
+ wd = Path(working_dir)
150
+ wd.mkdir(parents=True, exist_ok=True)
151
+
152
+ agent_config = _get_agent_config(scoped_overrides)
153
+
154
+ if not agent_config:
155
+ logger.debug("No agent/* config paths found, skipping config file generation")
156
+ return
157
+
158
+ generate_claude_config(wd, agent_config)
159
+ generate_mcp_config(wd, agent_config, agent_view_id=agent_view_id)
160
+ generate_codex_config(wd, agent_config)
161
+
162
+ logger.info("Populated agent config files in %s", wd)
@@ -0,0 +1,36 @@
1
+ """Agent Manager — multi-token orchestration for LLM agent providers."""
2
+
3
+ from .active import read_credentials, resolve_active_token, update_active_token
4
+ from .auth import AuthenticationError, AuthResult, authenticate_interactive, save_credentials
5
+ from .config import AgentManagerConfig
6
+ from .models import AgentProvider, RotationResult, Token, UsageSummary
7
+ from .rotator import rotate_all, rotate_tokens, select_best_token
8
+ from .token_store import deregister_token, get_token, get_token_by_path, list_tokens, register_token, set_primary_token
9
+ from .usage_store import get_usage_summaries, get_usage_summary, record_usage
10
+
11
+ __all__ = [
12
+ "AgentManagerConfig",
13
+ "AgentProvider",
14
+ "AuthResult",
15
+ "AuthenticationError",
16
+ "RotationResult",
17
+ "Token",
18
+ "UsageSummary",
19
+ "authenticate_interactive",
20
+ "deregister_token",
21
+ "get_token",
22
+ "get_token_by_path",
23
+ "get_usage_summaries",
24
+ "get_usage_summary",
25
+ "list_tokens",
26
+ "read_credentials",
27
+ "record_usage",
28
+ "register_token",
29
+ "resolve_active_token",
30
+ "rotate_all",
31
+ "rotate_tokens",
32
+ "save_credentials",
33
+ "select_best_token",
34
+ "set_primary_token",
35
+ "update_active_token",
36
+ ]
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from .config import AgentManagerConfig
10
+ from .models import AgentProvider, Token
11
+
12
+
13
+ def resolve_active_token(
14
+ config: AgentManagerConfig,
15
+ agent_type: AgentProvider,
16
+ ) -> str | None:
17
+ """Read the active symlink for an agent type. Returns the target path, or None."""
18
+ link = Path(config.active_dir) / agent_type.value
19
+ if not link.is_symlink():
20
+ return None
21
+ target = link.resolve()
22
+ if not target.is_file():
23
+ return None
24
+ return str(target)
25
+
26
+
27
+ def update_active_token(
28
+ config: AgentManagerConfig,
29
+ agent_type: AgentProvider,
30
+ token: Token,
31
+ logger: logging.Logger | None = None,
32
+ ) -> None:
33
+ """Atomically update the active symlink for an agent type.
34
+
35
+ Strategy: create temp symlink in active_dir, then os.rename() over the
36
+ real one. os.rename() is atomic on POSIX when src and dst are on the
37
+ same filesystem.
38
+ """
39
+ active_dir = Path(config.active_dir)
40
+ active_dir.mkdir(parents=True, exist_ok=True)
41
+
42
+ link_path = active_dir / agent_type.value
43
+ target = token.credentials_path
44
+
45
+ # mkstemp creates a file; remove it so we can create a symlink at that path.
46
+ fd, tmp_path = tempfile.mkstemp(dir=str(active_dir), prefix=f".{agent_type.value}.")
47
+ os.close(fd)
48
+ os.unlink(tmp_path)
49
+ os.symlink(target, tmp_path)
50
+ os.rename(tmp_path, str(link_path))
51
+
52
+ if logger:
53
+ logger.info(
54
+ f"Active token updated: agent_type={agent_type.value} "
55
+ f"label={token.label} target={target}"
56
+ )
57
+
58
+
59
+ def read_credentials(credentials_path: str) -> dict:
60
+ """Read opaque credentials JSON from a token file."""
61
+ with open(credentials_path) as f:
62
+ return json.load(f)
@@ -0,0 +1,131 @@
1
+ """Interactive OAuth authentication for agent CLI tools.
2
+
3
+ Launches ``claude`` or ``codex`` CLI in an isolated temporary HOME directory
4
+ to perform OAuth, then extracts and normalises credentials to the internal
5
+ JSON format used by token runners.
6
+
7
+ The isolation prevents the auth flow from overwriting the main active
8
+ credentials at ``/workspace/.claude`` or ``/workspace/.codex``.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import tempfile
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Protocol, runtime_checkable
21
+
22
+ from .models import AgentProvider
23
+
24
+
25
+ class AuthenticationError(RuntimeError):
26
+ """Raised when the interactive auth flow fails or is cancelled."""
27
+
28
+
29
+ @dataclass
30
+ class AuthResult:
31
+ """Normalised credentials extracted after successful OAuth."""
32
+
33
+ subscription_key: str
34
+ refresh_token: str | None
35
+ expires_at: int | None
36
+ subscription_type: str | None
37
+ id_token: str | None = None
38
+ raw_auth: dict | None = None # Native auth.json for agents that need it (Codex)
39
+
40
+
41
+ @runtime_checkable
42
+ class AuthStrategy(Protocol):
43
+ """Protocol for provider-specific authentication flows."""
44
+
45
+ def authenticate(self, tmp_home: str, logger: logging.Logger) -> AuthResult: ...
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Auth strategy registry
50
+ # ---------------------------------------------------------------------------
51
+
52
+ _STRATEGIES: dict[AgentProvider, AuthStrategy] = {}
53
+
54
+
55
+ def register_auth_strategy(provider: AgentProvider, strategy: AuthStrategy) -> None:
56
+ _STRATEGIES[provider] = strategy
57
+
58
+
59
+ def get_auth_strategy(provider: AgentProvider) -> AuthStrategy | None:
60
+ return _STRATEGIES.get(provider)
61
+
62
+
63
+ def clear_auth_strategies() -> None:
64
+ _STRATEGIES.clear()
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Public API
69
+ # ---------------------------------------------------------------------------
70
+
71
+ def authenticate_interactive(
72
+ agent_type: AgentProvider,
73
+ logger: logging.Logger | None = None,
74
+ ) -> AuthResult:
75
+ """Run interactive OAuth for the given agent type.
76
+
77
+ Creates an isolated temp HOME directory so the auth flow does NOT
78
+ touch ``~/.claude`` or ``~/.codex`` (symlinked to ``/workspace/``).
79
+
80
+ Raises :class:`AuthenticationError` on failure or user cancellation.
81
+ """
82
+ _log = logger or logging.getLogger(__name__)
83
+
84
+ strategy = _STRATEGIES.get(agent_type)
85
+ if strategy is None:
86
+ raise ValueError(f"No auth strategy registered for: {agent_type.value}")
87
+
88
+ tmp_home = tempfile.mkdtemp(prefix=f"auth_{agent_type.value}_")
89
+ _log.info(f"Using isolated HOME: {tmp_home}")
90
+
91
+ try:
92
+ return strategy.authenticate(tmp_home, _log)
93
+ finally:
94
+ shutil.rmtree(tmp_home, ignore_errors=True)
95
+ _log.debug(f"Cleaned up temp HOME: {tmp_home}")
96
+
97
+
98
+ def save_credentials(auth_result: AuthResult, output_path: str) -> None:
99
+ """Save normalised credentials to a JSON file.
100
+
101
+ Creates parent directories if needed.
102
+ """
103
+ data = {
104
+ "subscription_key": auth_result.subscription_key,
105
+ "refresh_token": auth_result.refresh_token,
106
+ "expires_at": auth_result.expires_at,
107
+ "subscription_type": auth_result.subscription_type,
108
+ "id_token": auth_result.id_token,
109
+ "raw_auth": auth_result.raw_auth,
110
+ }
111
+ path = Path(output_path)
112
+ path.parent.mkdir(parents=True, exist_ok=True)
113
+ path.write_text(json.dumps(data, indent=2))
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Shared CLI helper (used by strategy implementations in modules)
118
+ # ---------------------------------------------------------------------------
119
+
120
+ def _run_cli(cmd: list[str], tmp_home: str, name: str) -> None:
121
+ """Run a CLI command with isolated HOME. Raises on failure."""
122
+ env = {**os.environ, "HOME": tmp_home}
123
+ try:
124
+ proc = subprocess.run(cmd, env=env)
125
+ except FileNotFoundError as exc:
126
+ raise AuthenticationError(f"{name} CLI not found. Is it installed?") from exc
127
+
128
+ if proc.returncode != 0:
129
+ raise AuthenticationError(
130
+ f"{name} login failed with exit code {proc.returncode}"
131
+ )
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class AgentManagerConfig:
9
+ tokens_dir: str = "/etc/tokens"
10
+ active_dir: str = "/etc/tokens/active"
11
+ usage_window_hours: int = 24
12
+ rotation_interval_hours: int = 1
13
+
14
+ @classmethod
15
+ def from_env(cls) -> AgentManagerConfig:
16
+ """Build from env vars only."""
17
+ return cls(
18
+ tokens_dir=os.environ.get("AGENT_TOKENS_DIR", "/etc/tokens"),
19
+ active_dir=os.environ.get("AGENT_ACTIVE_DIR", "/etc/tokens/active"),
20
+ usage_window_hours=int(os.environ.get("AGENT_USAGE_WINDOW_HOURS", "24")),
21
+ rotation_interval_hours=int(os.environ.get("AGENT_ROTATION_INTERVAL_HOURS", "1")),
22
+ )
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from enum import Enum
6
+
7
+
8
+ class AgentProvider(Enum):
9
+ CLAUDE = "claude"
10
+ CODEX = "codex"
11
+
12
+
13
+ @dataclass
14
+ class Token:
15
+ id: int
16
+ agent_type: AgentProvider
17
+ label: str
18
+ credentials_path: str
19
+ model: str | None
20
+ is_primary: bool
21
+ token_limit: int
22
+ enabled: bool
23
+ created_at: datetime
24
+ updated_at: datetime
25
+
26
+ @classmethod
27
+ def from_row(cls, row: dict) -> Token:
28
+ return cls(
29
+ id=row["id"],
30
+ agent_type=AgentProvider(row["agent_type"]),
31
+ label=row["label"],
32
+ credentials_path=row["credentials_path"],
33
+ model=row.get("model"),
34
+ is_primary=bool(row.get("is_primary", False)),
35
+ token_limit=row["token_limit"],
36
+ enabled=bool(row["enabled"]),
37
+ created_at=row["created_at"],
38
+ updated_at=row["updated_at"],
39
+ )
40
+
41
+
42
+ @dataclass
43
+ class UsageSummary:
44
+ token_id: int
45
+ total_tokens: int
46
+ call_count: int
47
+
48
+
49
+ @dataclass
50
+ class RotationResult:
51
+ agent_type: AgentProvider
52
+ previous_token_id: int | None
53
+ new_token_id: int
54
+ reason: str
55
+ timestamp: datetime
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from datetime import UTC, datetime
5
+
6
+ import pymysql
7
+
8
+ from .active import resolve_active_token, update_active_token
9
+ from .config import AgentManagerConfig
10
+ from .models import AgentProvider, RotationResult, Token, UsageSummary
11
+ from .token_store import get_token_by_path, list_tokens
12
+ from .usage_store import get_usage_summaries
13
+
14
+
15
+ def select_best_token(
16
+ tokens: list[Token],
17
+ usage_map: dict[int, UsageSummary],
18
+ ) -> Token | None:
19
+ """Pick the token with the most remaining capacity.
20
+
21
+ Algorithm:
22
+ 0. If any token has is_primary=True, return it immediately (sticky selection).
23
+ 1. For each token, compute remaining = token_limit - total_tokens_used.
24
+ If token_limit == 0 (unlimited), remaining is infinity.
25
+ 2. Return the token with the highest remaining capacity.
26
+ 3. Tie-break: prefer the token with fewer total calls.
27
+ 4. Returns None if no tokens available.
28
+ """
29
+ if not tokens:
30
+ return None
31
+
32
+ # Respect manually-set primary token
33
+ for t in tokens:
34
+ if t.is_primary:
35
+ return t
36
+
37
+ def _sort_key(t: Token) -> tuple[float, int]:
38
+ summary = usage_map.get(t.id)
39
+ used = summary.total_tokens if summary else 0
40
+ calls = summary.call_count if summary else 0
41
+ remaining = float("inf") if t.token_limit == 0 else (t.token_limit - used)
42
+ # Negate remaining so highest is first; use calls as tie-break (fewer = better)
43
+ return (-remaining, calls)
44
+
45
+ return min(tokens, key=_sort_key)
46
+
47
+
48
+ def rotate_tokens(
49
+ conn: pymysql.Connection,
50
+ config: AgentManagerConfig,
51
+ agent_type: AgentProvider,
52
+ logger: logging.Logger | None = None,
53
+ ) -> RotationResult | None:
54
+ """Perform rotation for a single agent type."""
55
+ _log = logger or logging.getLogger(__name__)
56
+
57
+ tokens = list_tokens(conn, agent_type=agent_type, enabled_only=True)
58
+ if not tokens:
59
+ _log.warning(f"No enabled tokens for agent_type={agent_type.value}")
60
+ return None
61
+
62
+ # Current active
63
+ active_path = resolve_active_token(config, agent_type)
64
+ previous_token = get_token_by_path(conn, active_path) if active_path else None
65
+
66
+ # Usage summaries
67
+ summaries = get_usage_summaries(conn, agent_type.value, config.usage_window_hours)
68
+ usage_map = {s.token_id: s for s in summaries}
69
+
70
+ best = select_best_token(tokens, usage_map)
71
+ if best is None:
72
+ return None
73
+
74
+ # Update symlink if changed (or if no active token yet)
75
+ if previous_token is None or previous_token.id != best.id:
76
+ update_active_token(config, agent_type, best, logger)
77
+ reason = "initial" if previous_token is None else "rotation"
78
+ else:
79
+ reason = "unchanged"
80
+
81
+ return RotationResult(
82
+ agent_type=agent_type,
83
+ previous_token_id=previous_token.id if previous_token else None,
84
+ new_token_id=best.id,
85
+ reason=reason,
86
+ timestamp=datetime.now(UTC),
87
+ )
88
+
89
+
90
+ def rotate_all(
91
+ conn: pymysql.Connection,
92
+ config: AgentManagerConfig,
93
+ logger: logging.Logger | None = None,
94
+ ) -> list[RotationResult]:
95
+ """Rotate tokens for all known agent types."""
96
+ results = []
97
+ for agent_type in AgentProvider:
98
+ result = rotate_tokens(conn, config, agent_type, logger)
99
+ if result:
100
+ results.append(result)
101
+ return results