@team-agent/installer 0.1.11 → 0.2.1
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.
- package/crates/team-agent-core/src/lib.rs +50 -5
- package/package.json +1 -1
- package/schemas/team.schema.json +1 -0
- package/src/team_agent/approvals/__init__.py +65 -0
- package/src/team_agent/approvals/constants.py +6 -0
- package/src/team_agent/approvals/parsing.py +176 -0
- package/src/team_agent/approvals/runtime_prompts.py +171 -0
- package/src/team_agent/approvals/status.py +165 -0
- package/src/team_agent/cli/__init__.py +137 -0
- package/src/team_agent/cli/commands.py +339 -0
- package/src/team_agent/cli/e2e.py +202 -0
- package/src/team_agent/cli/helpers.py +137 -0
- package/src/team_agent/cli/parser.py +477 -0
- package/src/team_agent/compiler.py +98 -33
- package/src/team_agent/coordinator/__init__.py +53 -0
- package/src/team_agent/{coordinator.py → coordinator/__main__.py} +3 -1
- package/src/team_agent/coordinator/lifecycle.py +334 -0
- package/src/team_agent/coordinator/metadata.py +61 -0
- package/src/team_agent/coordinator/paths.py +17 -0
- package/src/team_agent/diagnose/__init__.py +48 -0
- package/src/team_agent/diagnose/checks.py +101 -0
- package/src/team_agent/diagnose/health.py +241 -0
- package/src/team_agent/diagnose/preflight.py +194 -0
- package/src/team_agent/diagnose/quick_start.py +233 -0
- package/src/team_agent/display/__init__.py +61 -0
- package/src/team_agent/display/close.py +147 -0
- package/src/team_agent/display/ghostty.py +77 -0
- package/src/team_agent/display/worker_window.py +110 -0
- package/src/team_agent/display/workspace.py +473 -0
- package/src/team_agent/launch/__init__.py +41 -0
- package/src/team_agent/launch/bootstrap.py +85 -0
- package/src/team_agent/launch/config.py +106 -0
- package/src/team_agent/launch/core.py +291 -0
- package/src/team_agent/launch/requirements.py +57 -0
- package/src/team_agent/leader/__init__.py +320 -0
- package/src/team_agent/lifecycle/__init__.py +5 -0
- package/src/team_agent/lifecycle/agents.py +226 -0
- package/src/team_agent/lifecycle/operations.py +321 -0
- package/src/team_agent/lifecycle/paste_buffer_hygiene.py +39 -0
- package/src/team_agent/lifecycle/start.py +363 -0
- package/src/team_agent/mcp_server/__init__.py +42 -0
- package/src/team_agent/mcp_server/__main__.py +7 -0
- package/src/team_agent/mcp_server/contracts.py +148 -0
- package/src/team_agent/mcp_server/normalize.py +257 -0
- package/src/team_agent/mcp_server/server.py +150 -0
- package/src/team_agent/mcp_server/tools.py +205 -0
- package/src/team_agent/message_store/__init__.py +23 -0
- package/src/team_agent/message_store/agent_health.py +109 -0
- package/src/team_agent/{message_store.py → message_store/core.py} +188 -245
- package/src/team_agent/message_store/result_watchers.py +102 -0
- package/src/team_agent/message_store/schema.py +266 -0
- package/src/team_agent/messaging/__init__.py +1 -0
- package/src/team_agent/messaging/activity_detector.py +190 -0
- package/src/team_agent/messaging/delivery.py +138 -0
- package/src/team_agent/messaging/deps.py +263 -0
- package/src/team_agent/messaging/idle_alerts.py +323 -0
- package/src/team_agent/messaging/internal_delivery.py +46 -0
- package/src/team_agent/messaging/leader.py +317 -0
- package/src/team_agent/messaging/leader_panes.py +343 -0
- package/src/team_agent/messaging/owner_bypass.py +29 -0
- package/src/team_agent/messaging/result_delivery.py +300 -0
- package/src/team_agent/messaging/results.py +456 -0
- package/src/team_agent/messaging/scheduler.py +428 -0
- package/src/team_agent/messaging/send.py +500 -0
- package/src/team_agent/messaging/session_drift.py +94 -0
- package/src/team_agent/messaging/tmux_io.py +337 -0
- package/src/team_agent/messaging/tmux_prompt.py +229 -0
- package/src/team_agent/orchestrator/__init__.py +376 -0
- package/src/team_agent/orchestrator/plan.py +122 -0
- package/src/team_agent/orchestrator/state.py +128 -0
- package/src/team_agent/profiles/__init__.py +82 -0
- package/src/team_agent/profiles/constants.py +19 -0
- package/src/team_agent/profiles/core.py +407 -0
- package/src/team_agent/profiles/helpers.py +69 -0
- package/src/team_agent/profiles/provider_env.py +188 -0
- package/src/team_agent/profiles/smoke.py +201 -0
- package/src/team_agent/provider_cli/__init__.py +43 -0
- package/src/team_agent/provider_cli/adapter.py +167 -0
- package/src/team_agent/provider_cli/base.py +48 -0
- package/src/team_agent/provider_cli/claude.py +457 -0
- package/src/team_agent/provider_cli/codex.py +319 -0
- package/src/team_agent/provider_cli/copilot.py +8 -0
- package/src/team_agent/provider_cli/fake.py +39 -0
- package/src/team_agent/provider_cli/gemini.py +95 -0
- package/src/team_agent/provider_cli/opencode.py +8 -0
- package/src/team_agent/provider_cli/prompt.py +62 -0
- package/src/team_agent/provider_cli/registry.py +18 -0
- package/src/team_agent/provider_cli/unsupported.py +32 -0
- package/src/team_agent/providers.py +67 -949
- package/src/team_agent/quality_gates.py +104 -0
- package/src/team_agent/restart/__init__.py +34 -0
- package/src/team_agent/restart/orchestration.py +328 -0
- package/src/team_agent/restart/selection.py +89 -0
- package/src/team_agent/restart/snapshot.py +70 -0
- package/src/team_agent/runtime.py +809 -5892
- package/src/team_agent/rust_core.py +22 -5
- package/src/team_agent/sessions/__init__.py +25 -0
- package/src/team_agent/sessions/capture.py +93 -0
- package/src/team_agent/sessions/inventory.py +44 -0
- package/src/team_agent/sessions/resume.py +135 -0
- package/src/team_agent/spec.py +3 -1
- package/src/team_agent/state.py +218 -4
- package/src/team_agent/status/__init__.py +63 -0
- package/src/team_agent/status/approvals.py +52 -0
- package/src/team_agent/status/compact.py +158 -0
- package/src/team_agent/status/constants.py +18 -0
- package/src/team_agent/status/inbox.py +28 -0
- package/src/team_agent/status/peek.py +117 -0
- package/src/team_agent/status/queries.py +168 -0
- package/src/team_agent/terminal.py +57 -0
- package/src/team_agent/cli.py +0 -858
- package/src/team_agent/mcp_server.py +0 -579
- package/src/team_agent/profiles.py +0 -882
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import urllib
|
|
4
|
+
|
|
5
|
+
from team_agent.profiles.constants import *
|
|
6
|
+
from team_agent.profiles.core import (
|
|
7
|
+
_agent_profile_dir,
|
|
8
|
+
_profile_lookup_dir,
|
|
9
|
+
_safe_init_command,
|
|
10
|
+
_safe_inspection_command,
|
|
11
|
+
compact_profile_check,
|
|
12
|
+
doctor_profile,
|
|
13
|
+
effective_model,
|
|
14
|
+
ensure_profile_secret_boundary,
|
|
15
|
+
ensure_profile_secret_boundary_dir,
|
|
16
|
+
gitignore_patterns,
|
|
17
|
+
init_profile,
|
|
18
|
+
known_profiles,
|
|
19
|
+
load_profile,
|
|
20
|
+
parse_env_file,
|
|
21
|
+
prepare_agent_profile_launch,
|
|
22
|
+
profile_dir,
|
|
23
|
+
required_profile_keys,
|
|
24
|
+
show_profile,
|
|
25
|
+
smoke_check_agent_profile,
|
|
26
|
+
validate_agent_profile,
|
|
27
|
+
)
|
|
28
|
+
from team_agent.profiles.helpers import (
|
|
29
|
+
_alternate_value,
|
|
30
|
+
_common_missing_values,
|
|
31
|
+
_format_profile_check_failure,
|
|
32
|
+
_is_secret_key,
|
|
33
|
+
_safe_codex_provider_id,
|
|
34
|
+
_safe_plain_profile_value,
|
|
35
|
+
_safe_profile_value,
|
|
36
|
+
_strip_env_value,
|
|
37
|
+
)
|
|
38
|
+
from team_agent.profiles.provider_env import (
|
|
39
|
+
_claude_project_keys,
|
|
40
|
+
_compatible_api_network_exports,
|
|
41
|
+
_compatible_claude_config_dir,
|
|
42
|
+
_ensure_compatible_claude_config,
|
|
43
|
+
_profile_proxy_mode,
|
|
44
|
+
_provider_command_overrides,
|
|
45
|
+
_provider_env_exports,
|
|
46
|
+
_provider_env_unsets,
|
|
47
|
+
_read_json_object,
|
|
48
|
+
_write_json,
|
|
49
|
+
_write_runtime_env_file,
|
|
50
|
+
ensure_compatible_claude_mcp_config,
|
|
51
|
+
)
|
|
52
|
+
from team_agent.profiles.smoke import (
|
|
53
|
+
_anthropic_compatible_smoke,
|
|
54
|
+
_anthropic_messages_url,
|
|
55
|
+
_http_json_smoke,
|
|
56
|
+
_openai_chat_url,
|
|
57
|
+
_openai_compatible_smoke,
|
|
58
|
+
_proxy_info_for_endpoint,
|
|
59
|
+
_proxy_url_from_env,
|
|
60
|
+
_redact_proxy_url,
|
|
61
|
+
_redacted_endpoint,
|
|
62
|
+
_temporary_profile_network_env,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
_REQUIRED_EXPORTS = (
|
|
66
|
+
"profile_dir",
|
|
67
|
+
"init_profile",
|
|
68
|
+
"doctor_profile",
|
|
69
|
+
"show_profile",
|
|
70
|
+
"load_profile",
|
|
71
|
+
"parse_env_file",
|
|
72
|
+
"validate_agent_profile",
|
|
73
|
+
"smoke_check_agent_profile",
|
|
74
|
+
"prepare_agent_profile_launch",
|
|
75
|
+
"effective_model",
|
|
76
|
+
"ensure_compatible_claude_mcp_config",
|
|
77
|
+
)
|
|
78
|
+
for _name in _REQUIRED_EXPORTS:
|
|
79
|
+
if _name not in globals():
|
|
80
|
+
raise ImportError(f"team_agent.profiles missing export: {_name}")
|
|
81
|
+
|
|
82
|
+
__all__ = [name for name in globals() if not name.startswith("__")]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
AUTH_MODES = {"subscription", "official_api", "compatible_api"}
|
|
7
|
+
PROFILE_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
8
|
+
SECRET_KEYS = {"API_KEY", "AUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "GEMINI_API_KEY"}
|
|
9
|
+
PROXY_ENV_KEYS = ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy", "NO_PROXY", "no_proxy")
|
|
10
|
+
CA_ENV_KEYS = ("NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE")
|
|
11
|
+
COMPATIBLE_API_NETWORK_ENV_KEYS = PROXY_ENV_KEYS + CA_ENV_KEYS
|
|
12
|
+
PROFILE_SECRET_BOUNDARY_TEXT = """# Team Agent Profile Secret Boundary
|
|
13
|
+
|
|
14
|
+
Do not read, print, grep, cat, sed, copy, summarize, or open raw `*.env` files in this directory.
|
|
15
|
+
These files may contain API keys or auth tokens and must stay out of agent context.
|
|
16
|
+
|
|
17
|
+
Use `team-agent profile show <name> --workspace . --json` or `team-agent profile doctor <name> --workspace . --json`
|
|
18
|
+
for redacted status. If a required value is missing, ask the human user to edit the local profile file.
|
|
19
|
+
"""
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from team_agent.profiles.constants import AUTH_MODES, PROFILE_KEY_RE, PROFILE_SECRET_BOUNDARY_TEXT
|
|
7
|
+
from team_agent.profiles.helpers import (
|
|
8
|
+
_alternate_value,
|
|
9
|
+
_common_missing_values,
|
|
10
|
+
_format_profile_check_failure,
|
|
11
|
+
_is_secret_key,
|
|
12
|
+
_safe_profile_value,
|
|
13
|
+
_strip_env_value,
|
|
14
|
+
)
|
|
15
|
+
from team_agent.rust_core import redact_text
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def profile_dir(workspace: Path) -> Path:
|
|
19
|
+
return workspace / ".team" / "current" / "profiles"
|
|
20
|
+
|
|
21
|
+
def _profile_lookup_dir(workspace: Path, profiles_dir: Path | str | None = None) -> Path:
|
|
22
|
+
return Path(profiles_dir).resolve() if profiles_dir else profile_dir(workspace)
|
|
23
|
+
|
|
24
|
+
def _agent_profile_dir(agent: dict[str, Any]) -> Path | None:
|
|
25
|
+
value = agent.get("_profile_dir")
|
|
26
|
+
return Path(str(value)).resolve() if value else None
|
|
27
|
+
|
|
28
|
+
def _safe_inspection_command(name: str, profiles_dir: Path | str | None = None) -> str:
|
|
29
|
+
if profiles_dir:
|
|
30
|
+
return f"team-agent profile show {name} --team {Path(profiles_dir).resolve().parent} --json"
|
|
31
|
+
return f"team-agent profile show {name} --workspace . --json"
|
|
32
|
+
|
|
33
|
+
def _safe_init_command(name: str, auth_mode: str, profiles_dir: Path | str | None = None) -> str:
|
|
34
|
+
if profiles_dir:
|
|
35
|
+
return f"team-agent profile init {name} --auth-mode {auth_mode} --team {Path(profiles_dir).resolve().parent}"
|
|
36
|
+
return f"team-agent profile init {name} --auth-mode {auth_mode}"
|
|
37
|
+
|
|
38
|
+
def ensure_profile_secret_boundary_dir(directory: Path) -> Path:
|
|
39
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
first_path = directory / "AGENTS.md"
|
|
41
|
+
for filename in ("AGENTS.md", "CLAUDE.md"):
|
|
42
|
+
path = directory / filename
|
|
43
|
+
if not path.exists():
|
|
44
|
+
path.write_text(PROFILE_SECRET_BOUNDARY_TEXT, encoding="utf-8")
|
|
45
|
+
return first_path
|
|
46
|
+
|
|
47
|
+
def ensure_profile_secret_boundary(workspace: Path) -> Path:
|
|
48
|
+
directory = profile_dir(workspace)
|
|
49
|
+
return ensure_profile_secret_boundary_dir(directory)
|
|
50
|
+
|
|
51
|
+
def init_profile(workspace: Path, name: str, auth_mode: str, profiles_dir: Path | str | None = None) -> dict[str, Any]:
|
|
52
|
+
if auth_mode not in AUTH_MODES:
|
|
53
|
+
raise ValueError(f"unknown auth_mode: {auth_mode}")
|
|
54
|
+
directory = _profile_lookup_dir(workspace, profiles_dir)
|
|
55
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
ensure_profile_secret_boundary_dir(directory)
|
|
57
|
+
path = directory / f"{name}.env"
|
|
58
|
+
template_path = directory / f"{name}.example.env"
|
|
59
|
+
if auth_mode == "subscription":
|
|
60
|
+
body = f"AUTH_MODE=subscription\nPROFILE_NAME={name}\n"
|
|
61
|
+
elif auth_mode == "official_api":
|
|
62
|
+
body = f"AUTH_MODE=official_api\nPROFILE_NAME={name}\nAPI_KEY=\nMODEL=\n"
|
|
63
|
+
else:
|
|
64
|
+
body = f"AUTH_MODE=compatible_api\nPROFILE_NAME={name}\nBASE_URL=\nAPI_KEY=\nMODEL=\n"
|
|
65
|
+
created_profile = False
|
|
66
|
+
created_template = False
|
|
67
|
+
if not path.exists():
|
|
68
|
+
path.write_text(body, encoding="utf-8")
|
|
69
|
+
try:
|
|
70
|
+
path.chmod(0o600)
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
created_profile = True
|
|
74
|
+
if not template_path.exists():
|
|
75
|
+
template_path.write_text(body, encoding="utf-8")
|
|
76
|
+
created_template = True
|
|
77
|
+
safe_inspection = _safe_inspection_command(name, directory if profiles_dir else None)
|
|
78
|
+
return {
|
|
79
|
+
"ok": True,
|
|
80
|
+
"profile": name,
|
|
81
|
+
"auth_mode": auth_mode,
|
|
82
|
+
"path": str(path),
|
|
83
|
+
"template_path": str(template_path),
|
|
84
|
+
"created_profile": created_profile,
|
|
85
|
+
"created_template": created_template,
|
|
86
|
+
"secret_written": False,
|
|
87
|
+
"safe_inspection_command": safe_inspection,
|
|
88
|
+
"raw_file_read_allowed_for_agents": False,
|
|
89
|
+
"instruction": (
|
|
90
|
+
f"Fill {path} locally; do not paste API keys into chat or role docs. "
|
|
91
|
+
f"Agents must inspect this profile only with `{safe_inspection}`."
|
|
92
|
+
),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def doctor_profile(workspace: Path, name: str, profiles_dir: Path | str | None = None) -> dict[str, Any]:
|
|
96
|
+
directory = _profile_lookup_dir(workspace, profiles_dir)
|
|
97
|
+
loaded = load_profile(workspace, name, directory)
|
|
98
|
+
real_path = directory / f"{name}.env"
|
|
99
|
+
example_path = directory / f"{name}.example.env"
|
|
100
|
+
exists = bool(loaded.get("exists"))
|
|
101
|
+
values = loaded.get("values", {})
|
|
102
|
+
keys_present = [key for key, value in values.items() if value]
|
|
103
|
+
auth_mode = values.get("AUTH_MODE")
|
|
104
|
+
return {
|
|
105
|
+
"ok": exists,
|
|
106
|
+
"profile": name,
|
|
107
|
+
"path": str(loaded.get("path") or real_path),
|
|
108
|
+
"template_path": str(example_path),
|
|
109
|
+
"credential_present": real_path.exists(),
|
|
110
|
+
"auth_mode": auth_mode,
|
|
111
|
+
"keys_present": sorted(keys_present),
|
|
112
|
+
"secret_keys_present": sorted(key for key in keys_present if _is_secret_key(key)),
|
|
113
|
+
"redaction_engine": redact_text(" ".join(f"{key}=present" for key in keys_present)).get("engine"),
|
|
114
|
+
"secret_values_printed": False,
|
|
115
|
+
"safe_for_agent_context": True,
|
|
116
|
+
"raw_file_read_allowed_for_agents": False,
|
|
117
|
+
"safe_inspection_command": _safe_inspection_command(name, directory if profiles_dir else None),
|
|
118
|
+
"suggestion": None if exists else f"Run {_safe_init_command(name, 'subscription', directory if profiles_dir else None)}.",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
def show_profile(workspace: Path, name: str, profiles_dir: Path | str | None = None) -> dict[str, Any]:
|
|
122
|
+
loaded = load_profile(workspace, name, profiles_dir)
|
|
123
|
+
values: dict[str, str] = loaded.get("values", {})
|
|
124
|
+
auth_mode = values.get("AUTH_MODE")
|
|
125
|
+
safe_values = {
|
|
126
|
+
key: _safe_profile_value(key, value)
|
|
127
|
+
for key, value in sorted(values.items())
|
|
128
|
+
}
|
|
129
|
+
missing = _common_missing_values(auth_mode, values)
|
|
130
|
+
return {
|
|
131
|
+
"ok": bool(loaded.get("exists")),
|
|
132
|
+
"profile": name,
|
|
133
|
+
"credential_present": bool(loaded.get("credential_present")),
|
|
134
|
+
"auth_mode": auth_mode,
|
|
135
|
+
"values": safe_values,
|
|
136
|
+
"keys_present": sorted(key for key, value in values.items() if value),
|
|
137
|
+
"secret_keys_present": sorted(key for key, value in values.items() if value and _is_secret_key(key)),
|
|
138
|
+
"missing_common": missing,
|
|
139
|
+
"safe_for_agent_context": True,
|
|
140
|
+
"secret_values_printed": False,
|
|
141
|
+
"raw_file_read_allowed_for_agents": False,
|
|
142
|
+
"instruction": (
|
|
143
|
+
"Use this redacted output for diagnostics. Do not read .team/*/profiles/*.env "
|
|
144
|
+
"or .team/runtime/provider-env/*.env into agent context."
|
|
145
|
+
),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
def known_profiles(team_dir: Path) -> set[str]:
|
|
149
|
+
directory = team_dir / "profiles"
|
|
150
|
+
if not directory.exists():
|
|
151
|
+
return set()
|
|
152
|
+
names = set()
|
|
153
|
+
for path in directory.glob("*.env"):
|
|
154
|
+
names.add(path.name.removesuffix(".env").removesuffix(".example"))
|
|
155
|
+
for path in directory.glob("*.example.env"):
|
|
156
|
+
names.add(path.name.removesuffix(".example.env"))
|
|
157
|
+
return names
|
|
158
|
+
|
|
159
|
+
def gitignore_patterns() -> list[str]:
|
|
160
|
+
return [".team/*/profiles/*.env", "!.team/*/profiles/*.example.env", ".team/runtime/provider-env/*.env"]
|
|
161
|
+
|
|
162
|
+
def load_profile(workspace: Path, name: str, profiles_dir: Path | str | None = None) -> dict[str, Any]:
|
|
163
|
+
directory = _profile_lookup_dir(workspace, profiles_dir)
|
|
164
|
+
real_path = directory / f"{name}.env"
|
|
165
|
+
example_path = directory / f"{name}.example.env"
|
|
166
|
+
path = real_path if real_path.exists() else example_path
|
|
167
|
+
if not path.exists():
|
|
168
|
+
return {
|
|
169
|
+
"exists": False,
|
|
170
|
+
"profile": name,
|
|
171
|
+
"path": str(real_path),
|
|
172
|
+
"template_path": str(example_path),
|
|
173
|
+
"credential_present": False,
|
|
174
|
+
"values": {},
|
|
175
|
+
}
|
|
176
|
+
values = parse_env_file(path)
|
|
177
|
+
return {
|
|
178
|
+
"exists": True,
|
|
179
|
+
"profile": name,
|
|
180
|
+
"path": str(path),
|
|
181
|
+
"template_path": str(example_path),
|
|
182
|
+
"credential_present": real_path.exists(),
|
|
183
|
+
"values": values,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
def parse_env_file(path: Path) -> dict[str, str]:
|
|
187
|
+
values: dict[str, str] = {}
|
|
188
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
189
|
+
line = raw.strip()
|
|
190
|
+
if not line or line.startswith("#"):
|
|
191
|
+
continue
|
|
192
|
+
if line.startswith("export "):
|
|
193
|
+
line = line[len("export ") :].strip()
|
|
194
|
+
if "=" not in line:
|
|
195
|
+
continue
|
|
196
|
+
key, value = line.split("=", 1)
|
|
197
|
+
key = key.strip()
|
|
198
|
+
if not PROFILE_KEY_RE.fullmatch(key):
|
|
199
|
+
continue
|
|
200
|
+
values[key] = _strip_env_value(value.strip())
|
|
201
|
+
return values
|
|
202
|
+
|
|
203
|
+
def validate_agent_profile(workspace: Path, agent: dict[str, Any]) -> dict[str, Any]:
|
|
204
|
+
profile = agent.get("profile")
|
|
205
|
+
auth_mode = str(agent.get("auth_mode") or "subscription")
|
|
206
|
+
profiles_dir = _agent_profile_dir(agent)
|
|
207
|
+
result = {
|
|
208
|
+
"ok": True,
|
|
209
|
+
"agent_id": agent.get("id"),
|
|
210
|
+
"provider": agent.get("provider"),
|
|
211
|
+
"profile": profile,
|
|
212
|
+
"auth_mode": auth_mode,
|
|
213
|
+
"path": None,
|
|
214
|
+
"credential_present": False,
|
|
215
|
+
"keys_present": [],
|
|
216
|
+
"missing_required": [],
|
|
217
|
+
"secret_values_printed": False,
|
|
218
|
+
}
|
|
219
|
+
if not profile:
|
|
220
|
+
return result
|
|
221
|
+
loaded = load_profile(workspace, str(profile), profiles_dir)
|
|
222
|
+
result["path"] = loaded.get("path")
|
|
223
|
+
result["credential_present"] = bool(loaded.get("credential_present"))
|
|
224
|
+
if not loaded.get("exists"):
|
|
225
|
+
result["ok"] = False
|
|
226
|
+
result["reason"] = "profile_missing"
|
|
227
|
+
result["suggestion"] = (
|
|
228
|
+
f"Run {_safe_init_command(str(profile), auth_mode, profiles_dir)}, then ask the human user "
|
|
229
|
+
"to fill the generated local profile file."
|
|
230
|
+
)
|
|
231
|
+
return result
|
|
232
|
+
values: dict[str, str] = loaded.get("values", {})
|
|
233
|
+
file_auth_mode = values.get("AUTH_MODE")
|
|
234
|
+
if file_auth_mode and file_auth_mode != auth_mode:
|
|
235
|
+
result["ok"] = False
|
|
236
|
+
result["reason"] = "auth_mode_mismatch"
|
|
237
|
+
result["file_auth_mode"] = file_auth_mode
|
|
238
|
+
result["suggestion"] = (
|
|
239
|
+
f"Set AUTH_MODE={auth_mode} in the local profile or update the role doc. "
|
|
240
|
+
f"Inspect safely with `{_safe_inspection_command(str(profile), profiles_dir)}`."
|
|
241
|
+
)
|
|
242
|
+
return result
|
|
243
|
+
keys_present = sorted(key for key, value in values.items() if value)
|
|
244
|
+
result["keys_present"] = keys_present
|
|
245
|
+
role_model = str(agent.get("model") or "") or None
|
|
246
|
+
profile_model = values.get("MODEL") or values.get("ANTHROPIC_MODEL")
|
|
247
|
+
effective = role_model or profile_model
|
|
248
|
+
result["effective_model"] = effective
|
|
249
|
+
result["model_source"] = "role" if role_model else ("profile" if profile_model else None)
|
|
250
|
+
if auth_mode == "compatible_api" and role_model and profile_model and role_model != profile_model:
|
|
251
|
+
result["ok"] = False
|
|
252
|
+
result["reason"] = "model_mismatch"
|
|
253
|
+
result["suggestion"] = (
|
|
254
|
+
f"Role model {role_model!r} does not match the profile MODEL value; "
|
|
255
|
+
f"inspect safely with `{_safe_inspection_command(str(profile), profiles_dir)}`, "
|
|
256
|
+
"then remove model from the role doc or make both values identical."
|
|
257
|
+
)
|
|
258
|
+
return result
|
|
259
|
+
required = required_profile_keys(str(agent.get("provider") or ""), auth_mode)
|
|
260
|
+
missing = [key for key in required if not values.get(key) and not _alternate_value(values, key)]
|
|
261
|
+
if auth_mode == "compatible_api" and not effective:
|
|
262
|
+
missing.append("MODEL")
|
|
263
|
+
result["missing_required"] = missing
|
|
264
|
+
if missing:
|
|
265
|
+
result["ok"] = False
|
|
266
|
+
result["reason"] = "profile_required_values_missing"
|
|
267
|
+
result["suggestion"] = (
|
|
268
|
+
f"Ask the human user to fill {', '.join(missing)} in local profile {profile}; "
|
|
269
|
+
f"inspect safely with `{_safe_inspection_command(str(profile), profiles_dir)}`. "
|
|
270
|
+
"Do not paste API keys into chat."
|
|
271
|
+
)
|
|
272
|
+
return result
|
|
273
|
+
|
|
274
|
+
def smoke_check_agent_profile(workspace: Path, agent: dict[str, Any], timeout: float = 8.0) -> dict[str, Any]:
|
|
275
|
+
auth_mode = str(agent.get("auth_mode") or "subscription")
|
|
276
|
+
profile = agent.get("profile")
|
|
277
|
+
result = {
|
|
278
|
+
"ok": True,
|
|
279
|
+
"agent_id": agent.get("id"),
|
|
280
|
+
"provider": agent.get("provider"),
|
|
281
|
+
"profile": profile,
|
|
282
|
+
"auth_mode": auth_mode,
|
|
283
|
+
"status": "not_required",
|
|
284
|
+
"secret_values_printed": False,
|
|
285
|
+
}
|
|
286
|
+
if auth_mode != "compatible_api" or not profile:
|
|
287
|
+
return result
|
|
288
|
+
loaded = load_profile(workspace, str(profile), _agent_profile_dir(agent))
|
|
289
|
+
values: dict[str, str] = loaded.get("values", {})
|
|
290
|
+
if str(values.get("PROFILE_SMOKE", "true")).lower() in {"0", "false", "no", "off"}:
|
|
291
|
+
result["status"] = "skipped_by_profile"
|
|
292
|
+
return result
|
|
293
|
+
validation = validate_agent_profile(workspace, agent)
|
|
294
|
+
if not validation.get("ok"):
|
|
295
|
+
return {**result, **validation, "ok": False, "status": "profile_invalid"}
|
|
296
|
+
provider = str(agent.get("provider") or "")
|
|
297
|
+
model = effective_model(agent, workspace)
|
|
298
|
+
from team_agent.profiles.smoke import _anthropic_compatible_smoke, _openai_compatible_smoke
|
|
299
|
+
|
|
300
|
+
if provider in {"claude", "claude_code"}:
|
|
301
|
+
return _anthropic_compatible_smoke(values, model, result, timeout)
|
|
302
|
+
if provider == "codex":
|
|
303
|
+
return _openai_compatible_smoke(values, model, result, timeout)
|
|
304
|
+
result["status"] = "unsupported_provider_smoke_skipped"
|
|
305
|
+
return result
|
|
306
|
+
|
|
307
|
+
def required_profile_keys(provider: str, auth_mode: str) -> list[str]:
|
|
308
|
+
if auth_mode == "subscription":
|
|
309
|
+
return []
|
|
310
|
+
if provider in {"claude", "claude_code"}:
|
|
311
|
+
if auth_mode == "official_api":
|
|
312
|
+
return ["API_KEY"]
|
|
313
|
+
if auth_mode == "compatible_api":
|
|
314
|
+
return ["BASE_URL", "API_KEY"]
|
|
315
|
+
if provider == "codex":
|
|
316
|
+
if auth_mode == "official_api":
|
|
317
|
+
return ["API_KEY"]
|
|
318
|
+
if auth_mode == "compatible_api":
|
|
319
|
+
return ["BASE_URL", "API_KEY"]
|
|
320
|
+
if provider == "gemini_cli" and auth_mode in {"official_api", "compatible_api"}:
|
|
321
|
+
return ["API_KEY"]
|
|
322
|
+
return []
|
|
323
|
+
|
|
324
|
+
def compact_profile_check(check: dict[str, Any]) -> dict[str, Any]:
|
|
325
|
+
keys = [
|
|
326
|
+
"agent_id",
|
|
327
|
+
"provider",
|
|
328
|
+
"profile",
|
|
329
|
+
"auth_mode",
|
|
330
|
+
"ok",
|
|
331
|
+
"status",
|
|
332
|
+
"reason",
|
|
333
|
+
"credential_present",
|
|
334
|
+
"keys_present",
|
|
335
|
+
"missing_required",
|
|
336
|
+
"effective_model",
|
|
337
|
+
"model_source",
|
|
338
|
+
"suggestion",
|
|
339
|
+
"http_status",
|
|
340
|
+
"endpoint",
|
|
341
|
+
"error",
|
|
342
|
+
"proxy_configured",
|
|
343
|
+
"proxy_scheme",
|
|
344
|
+
"proxy_url",
|
|
345
|
+
"proxy_source",
|
|
346
|
+
"proxy_mode",
|
|
347
|
+
]
|
|
348
|
+
return {key: check.get(key) for key in keys if key in check}
|
|
349
|
+
|
|
350
|
+
def prepare_agent_profile_launch(workspace: Path, agent: dict[str, Any]) -> dict[str, Any] | None:
|
|
351
|
+
profile = agent.get("profile")
|
|
352
|
+
if not profile:
|
|
353
|
+
return None
|
|
354
|
+
check = validate_agent_profile(workspace, agent)
|
|
355
|
+
if not check.get("ok"):
|
|
356
|
+
raise ValueError(_format_profile_check_failure(check))
|
|
357
|
+
loaded = load_profile(workspace, str(profile), _agent_profile_dir(agent))
|
|
358
|
+
values: dict[str, str] = loaded.get("values", {})
|
|
359
|
+
auth_mode = str(agent.get("auth_mode") or values.get("AUTH_MODE") or "subscription")
|
|
360
|
+
provider = str(agent.get("provider") or "")
|
|
361
|
+
from team_agent.profiles.provider_env import (
|
|
362
|
+
_compatible_api_network_exports,
|
|
363
|
+
_compatible_claude_config_dir,
|
|
364
|
+
_profile_proxy_mode,
|
|
365
|
+
_provider_command_overrides,
|
|
366
|
+
_provider_env_exports,
|
|
367
|
+
_provider_env_unsets,
|
|
368
|
+
_write_runtime_env_file,
|
|
369
|
+
)
|
|
370
|
+
from team_agent.profiles.constants import COMPATIBLE_API_NETWORK_ENV_KEYS
|
|
371
|
+
|
|
372
|
+
exports = _provider_env_exports(provider, auth_mode, values)
|
|
373
|
+
claude_projects_root = None
|
|
374
|
+
if provider in {"claude", "claude_code"} and auth_mode == "compatible_api":
|
|
375
|
+
claude_config_dir = _compatible_claude_config_dir(workspace, str(agent["id"]))
|
|
376
|
+
exports["CLAUDE_CONFIG_DIR"] = str(claude_config_dir)
|
|
377
|
+
claude_projects_root = str(claude_config_dir / "projects")
|
|
378
|
+
exports.update(_compatible_api_network_exports(auth_mode, values))
|
|
379
|
+
unsets = _provider_env_unsets(provider, auth_mode)
|
|
380
|
+
if auth_mode == "compatible_api" and _profile_proxy_mode(values) == "direct":
|
|
381
|
+
unsets.extend(COMPATIBLE_API_NETWORK_ENV_KEYS)
|
|
382
|
+
exports = {key: value for key, value in exports.items() if key not in COMPATIBLE_API_NETWORK_ENV_KEYS}
|
|
383
|
+
overrides = _provider_command_overrides(str(agent.get("provider") or ""), auth_mode, values, agent)
|
|
384
|
+
env_file = None
|
|
385
|
+
if exports or unsets:
|
|
386
|
+
env_file = _write_runtime_env_file(workspace, str(agent["id"]), exports, unsets)
|
|
387
|
+
return {
|
|
388
|
+
"profile": str(profile),
|
|
389
|
+
"auth_mode": auth_mode,
|
|
390
|
+
"path": loaded.get("path"),
|
|
391
|
+
"credential_present": loaded.get("credential_present"),
|
|
392
|
+
"env_file": str(env_file) if env_file else None,
|
|
393
|
+
"env_keys": sorted(exports),
|
|
394
|
+
"env_unset": sorted(unsets),
|
|
395
|
+
"claude_projects_root": claude_projects_root,
|
|
396
|
+
"command_overrides": overrides,
|
|
397
|
+
"secret_values_printed": False,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
def effective_model(agent: dict[str, Any], workspace: Path | None = None) -> str | None:
|
|
401
|
+
if agent.get("model"):
|
|
402
|
+
return str(agent["model"])
|
|
403
|
+
if workspace is None or not agent.get("profile"):
|
|
404
|
+
return None
|
|
405
|
+
loaded = load_profile(workspace, str(agent["profile"]), _agent_profile_dir(agent))
|
|
406
|
+
values: dict[str, str] = loaded.get("values", {})
|
|
407
|
+
return values.get("MODEL") or values.get("ANTHROPIC_MODEL")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import urllib.parse
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from team_agent.rust_core import redact_text
|
|
8
|
+
from team_agent.profiles.constants import SECRET_KEYS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _format_profile_check_failure(check: dict[str, Any]) -> str:
|
|
12
|
+
agent_id = check.get("agent_id") or "unknown"
|
|
13
|
+
profile = check.get("profile") or "-"
|
|
14
|
+
reason = check.get("reason") or "profile_invalid"
|
|
15
|
+
suggestion = check.get("suggestion") or f"Inspect safely with `team-agent profile show {profile} --workspace . --json`."
|
|
16
|
+
return f"profile validation failed for {agent_id} profile {profile}: {reason}. {suggestion}"
|
|
17
|
+
|
|
18
|
+
def _alternate_value(values: dict[str, str], key: str) -> str | None:
|
|
19
|
+
alternates = {
|
|
20
|
+
"BASE_URL": ["ANTHROPIC_BASE_URL", "OPENAI_BASE_URL"],
|
|
21
|
+
"API_KEY": ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "AUTH_TOKEN", "OPENAI_API_KEY", "GEMINI_API_KEY"],
|
|
22
|
+
}
|
|
23
|
+
for candidate in alternates.get(key, []):
|
|
24
|
+
if values.get(candidate):
|
|
25
|
+
return values[candidate]
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
def _strip_env_value(value: str) -> str:
|
|
29
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
30
|
+
return value[1:-1]
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
def _safe_codex_provider_id(value: str) -> bool:
|
|
34
|
+
return re.fullmatch(r"[A-Za-z0-9_-]+", value) is not None
|
|
35
|
+
|
|
36
|
+
def _is_secret_key(key: str) -> bool:
|
|
37
|
+
upper = key.upper()
|
|
38
|
+
return upper in SECRET_KEYS or "KEY" in upper or "TOKEN" in upper or "SECRET" in upper
|
|
39
|
+
|
|
40
|
+
def _safe_profile_value(key: str, value: str) -> dict[str, Any]:
|
|
41
|
+
if _is_secret_key(key):
|
|
42
|
+
return {"present": bool(value), "redacted": True}
|
|
43
|
+
return {"present": bool(value), "redacted": False, "value": _safe_plain_profile_value(value)}
|
|
44
|
+
|
|
45
|
+
def _common_missing_values(auth_mode: str | None, values: dict[str, str]) -> list[str]:
|
|
46
|
+
if auth_mode == "compatible_api":
|
|
47
|
+
required = ["BASE_URL", "API_KEY", "MODEL"]
|
|
48
|
+
elif auth_mode == "official_api":
|
|
49
|
+
required = ["API_KEY"]
|
|
50
|
+
else:
|
|
51
|
+
required = []
|
|
52
|
+
missing = []
|
|
53
|
+
for key in required:
|
|
54
|
+
if key == "MODEL":
|
|
55
|
+
if not (values.get("MODEL") or values.get("ANTHROPIC_MODEL")):
|
|
56
|
+
missing.append(key)
|
|
57
|
+
continue
|
|
58
|
+
if not values.get(key) and not _alternate_value(values, key):
|
|
59
|
+
missing.append(key)
|
|
60
|
+
return missing
|
|
61
|
+
|
|
62
|
+
def _safe_plain_profile_value(value: str) -> str:
|
|
63
|
+
parsed = urllib.parse.urlparse(value)
|
|
64
|
+
if parsed.scheme and parsed.netloc:
|
|
65
|
+
host = parsed.hostname or ""
|
|
66
|
+
port = f":{parsed.port}" if parsed.port else ""
|
|
67
|
+
auth = "[redacted]@" if parsed.username or parsed.password else ""
|
|
68
|
+
value = urllib.parse.urlunparse((parsed.scheme, f"{auth}{host}{port}", parsed.path, "", "", ""))
|
|
69
|
+
return str(redact_text(value).get("text") or "")
|