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.
- agento/__init__.py +0 -0
- agento/framework/__init__.py +0 -0
- agento/framework/agent_config_writer.py +162 -0
- agento/framework/agent_manager/__init__.py +36 -0
- agento/framework/agent_manager/active.py +62 -0
- agento/framework/agent_manager/auth.py +131 -0
- agento/framework/agent_manager/config.py +22 -0
- agento/framework/agent_manager/models.py +55 -0
- agento/framework/agent_manager/rotator.py +101 -0
- agento/framework/agent_manager/runner.py +182 -0
- agento/framework/agent_manager/token_resolver.py +45 -0
- agento/framework/agent_manager/token_store.py +145 -0
- agento/framework/agent_manager/usage_store.py +92 -0
- agento/framework/agent_view_runtime.py +96 -0
- agento/framework/agent_view_worker.py +120 -0
- agento/framework/bootstrap.py +312 -0
- agento/framework/channels/__init__.py +3 -0
- agento/framework/channels/base.py +80 -0
- agento/framework/channels/registry.py +26 -0
- agento/framework/channels/test.py +22 -0
- agento/framework/cli/__init__.py +145 -0
- agento/framework/cli/__main__.py +3 -0
- agento/framework/cli/_env.py +56 -0
- agento/framework/cli/_output.py +41 -0
- agento/framework/cli/_project.py +45 -0
- agento/framework/cli/compose.py +129 -0
- agento/framework/cli/config.py +377 -0
- agento/framework/cli/doctor.py +154 -0
- agento/framework/cli/init.py +151 -0
- agento/framework/cli/module.py +204 -0
- agento/framework/cli/runtime.py +315 -0
- agento/framework/cli/templates/docker-compose.yml +93 -0
- agento/framework/cli/templates/env.example +2 -0
- agento/framework/cli/templates/gitignore +14 -0
- agento/framework/cli/templates/secrets.env.example +10 -0
- agento/framework/cli/terminal.py +101 -0
- agento/framework/cli/token.py +350 -0
- agento/framework/commands.py +73 -0
- agento/framework/config_resolver.py +227 -0
- agento/framework/consumer.py +524 -0
- agento/framework/consumer_config.py +40 -0
- agento/framework/contracts/__init__.py +88 -0
- agento/framework/core_config.py +246 -0
- agento/framework/cron.json +9 -0
- agento/framework/crontab.py +132 -0
- agento/framework/crypto.py +56 -0
- agento/framework/data_patch.py +176 -0
- agento/framework/database_config.py +34 -0
- agento/framework/db.py +47 -0
- agento/framework/dependency_resolver.py +142 -0
- agento/framework/e2e.py +209 -0
- agento/framework/encryptor.py +44 -0
- agento/framework/event_manager.py +70 -0
- agento/framework/events.py +244 -0
- agento/framework/ingress_identity.py +88 -0
- agento/framework/job_models.py +105 -0
- agento/framework/lock.py +32 -0
- agento/framework/log.py +63 -0
- agento/framework/migrate.py +161 -0
- agento/framework/module_loader.py +162 -0
- agento/framework/module_scaffold.py +127 -0
- agento/framework/module_status.py +80 -0
- agento/framework/module_validator.py +179 -0
- agento/framework/onboarding.py +40 -0
- agento/framework/publisher.py +68 -0
- agento/framework/replay.py +103 -0
- agento/framework/retry_policy.py +54 -0
- agento/framework/router.py +138 -0
- agento/framework/router_registry.py +25 -0
- agento/framework/run_dir.py +35 -0
- agento/framework/runner.py +39 -0
- agento/framework/runner_factory.py +52 -0
- agento/framework/scoped_config.py +309 -0
- agento/framework/setup.py +191 -0
- agento/framework/sql/001_create_tables.sql +41 -0
- agento/framework/sql/002_generalize_jobs.sql +6 -0
- agento/framework/sql/003_rename_queued_to_todo.sql +8 -0
- agento/framework/sql/004_add_followup_type.sql +8 -0
- agento/framework/sql/005_agent_manager.sql +29 -0
- agento/framework/sql/007_model_and_tracking.sql +12 -0
- agento/framework/sql/008_job_prompt_output.sql +3 -0
- agento/framework/sql/009_add_blank_type.sql +3 -0
- agento/framework/sql/010_core_config_data.sql +10 -0
- agento/framework/sql/011_module_migrations.sql +3 -0
- agento/framework/sql/012_data_patches_table.sql +9 -0
- agento/framework/sql/013_singular_table_names.sql +4 -0
- agento/framework/sql/014_workspace_agent_view.sql +38 -0
- agento/framework/sql/015_ingress_identity.sql +13 -0
- agento/framework/sql/016_job_priority.sql +6 -0
- agento/framework/workflows/__init__.py +26 -0
- agento/framework/workflows/base.py +55 -0
- agento/framework/workflows/blank.py +18 -0
- agento/framework/workspace.py +85 -0
- agento/modules/agent_view/config.json +4 -0
- agento/modules/agent_view/events.json +5 -0
- agento/modules/agent_view/module.json +7 -0
- agento/modules/agent_view/src/__init__.py +0 -0
- agento/modules/agent_view/src/instruction_writer.py +50 -0
- agento/modules/agent_view/src/observers.py +46 -0
- agento/modules/agent_view/system.json +7 -0
- agento/modules/claude/di.json +8 -0
- agento/modules/claude/module.json +7 -0
- agento/modules/claude/src/__init__.py +0 -0
- agento/modules/claude/src/auth.py +43 -0
- agento/modules/claude/src/output_parser.py +50 -0
- agento/modules/claude/src/runner.py +35 -0
- agento/modules/codex/di.json +8 -0
- agento/modules/codex/module.json +7 -0
- agento/modules/codex/src/__init__.py +0 -0
- agento/modules/codex/src/auth.py +45 -0
- agento/modules/codex/src/runner.py +68 -0
- agento/modules/core/config.json +7 -0
- agento/modules/core/data_patch.json +5 -0
- agento/modules/core/di.json +10 -0
- agento/modules/core/module.json +7 -0
- agento/modules/core/src/commands/__init__.py +0 -0
- agento/modules/core/src/commands/ingress_bind.py +41 -0
- agento/modules/core/src/commands/ingress_list.py +54 -0
- agento/modules/core/src/commands/ingress_unbind.py +38 -0
- agento/modules/core/src/patches/seed_workspace.py +19 -0
- agento/modules/core/src/routers/__init__.py +0 -0
- agento/modules/core/src/routers/identity_router.py +26 -0
- agento/modules/core/system.json +14 -0
- agento/modules/core/toolbox/browser.js +367 -0
- agento/modules/core/toolbox/email.js +122 -0
- agento/modules/core/toolbox/schedule.js +97 -0
- agento/modules/crypt/events.json +5 -0
- agento/modules/crypt/module.json +6 -0
- agento/modules/crypt/src/aes_cbc_backend.py +13 -0
- agento/modules/crypt/src/observers.py +21 -0
- agento/modules/jira/config.json +4 -0
- agento/modules/jira/cron.json +6 -0
- agento/modules/jira/di.json +15 -0
- agento/modules/jira/module.json +7 -0
- agento/modules/jira/src/__init__.py +0 -0
- agento/modules/jira/src/channel.py +289 -0
- agento/modules/jira/src/commands/__init__.py +0 -0
- agento/modules/jira/src/commands/exec_todo.py +48 -0
- agento/modules/jira/src/commands/publish.py +76 -0
- agento/modules/jira/src/config.py +41 -0
- agento/modules/jira/src/mention_detector.py +40 -0
- agento/modules/jira/src/models.py +38 -0
- agento/modules/jira/src/onboarding.py +171 -0
- agento/modules/jira/src/task_list.py +103 -0
- agento/modules/jira/src/toolbox_client.py +57 -0
- agento/modules/jira/src/workflows/__init__.py +0 -0
- agento/modules/jira/src/workflows/followup.py +52 -0
- agento/modules/jira/src/workflows/todo.py +84 -0
- agento/modules/jira/system.json +12 -0
- agento/modules/jira/toolbox/api.js +113 -0
- agento/modules/jira/toolbox/jira-proxy.js +67 -0
- agento/modules/jira/toolbox/jira.js +666 -0
- agento/modules/jira_periodic_tasks/config.json +12 -0
- agento/modules/jira_periodic_tasks/cron.json +5 -0
- agento/modules/jira_periodic_tasks/di.json +11 -0
- agento/modules/jira_periodic_tasks/module.json +7 -0
- agento/modules/jira_periodic_tasks/src/__init__.py +0 -0
- agento/modules/jira_periodic_tasks/src/commands/__init__.py +0 -0
- agento/modules/jira_periodic_tasks/src/commands/exec_cron.py +36 -0
- agento/modules/jira_periodic_tasks/src/commands/sync.py +47 -0
- agento/modules/jira_periodic_tasks/src/config.py +19 -0
- agento/modules/jira_periodic_tasks/src/crontab.py +91 -0
- agento/modules/jira_periodic_tasks/src/onboarding.py +316 -0
- agento/modules/jira_periodic_tasks/src/sync.py +162 -0
- agento/modules/jira_periodic_tasks/src/workflows/__init__.py +0 -0
- agento/modules/jira_periodic_tasks/src/workflows/cron.py +37 -0
- agento/modules/jira_periodic_tasks/system.json +5 -0
- agento/toolbox/adapters/index.js +38 -0
- agento/toolbox/adapters/mssql.js +116 -0
- agento/toolbox/adapters/mysql.js +112 -0
- agento/toolbox/adapters/opensearch.js +94 -0
- agento/toolbox/adapters/sql-timeout.js +12 -0
- agento/toolbox/config-loader.js +410 -0
- agento/toolbox/crypto.js +51 -0
- agento/toolbox/db.js +18 -0
- agento/toolbox/eslint.config.js +33 -0
- agento/toolbox/log.js +35 -0
- agento/toolbox/package-lock.json +5296 -0
- agento/toolbox/package.json +24 -0
- agento/toolbox/playwright-client.js +89 -0
- agento/toolbox/server.js +198 -0
- agento/toolbox/tests/config-loader.test.js +797 -0
- agento/toolbox/tests/crypto.test.js +61 -0
- agento/toolbox/tests/healthcheck.test.js +198 -0
- agento/toolbox/tests/jira-proxy.test.js +120 -0
- agento/toolbox/tests/log.test.js +46 -0
- agento/toolbox/tests/sql-timeout.test.js +129 -0
- agento_core-0.1.4.dist-info/METADATA +160 -0
- agento_core-0.1.4.dist-info/RECORD +192 -0
- agento_core-0.1.4.dist-info/WHEEL +4 -0
- agento_core-0.1.4.dist-info/entry_points.txt +8 -0
- 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
|