@team-agent/installer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +201 -0
- package/crates/team-agent-core/Cargo.toml +12 -0
- package/crates/team-agent-core/src/lib.rs +287 -0
- package/crates/team-agent-core/src/main.rs +152 -0
- package/examples/team.spec.yaml +206 -0
- package/examples/team_state.md +35 -0
- package/npm/install.mjs +266 -0
- package/package.json +28 -0
- package/pyproject.toml +18 -0
- package/schemas/result-envelope.schema.json +76 -0
- package/schemas/team.schema.json +241 -0
- package/scripts/install.py +88 -0
- package/scripts/run_regression_tests.py +79 -0
- package/skills/team-agent/SKILL.md +173 -0
- package/src/team_agent/__init__.py +3 -0
- package/src/team_agent/__main__.py +5 -0
- package/src/team_agent/cli.py +857 -0
- package/src/team_agent/compiler.py +269 -0
- package/src/team_agent/coordinator.py +62 -0
- package/src/team_agent/errors.py +10 -0
- package/src/team_agent/events.py +37 -0
- package/src/team_agent/fake_worker.py +80 -0
- package/src/team_agent/mcp_server.py +579 -0
- package/src/team_agent/message_store.py +497 -0
- package/src/team_agent/paths.py +45 -0
- package/src/team_agent/permissions.py +123 -0
- package/src/team_agent/profiles.py +882 -0
- package/src/team_agent/providers.py +1045 -0
- package/src/team_agent/routing.py +84 -0
- package/src/team_agent/runtime.py +5213 -0
- package/src/team_agent/rust_core.py +156 -0
- package/src/team_agent/simple_yaml.py +236 -0
- package/src/team_agent/spec.py +308 -0
- package/src/team_agent/state.py +112 -0
- package/src/team_agent/task_graph.py +80 -0
- package/templates/team_state.md +32 -0
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from team_agent.rust_core import redact_text
|
|
15
|
+
|
|
16
|
+
AUTH_MODES = {"subscription", "official_api", "compatible_api"}
|
|
17
|
+
PROFILE_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
18
|
+
SECRET_KEYS = {"API_KEY", "AUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "GEMINI_API_KEY"}
|
|
19
|
+
PROXY_ENV_KEYS = ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy", "NO_PROXY", "no_proxy")
|
|
20
|
+
CA_ENV_KEYS = ("NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE")
|
|
21
|
+
COMPATIBLE_API_NETWORK_ENV_KEYS = PROXY_ENV_KEYS + CA_ENV_KEYS
|
|
22
|
+
PROFILE_SECRET_BOUNDARY_TEXT = """# Team Agent Profile Secret Boundary
|
|
23
|
+
|
|
24
|
+
Do not read, print, grep, cat, sed, copy, summarize, or open raw `*.env` files in this directory.
|
|
25
|
+
These files may contain API keys or auth tokens and must stay out of agent context.
|
|
26
|
+
|
|
27
|
+
Use `team-agent profile show <name> --workspace . --json` or `team-agent profile doctor <name> --workspace . --json`
|
|
28
|
+
for redacted status. If a required value is missing, ask the human user to edit the local profile file.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def profile_dir(workspace: Path) -> Path:
|
|
33
|
+
return workspace / ".team" / "current" / "profiles"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _profile_lookup_dir(workspace: Path, profiles_dir: Path | str | None = None) -> Path:
|
|
37
|
+
return Path(profiles_dir).resolve() if profiles_dir else profile_dir(workspace)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _agent_profile_dir(agent: dict[str, Any]) -> Path | None:
|
|
41
|
+
value = agent.get("_profile_dir")
|
|
42
|
+
return Path(str(value)).resolve() if value else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _safe_inspection_command(name: str, profiles_dir: Path | str | None = None) -> str:
|
|
46
|
+
if profiles_dir:
|
|
47
|
+
return f"team-agent profile show {name} --team {Path(profiles_dir).resolve().parent} --json"
|
|
48
|
+
return f"team-agent profile show {name} --workspace . --json"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _safe_init_command(name: str, auth_mode: str, profiles_dir: Path | str | None = None) -> str:
|
|
52
|
+
if profiles_dir:
|
|
53
|
+
return f"team-agent profile init {name} --auth-mode {auth_mode} --team {Path(profiles_dir).resolve().parent}"
|
|
54
|
+
return f"team-agent profile init {name} --auth-mode {auth_mode}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def ensure_profile_secret_boundary_dir(directory: Path) -> Path:
|
|
58
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
first_path = directory / "AGENTS.md"
|
|
60
|
+
for filename in ("AGENTS.md", "CLAUDE.md"):
|
|
61
|
+
path = directory / filename
|
|
62
|
+
if not path.exists():
|
|
63
|
+
path.write_text(PROFILE_SECRET_BOUNDARY_TEXT, encoding="utf-8")
|
|
64
|
+
return first_path
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def ensure_profile_secret_boundary(workspace: Path) -> Path:
|
|
68
|
+
directory = profile_dir(workspace)
|
|
69
|
+
return ensure_profile_secret_boundary_dir(directory)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def init_profile(workspace: Path, name: str, auth_mode: str, profiles_dir: Path | str | None = None) -> dict[str, Any]:
|
|
73
|
+
if auth_mode not in AUTH_MODES:
|
|
74
|
+
raise ValueError(f"unknown auth_mode: {auth_mode}")
|
|
75
|
+
directory = _profile_lookup_dir(workspace, profiles_dir)
|
|
76
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
ensure_profile_secret_boundary_dir(directory)
|
|
78
|
+
path = directory / f"{name}.env"
|
|
79
|
+
template_path = directory / f"{name}.example.env"
|
|
80
|
+
if auth_mode == "subscription":
|
|
81
|
+
body = f"AUTH_MODE=subscription\nPROFILE_NAME={name}\n"
|
|
82
|
+
elif auth_mode == "official_api":
|
|
83
|
+
body = f"AUTH_MODE=official_api\nPROFILE_NAME={name}\nAPI_KEY=\nMODEL=\n"
|
|
84
|
+
else:
|
|
85
|
+
body = f"AUTH_MODE=compatible_api\nPROFILE_NAME={name}\nBASE_URL=\nAPI_KEY=\nMODEL=\n"
|
|
86
|
+
created_profile = False
|
|
87
|
+
created_template = False
|
|
88
|
+
if not path.exists():
|
|
89
|
+
path.write_text(body, encoding="utf-8")
|
|
90
|
+
try:
|
|
91
|
+
path.chmod(0o600)
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
created_profile = True
|
|
95
|
+
if not template_path.exists():
|
|
96
|
+
template_path.write_text(body, encoding="utf-8")
|
|
97
|
+
created_template = True
|
|
98
|
+
safe_inspection = _safe_inspection_command(name, directory if profiles_dir else None)
|
|
99
|
+
return {
|
|
100
|
+
"ok": True,
|
|
101
|
+
"profile": name,
|
|
102
|
+
"auth_mode": auth_mode,
|
|
103
|
+
"path": str(path),
|
|
104
|
+
"template_path": str(template_path),
|
|
105
|
+
"created_profile": created_profile,
|
|
106
|
+
"created_template": created_template,
|
|
107
|
+
"secret_written": False,
|
|
108
|
+
"safe_inspection_command": safe_inspection,
|
|
109
|
+
"raw_file_read_allowed_for_agents": False,
|
|
110
|
+
"instruction": (
|
|
111
|
+
f"Fill {path} locally; do not paste API keys into chat or role docs. "
|
|
112
|
+
f"Agents must inspect this profile only with `{safe_inspection}`."
|
|
113
|
+
),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def doctor_profile(workspace: Path, name: str, profiles_dir: Path | str | None = None) -> dict[str, Any]:
|
|
118
|
+
directory = _profile_lookup_dir(workspace, profiles_dir)
|
|
119
|
+
loaded = load_profile(workspace, name, directory)
|
|
120
|
+
real_path = directory / f"{name}.env"
|
|
121
|
+
example_path = directory / f"{name}.example.env"
|
|
122
|
+
exists = bool(loaded.get("exists"))
|
|
123
|
+
values = loaded.get("values", {})
|
|
124
|
+
keys_present = [key for key, value in values.items() if value]
|
|
125
|
+
auth_mode = values.get("AUTH_MODE")
|
|
126
|
+
return {
|
|
127
|
+
"ok": exists,
|
|
128
|
+
"profile": name,
|
|
129
|
+
"path": str(loaded.get("path") or real_path),
|
|
130
|
+
"template_path": str(example_path),
|
|
131
|
+
"credential_present": real_path.exists(),
|
|
132
|
+
"auth_mode": auth_mode,
|
|
133
|
+
"keys_present": sorted(keys_present),
|
|
134
|
+
"secret_keys_present": sorted(key for key in keys_present if _is_secret_key(key)),
|
|
135
|
+
"redaction_engine": redact_text(" ".join(f"{key}=present" for key in keys_present)).get("engine"),
|
|
136
|
+
"secret_values_printed": False,
|
|
137
|
+
"safe_for_agent_context": True,
|
|
138
|
+
"raw_file_read_allowed_for_agents": False,
|
|
139
|
+
"safe_inspection_command": _safe_inspection_command(name, directory if profiles_dir else None),
|
|
140
|
+
"suggestion": None if exists else f"Run {_safe_init_command(name, 'subscription', directory if profiles_dir else None)}.",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def show_profile(workspace: Path, name: str, profiles_dir: Path | str | None = None) -> dict[str, Any]:
|
|
145
|
+
loaded = load_profile(workspace, name, profiles_dir)
|
|
146
|
+
values: dict[str, str] = loaded.get("values", {})
|
|
147
|
+
auth_mode = values.get("AUTH_MODE")
|
|
148
|
+
safe_values = {
|
|
149
|
+
key: _safe_profile_value(key, value)
|
|
150
|
+
for key, value in sorted(values.items())
|
|
151
|
+
}
|
|
152
|
+
missing = _common_missing_values(auth_mode, values)
|
|
153
|
+
return {
|
|
154
|
+
"ok": bool(loaded.get("exists")),
|
|
155
|
+
"profile": name,
|
|
156
|
+
"credential_present": bool(loaded.get("credential_present")),
|
|
157
|
+
"auth_mode": auth_mode,
|
|
158
|
+
"values": safe_values,
|
|
159
|
+
"keys_present": sorted(key for key, value in values.items() if value),
|
|
160
|
+
"secret_keys_present": sorted(key for key, value in values.items() if value and _is_secret_key(key)),
|
|
161
|
+
"missing_common": missing,
|
|
162
|
+
"safe_for_agent_context": True,
|
|
163
|
+
"secret_values_printed": False,
|
|
164
|
+
"raw_file_read_allowed_for_agents": False,
|
|
165
|
+
"instruction": (
|
|
166
|
+
"Use this redacted output for diagnostics. Do not read .team/*/profiles/*.env "
|
|
167
|
+
"or .team/runtime/provider-env/*.env into agent context."
|
|
168
|
+
),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def known_profiles(team_dir: Path) -> set[str]:
|
|
173
|
+
directory = team_dir / "profiles"
|
|
174
|
+
if not directory.exists():
|
|
175
|
+
return set()
|
|
176
|
+
names = set()
|
|
177
|
+
for path in directory.glob("*.env"):
|
|
178
|
+
names.add(path.name.removesuffix(".env").removesuffix(".example"))
|
|
179
|
+
for path in directory.glob("*.example.env"):
|
|
180
|
+
names.add(path.name.removesuffix(".example.env"))
|
|
181
|
+
return names
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def gitignore_patterns() -> list[str]:
|
|
185
|
+
return [".team/*/profiles/*.env", "!.team/*/profiles/*.example.env", ".team/runtime/provider-env/*.env"]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def load_profile(workspace: Path, name: str, profiles_dir: Path | str | None = None) -> dict[str, Any]:
|
|
189
|
+
directory = _profile_lookup_dir(workspace, profiles_dir)
|
|
190
|
+
real_path = directory / f"{name}.env"
|
|
191
|
+
example_path = directory / f"{name}.example.env"
|
|
192
|
+
path = real_path if real_path.exists() else example_path
|
|
193
|
+
if not path.exists():
|
|
194
|
+
return {
|
|
195
|
+
"exists": False,
|
|
196
|
+
"profile": name,
|
|
197
|
+
"path": str(real_path),
|
|
198
|
+
"template_path": str(example_path),
|
|
199
|
+
"credential_present": False,
|
|
200
|
+
"values": {},
|
|
201
|
+
}
|
|
202
|
+
values = parse_env_file(path)
|
|
203
|
+
return {
|
|
204
|
+
"exists": True,
|
|
205
|
+
"profile": name,
|
|
206
|
+
"path": str(path),
|
|
207
|
+
"template_path": str(example_path),
|
|
208
|
+
"credential_present": real_path.exists(),
|
|
209
|
+
"values": values,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def parse_env_file(path: Path) -> dict[str, str]:
|
|
214
|
+
values: dict[str, str] = {}
|
|
215
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
216
|
+
line = raw.strip()
|
|
217
|
+
if not line or line.startswith("#"):
|
|
218
|
+
continue
|
|
219
|
+
if line.startswith("export "):
|
|
220
|
+
line = line[len("export ") :].strip()
|
|
221
|
+
if "=" not in line:
|
|
222
|
+
continue
|
|
223
|
+
key, value = line.split("=", 1)
|
|
224
|
+
key = key.strip()
|
|
225
|
+
if not PROFILE_KEY_RE.fullmatch(key):
|
|
226
|
+
continue
|
|
227
|
+
values[key] = _strip_env_value(value.strip())
|
|
228
|
+
return values
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def validate_agent_profile(workspace: Path, agent: dict[str, Any]) -> dict[str, Any]:
|
|
232
|
+
profile = agent.get("profile")
|
|
233
|
+
auth_mode = str(agent.get("auth_mode") or "subscription")
|
|
234
|
+
profiles_dir = _agent_profile_dir(agent)
|
|
235
|
+
result = {
|
|
236
|
+
"ok": True,
|
|
237
|
+
"agent_id": agent.get("id"),
|
|
238
|
+
"provider": agent.get("provider"),
|
|
239
|
+
"profile": profile,
|
|
240
|
+
"auth_mode": auth_mode,
|
|
241
|
+
"path": None,
|
|
242
|
+
"credential_present": False,
|
|
243
|
+
"keys_present": [],
|
|
244
|
+
"missing_required": [],
|
|
245
|
+
"secret_values_printed": False,
|
|
246
|
+
}
|
|
247
|
+
if not profile:
|
|
248
|
+
return result
|
|
249
|
+
loaded = load_profile(workspace, str(profile), profiles_dir)
|
|
250
|
+
result["path"] = loaded.get("path")
|
|
251
|
+
result["credential_present"] = bool(loaded.get("credential_present"))
|
|
252
|
+
if not loaded.get("exists"):
|
|
253
|
+
result["ok"] = False
|
|
254
|
+
result["reason"] = "profile_missing"
|
|
255
|
+
result["suggestion"] = (
|
|
256
|
+
f"Run {_safe_init_command(str(profile), auth_mode, profiles_dir)}, then ask the human user "
|
|
257
|
+
"to fill the generated local profile file."
|
|
258
|
+
)
|
|
259
|
+
return result
|
|
260
|
+
values: dict[str, str] = loaded.get("values", {})
|
|
261
|
+
file_auth_mode = values.get("AUTH_MODE")
|
|
262
|
+
if file_auth_mode and file_auth_mode != auth_mode:
|
|
263
|
+
result["ok"] = False
|
|
264
|
+
result["reason"] = "auth_mode_mismatch"
|
|
265
|
+
result["file_auth_mode"] = file_auth_mode
|
|
266
|
+
result["suggestion"] = (
|
|
267
|
+
f"Set AUTH_MODE={auth_mode} in the local profile or update the role doc. "
|
|
268
|
+
f"Inspect safely with `{_safe_inspection_command(str(profile), profiles_dir)}`."
|
|
269
|
+
)
|
|
270
|
+
return result
|
|
271
|
+
keys_present = sorted(key for key, value in values.items() if value)
|
|
272
|
+
result["keys_present"] = keys_present
|
|
273
|
+
role_model = str(agent.get("model") or "") or None
|
|
274
|
+
profile_model = values.get("MODEL") or values.get("ANTHROPIC_MODEL")
|
|
275
|
+
effective = role_model or profile_model
|
|
276
|
+
result["effective_model"] = effective
|
|
277
|
+
result["model_source"] = "role" if role_model else ("profile" if profile_model else None)
|
|
278
|
+
if auth_mode == "compatible_api" and role_model and profile_model and role_model != profile_model:
|
|
279
|
+
result["ok"] = False
|
|
280
|
+
result["reason"] = "model_mismatch"
|
|
281
|
+
result["suggestion"] = (
|
|
282
|
+
f"Role model {role_model!r} does not match the profile MODEL value; "
|
|
283
|
+
f"inspect safely with `{_safe_inspection_command(str(profile), profiles_dir)}`, "
|
|
284
|
+
"then remove model from the role doc or make both values identical."
|
|
285
|
+
)
|
|
286
|
+
return result
|
|
287
|
+
required = required_profile_keys(str(agent.get("provider") or ""), auth_mode)
|
|
288
|
+
missing = [key for key in required if not values.get(key) and not _alternate_value(values, key)]
|
|
289
|
+
if auth_mode == "compatible_api" and not effective:
|
|
290
|
+
missing.append("MODEL")
|
|
291
|
+
result["missing_required"] = missing
|
|
292
|
+
if missing:
|
|
293
|
+
result["ok"] = False
|
|
294
|
+
result["reason"] = "profile_required_values_missing"
|
|
295
|
+
result["suggestion"] = (
|
|
296
|
+
f"Ask the human user to fill {', '.join(missing)} in local profile {profile}; "
|
|
297
|
+
f"inspect safely with `{_safe_inspection_command(str(profile), profiles_dir)}`. "
|
|
298
|
+
"Do not paste API keys into chat."
|
|
299
|
+
)
|
|
300
|
+
return result
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def smoke_check_agent_profile(workspace: Path, agent: dict[str, Any], timeout: float = 8.0) -> dict[str, Any]:
|
|
304
|
+
auth_mode = str(agent.get("auth_mode") or "subscription")
|
|
305
|
+
profile = agent.get("profile")
|
|
306
|
+
result = {
|
|
307
|
+
"ok": True,
|
|
308
|
+
"agent_id": agent.get("id"),
|
|
309
|
+
"provider": agent.get("provider"),
|
|
310
|
+
"profile": profile,
|
|
311
|
+
"auth_mode": auth_mode,
|
|
312
|
+
"status": "not_required",
|
|
313
|
+
"secret_values_printed": False,
|
|
314
|
+
}
|
|
315
|
+
if auth_mode != "compatible_api" or not profile:
|
|
316
|
+
return result
|
|
317
|
+
loaded = load_profile(workspace, str(profile), _agent_profile_dir(agent))
|
|
318
|
+
values: dict[str, str] = loaded.get("values", {})
|
|
319
|
+
if str(values.get("PROFILE_SMOKE", "true")).lower() in {"0", "false", "no", "off"}:
|
|
320
|
+
result["status"] = "skipped_by_profile"
|
|
321
|
+
return result
|
|
322
|
+
validation = validate_agent_profile(workspace, agent)
|
|
323
|
+
if not validation.get("ok"):
|
|
324
|
+
return {**result, **validation, "ok": False, "status": "profile_invalid"}
|
|
325
|
+
provider = str(agent.get("provider") or "")
|
|
326
|
+
model = effective_model(agent, workspace)
|
|
327
|
+
if provider in {"claude", "claude_code"}:
|
|
328
|
+
return _anthropic_compatible_smoke(values, model, result, timeout)
|
|
329
|
+
if provider == "codex":
|
|
330
|
+
return _openai_compatible_smoke(values, model, result, timeout)
|
|
331
|
+
result["status"] = "unsupported_provider_smoke_skipped"
|
|
332
|
+
return result
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def required_profile_keys(provider: str, auth_mode: str) -> list[str]:
|
|
336
|
+
if auth_mode == "subscription":
|
|
337
|
+
return []
|
|
338
|
+
if provider in {"claude", "claude_code"}:
|
|
339
|
+
if auth_mode == "official_api":
|
|
340
|
+
return ["API_KEY"]
|
|
341
|
+
if auth_mode == "compatible_api":
|
|
342
|
+
return ["BASE_URL", "API_KEY"]
|
|
343
|
+
if provider == "codex":
|
|
344
|
+
if auth_mode == "official_api":
|
|
345
|
+
return ["API_KEY"]
|
|
346
|
+
if auth_mode == "compatible_api":
|
|
347
|
+
return ["BASE_URL", "API_KEY"]
|
|
348
|
+
if provider == "gemini_cli" and auth_mode in {"official_api", "compatible_api"}:
|
|
349
|
+
return ["API_KEY"]
|
|
350
|
+
return []
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def compact_profile_check(check: dict[str, Any]) -> dict[str, Any]:
|
|
354
|
+
keys = [
|
|
355
|
+
"agent_id",
|
|
356
|
+
"provider",
|
|
357
|
+
"profile",
|
|
358
|
+
"auth_mode",
|
|
359
|
+
"ok",
|
|
360
|
+
"status",
|
|
361
|
+
"reason",
|
|
362
|
+
"credential_present",
|
|
363
|
+
"keys_present",
|
|
364
|
+
"missing_required",
|
|
365
|
+
"effective_model",
|
|
366
|
+
"model_source",
|
|
367
|
+
"suggestion",
|
|
368
|
+
"http_status",
|
|
369
|
+
"endpoint",
|
|
370
|
+
"error",
|
|
371
|
+
"proxy_configured",
|
|
372
|
+
"proxy_scheme",
|
|
373
|
+
"proxy_url",
|
|
374
|
+
"proxy_source",
|
|
375
|
+
"proxy_mode",
|
|
376
|
+
]
|
|
377
|
+
return {key: check.get(key) for key in keys if key in check}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def prepare_agent_profile_launch(workspace: Path, agent: dict[str, Any]) -> dict[str, Any] | None:
|
|
381
|
+
profile = agent.get("profile")
|
|
382
|
+
if not profile:
|
|
383
|
+
return None
|
|
384
|
+
check = validate_agent_profile(workspace, agent)
|
|
385
|
+
if not check.get("ok"):
|
|
386
|
+
raise ValueError(_format_profile_check_failure(check))
|
|
387
|
+
loaded = load_profile(workspace, str(profile), _agent_profile_dir(agent))
|
|
388
|
+
values: dict[str, str] = loaded.get("values", {})
|
|
389
|
+
auth_mode = str(agent.get("auth_mode") or values.get("AUTH_MODE") or "subscription")
|
|
390
|
+
provider = str(agent.get("provider") or "")
|
|
391
|
+
exports = _provider_env_exports(provider, auth_mode, values)
|
|
392
|
+
claude_projects_root = None
|
|
393
|
+
if provider in {"claude", "claude_code"} and auth_mode == "compatible_api":
|
|
394
|
+
claude_config_dir = _compatible_claude_config_dir(workspace, str(agent["id"]))
|
|
395
|
+
exports["CLAUDE_CONFIG_DIR"] = str(claude_config_dir)
|
|
396
|
+
claude_projects_root = str(claude_config_dir / "projects")
|
|
397
|
+
exports.update(_compatible_api_network_exports(auth_mode, values))
|
|
398
|
+
unsets = _provider_env_unsets(provider, auth_mode)
|
|
399
|
+
if auth_mode == "compatible_api" and _profile_proxy_mode(values) == "direct":
|
|
400
|
+
unsets.extend(COMPATIBLE_API_NETWORK_ENV_KEYS)
|
|
401
|
+
exports = {key: value for key, value in exports.items() if key not in COMPATIBLE_API_NETWORK_ENV_KEYS}
|
|
402
|
+
overrides = _provider_command_overrides(str(agent.get("provider") or ""), auth_mode, values, agent)
|
|
403
|
+
env_file = None
|
|
404
|
+
if exports or unsets:
|
|
405
|
+
env_file = _write_runtime_env_file(workspace, str(agent["id"]), exports, unsets)
|
|
406
|
+
return {
|
|
407
|
+
"profile": str(profile),
|
|
408
|
+
"auth_mode": auth_mode,
|
|
409
|
+
"path": loaded.get("path"),
|
|
410
|
+
"credential_present": loaded.get("credential_present"),
|
|
411
|
+
"env_file": str(env_file) if env_file else None,
|
|
412
|
+
"env_keys": sorted(exports),
|
|
413
|
+
"env_unset": sorted(unsets),
|
|
414
|
+
"claude_projects_root": claude_projects_root,
|
|
415
|
+
"command_overrides": overrides,
|
|
416
|
+
"secret_values_printed": False,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def effective_model(agent: dict[str, Any], workspace: Path | None = None) -> str | None:
|
|
421
|
+
if agent.get("model"):
|
|
422
|
+
return str(agent["model"])
|
|
423
|
+
if workspace is None or not agent.get("profile"):
|
|
424
|
+
return None
|
|
425
|
+
loaded = load_profile(workspace, str(agent["profile"]), _agent_profile_dir(agent))
|
|
426
|
+
values: dict[str, str] = loaded.get("values", {})
|
|
427
|
+
return values.get("MODEL") or values.get("ANTHROPIC_MODEL")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _provider_env_exports(provider: str, auth_mode: str, values: dict[str, str]) -> dict[str, str]:
|
|
431
|
+
if auth_mode == "subscription":
|
|
432
|
+
return {}
|
|
433
|
+
if provider in {"claude", "claude_code"}:
|
|
434
|
+
exports: dict[str, str] = {}
|
|
435
|
+
base_url = values.get("ANTHROPIC_BASE_URL") or values.get("BASE_URL")
|
|
436
|
+
api_key = values.get("ANTHROPIC_API_KEY") or values.get("API_KEY")
|
|
437
|
+
auth_token = values.get("ANTHROPIC_AUTH_TOKEN") or values.get("AUTH_TOKEN")
|
|
438
|
+
model = values.get("ANTHROPIC_MODEL") or values.get("MODEL")
|
|
439
|
+
if base_url:
|
|
440
|
+
exports["ANTHROPIC_BASE_URL"] = base_url
|
|
441
|
+
if auth_mode == "official_api" and api_key:
|
|
442
|
+
exports["ANTHROPIC_API_KEY"] = api_key
|
|
443
|
+
if auth_token or (auth_mode == "compatible_api" and api_key):
|
|
444
|
+
exports["ANTHROPIC_AUTH_TOKEN"] = auth_token or api_key
|
|
445
|
+
if model:
|
|
446
|
+
exports["ANTHROPIC_MODEL"] = model
|
|
447
|
+
return exports
|
|
448
|
+
if provider == "codex":
|
|
449
|
+
exports = {}
|
|
450
|
+
api_key = values.get("OPENAI_API_KEY") or values.get("API_KEY")
|
|
451
|
+
if api_key:
|
|
452
|
+
exports["TEAM_AGENT_PROVIDER_API_KEY"] = api_key
|
|
453
|
+
exports.setdefault("OPENAI_API_KEY", api_key)
|
|
454
|
+
if values.get("BASE_URL"):
|
|
455
|
+
exports["OPENAI_BASE_URL"] = values["BASE_URL"]
|
|
456
|
+
return exports
|
|
457
|
+
if provider == "gemini_cli":
|
|
458
|
+
api_key = values.get("GEMINI_API_KEY") or values.get("API_KEY")
|
|
459
|
+
return {"GEMINI_API_KEY": api_key} if api_key else {}
|
|
460
|
+
return {}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _provider_env_unsets(provider: str, auth_mode: str) -> list[str]:
|
|
464
|
+
unsets: list[str] = []
|
|
465
|
+
if provider in {"claude", "claude_code"}:
|
|
466
|
+
if auth_mode == "compatible_api":
|
|
467
|
+
unsets.append("ANTHROPIC_API_KEY")
|
|
468
|
+
if auth_mode == "official_api":
|
|
469
|
+
unsets.append("ANTHROPIC_AUTH_TOKEN")
|
|
470
|
+
if provider == "codex" and auth_mode == "compatible_api":
|
|
471
|
+
unsets.extend(["OPENAI_API_KEY", "OPENAI_BASE_URL"])
|
|
472
|
+
if provider == "gemini_cli" and auth_mode == "compatible_api":
|
|
473
|
+
unsets.append("GEMINI_API_KEY")
|
|
474
|
+
return sorted(set(unsets))
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _provider_command_overrides(provider: str, auth_mode: str, values: dict[str, str], agent: dict[str, Any]) -> dict[str, Any]:
|
|
478
|
+
overrides: dict[str, Any] = {}
|
|
479
|
+
model = agent.get("model") or values.get("MODEL") or values.get("ANTHROPIC_MODEL")
|
|
480
|
+
if model:
|
|
481
|
+
overrides["model"] = str(model)
|
|
482
|
+
if provider == "codex":
|
|
483
|
+
codex_profile = values.get("CODEX_PROFILE") or values.get("NATIVE_PROFILE")
|
|
484
|
+
if codex_profile:
|
|
485
|
+
overrides["codex_profile"] = codex_profile
|
|
486
|
+
configs: list[str] = []
|
|
487
|
+
model_provider = values.get("MODEL_PROVIDER")
|
|
488
|
+
base_url = values.get("BASE_URL")
|
|
489
|
+
if auth_mode == "compatible_api" and model_provider and base_url and _safe_codex_provider_id(model_provider):
|
|
490
|
+
configs.append(f'model_provider="{model_provider}"')
|
|
491
|
+
prefix = f"model_providers.{model_provider}"
|
|
492
|
+
configs.append(f'{prefix}.base_url="{base_url}"')
|
|
493
|
+
configs.append(f'{prefix}.env_key="TEAM_AGENT_PROVIDER_API_KEY"')
|
|
494
|
+
if values.get("WIRE_API"):
|
|
495
|
+
configs.append(f'{prefix}.wire_api="{values["WIRE_API"]}"')
|
|
496
|
+
if values.get("PROVIDER_NAME"):
|
|
497
|
+
configs.append(f'{prefix}.name="{values["PROVIDER_NAME"]}"')
|
|
498
|
+
if configs:
|
|
499
|
+
overrides["codex_config"] = configs
|
|
500
|
+
return overrides
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _write_runtime_env_file(workspace: Path, agent_id: str, exports: dict[str, str], unsets: list[str]) -> Path:
|
|
504
|
+
directory = workspace / ".team" / "runtime" / "provider-env"
|
|
505
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
506
|
+
path = directory / f"{agent_id}.env"
|
|
507
|
+
lines = [f"unset {key}" for key in sorted(unsets)]
|
|
508
|
+
lines.extend(f"export {key}={shlex.quote(value)}" for key, value in sorted(exports.items()))
|
|
509
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
510
|
+
try:
|
|
511
|
+
path.chmod(0o600)
|
|
512
|
+
except OSError:
|
|
513
|
+
pass
|
|
514
|
+
return path
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _compatible_claude_config_dir(workspace: Path, agent_id: str) -> Path:
|
|
518
|
+
directory = workspace / ".team" / "runtime" / "provider-config" / agent_id / "claude"
|
|
519
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
520
|
+
_ensure_compatible_claude_config(directory, workspace)
|
|
521
|
+
return directory
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def ensure_compatible_claude_mcp_config(workspace: Path, agent_id: str, mcp_config: dict[str, Any]) -> None:
|
|
525
|
+
if not mcp_config:
|
|
526
|
+
return
|
|
527
|
+
directory = _compatible_claude_config_dir(workspace, agent_id)
|
|
528
|
+
state_path = directory / ".claude.json"
|
|
529
|
+
state = _read_json_object(state_path)
|
|
530
|
+
projects = state.setdefault("projects", {})
|
|
531
|
+
if not isinstance(projects, dict):
|
|
532
|
+
projects = {}
|
|
533
|
+
state["projects"] = projects
|
|
534
|
+
for project_key in _claude_project_keys(workspace):
|
|
535
|
+
project = projects.setdefault(project_key, {})
|
|
536
|
+
if not isinstance(project, dict):
|
|
537
|
+
project = {}
|
|
538
|
+
projects[project_key] = project
|
|
539
|
+
project["hasTrustDialogAccepted"] = True
|
|
540
|
+
project.setdefault("projectOnboardingSeenCount", 1)
|
|
541
|
+
project.setdefault("allowedTools", [])
|
|
542
|
+
project.setdefault("mcpContextUris", [])
|
|
543
|
+
project.setdefault("enabledMcpjsonServers", [])
|
|
544
|
+
project.setdefault("disabledMcpjsonServers", [])
|
|
545
|
+
project.setdefault("hasClaudeMdExternalIncludesApproved", False)
|
|
546
|
+
project.setdefault("hasClaudeMdExternalIncludesWarningShown", False)
|
|
547
|
+
servers = project.setdefault("mcpServers", {})
|
|
548
|
+
if not isinstance(servers, dict):
|
|
549
|
+
servers = {}
|
|
550
|
+
project["mcpServers"] = servers
|
|
551
|
+
servers.update(mcp_config)
|
|
552
|
+
_write_json(state_path, state)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _ensure_compatible_claude_config(directory: Path, workspace: Path) -> None:
|
|
556
|
+
settings_path = directory / "settings.json"
|
|
557
|
+
settings = _read_json_object(settings_path)
|
|
558
|
+
settings.setdefault("theme", "auto")
|
|
559
|
+
settings.setdefault("skipDangerousModePermissionPrompt", True)
|
|
560
|
+
_write_json(settings_path, settings)
|
|
561
|
+
|
|
562
|
+
state_path = directory / ".claude.json"
|
|
563
|
+
state = _read_json_object(state_path)
|
|
564
|
+
state["hasCompletedOnboarding"] = True
|
|
565
|
+
state.setdefault("lastOnboardingVersion", "2.1.0")
|
|
566
|
+
state.setdefault("firstStartTime", "1970-01-01T00:00:00.000Z")
|
|
567
|
+
state.setdefault("numStartups", 0)
|
|
568
|
+
projects = state.get("projects")
|
|
569
|
+
if not isinstance(projects, dict):
|
|
570
|
+
projects = {}
|
|
571
|
+
state["projects"] = projects
|
|
572
|
+
for project_key in _claude_project_keys(workspace):
|
|
573
|
+
project = projects.get(project_key)
|
|
574
|
+
if not isinstance(project, dict):
|
|
575
|
+
project = {}
|
|
576
|
+
projects[project_key] = project
|
|
577
|
+
project["hasTrustDialogAccepted"] = True
|
|
578
|
+
project.setdefault("projectOnboardingSeenCount", 1)
|
|
579
|
+
_write_json(state_path, state)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _claude_project_keys(workspace: Path) -> list[str]:
|
|
583
|
+
keys = [str(workspace)]
|
|
584
|
+
try:
|
|
585
|
+
resolved = str(workspace.resolve())
|
|
586
|
+
except OSError:
|
|
587
|
+
resolved = None
|
|
588
|
+
if resolved and resolved not in keys:
|
|
589
|
+
keys.append(resolved)
|
|
590
|
+
return keys
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _read_json_object(path: Path) -> dict[str, Any]:
|
|
594
|
+
try:
|
|
595
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
596
|
+
except (OSError, json.JSONDecodeError):
|
|
597
|
+
return {}
|
|
598
|
+
return data if isinstance(data, dict) else {}
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _write_json(path: Path, data: dict[str, Any]) -> None:
|
|
602
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
603
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
604
|
+
try:
|
|
605
|
+
path.chmod(0o600)
|
|
606
|
+
except OSError:
|
|
607
|
+
pass
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _anthropic_compatible_smoke(
|
|
611
|
+
values: dict[str, str],
|
|
612
|
+
model: str | None,
|
|
613
|
+
base_result: dict[str, Any],
|
|
614
|
+
timeout: float,
|
|
615
|
+
) -> dict[str, Any]:
|
|
616
|
+
base_url = values.get("ANTHROPIC_BASE_URL") or values.get("BASE_URL")
|
|
617
|
+
api_key = values.get("ANTHROPIC_API_KEY") or values.get("API_KEY")
|
|
618
|
+
auth_token = values.get("ANTHROPIC_AUTH_TOKEN") or values.get("AUTH_TOKEN")
|
|
619
|
+
if not base_url or not (api_key or auth_token) or not model:
|
|
620
|
+
return {**base_result, "ok": False, "status": "smoke_failed", "reason": "missing_base_url_api_key_or_model"}
|
|
621
|
+
endpoint = _anthropic_messages_url(base_url)
|
|
622
|
+
payload = {
|
|
623
|
+
"model": model,
|
|
624
|
+
"max_tokens": 1,
|
|
625
|
+
"messages": [{"role": "user", "content": "ping"}],
|
|
626
|
+
}
|
|
627
|
+
headers = {
|
|
628
|
+
"content-type": "application/json",
|
|
629
|
+
"anthropic-version": values.get("ANTHROPIC_VERSION") or "2023-06-01",
|
|
630
|
+
}
|
|
631
|
+
headers["authorization"] = f"Bearer {auth_token or api_key}"
|
|
632
|
+
return _http_json_smoke(endpoint, payload, headers, values, base_result, timeout)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _openai_compatible_smoke(
|
|
636
|
+
values: dict[str, str],
|
|
637
|
+
model: str | None,
|
|
638
|
+
base_result: dict[str, Any],
|
|
639
|
+
timeout: float,
|
|
640
|
+
) -> dict[str, Any]:
|
|
641
|
+
base_url = values.get("OPENAI_BASE_URL") or values.get("BASE_URL")
|
|
642
|
+
api_key = values.get("OPENAI_API_KEY") or values.get("API_KEY")
|
|
643
|
+
if not base_url or not api_key or not model:
|
|
644
|
+
return {**base_result, "ok": False, "status": "smoke_failed", "reason": "missing_base_url_api_key_or_model"}
|
|
645
|
+
endpoint = _openai_chat_url(base_url)
|
|
646
|
+
payload = {
|
|
647
|
+
"model": model,
|
|
648
|
+
"max_tokens": 1,
|
|
649
|
+
"messages": [{"role": "user", "content": "ping"}],
|
|
650
|
+
}
|
|
651
|
+
headers = {"content-type": "application/json", "authorization": f"Bearer {api_key}"}
|
|
652
|
+
return _http_json_smoke(endpoint, payload, headers, values, base_result, timeout)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _http_json_smoke(
|
|
656
|
+
endpoint: str,
|
|
657
|
+
payload: dict[str, Any],
|
|
658
|
+
headers: dict[str, str],
|
|
659
|
+
values: dict[str, str],
|
|
660
|
+
base_result: dict[str, Any],
|
|
661
|
+
timeout: float,
|
|
662
|
+
) -> dict[str, Any]:
|
|
663
|
+
proxy_info = _proxy_info_for_endpoint(endpoint, values)
|
|
664
|
+
request = urllib.request.Request(
|
|
665
|
+
endpoint,
|
|
666
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
667
|
+
headers=headers,
|
|
668
|
+
method="POST",
|
|
669
|
+
)
|
|
670
|
+
try:
|
|
671
|
+
with _temporary_profile_network_env(values):
|
|
672
|
+
response_ctx = urllib.request.urlopen(request, timeout=timeout)
|
|
673
|
+
with response_ctx as response:
|
|
674
|
+
status = int(getattr(response, "status", 200))
|
|
675
|
+
body = response.read(1024).decode("utf-8", errors="replace")
|
|
676
|
+
except urllib.error.HTTPError as exc:
|
|
677
|
+
body = exc.read(4096).decode("utf-8", errors="replace")
|
|
678
|
+
return {
|
|
679
|
+
**base_result,
|
|
680
|
+
**proxy_info,
|
|
681
|
+
"ok": False,
|
|
682
|
+
"status": "smoke_failed",
|
|
683
|
+
"reason": "http_error",
|
|
684
|
+
"http_status": exc.code,
|
|
685
|
+
"endpoint": _redacted_endpoint(endpoint),
|
|
686
|
+
"error": redact_text(body or str(exc)).get("text"),
|
|
687
|
+
}
|
|
688
|
+
except Exception as exc:
|
|
689
|
+
reason = "proxy_connectivity_failed" if proxy_info.get("proxy_configured") else "request_failed"
|
|
690
|
+
return {
|
|
691
|
+
**base_result,
|
|
692
|
+
**proxy_info,
|
|
693
|
+
"ok": False,
|
|
694
|
+
"status": "smoke_failed",
|
|
695
|
+
"reason": reason,
|
|
696
|
+
"endpoint": _redacted_endpoint(endpoint),
|
|
697
|
+
"error": redact_text(str(exc)).get("text"),
|
|
698
|
+
"suggestion": (
|
|
699
|
+
"Proxy is configured for this request; allow the profile BASE_URL through the proxy or disable the proxy for Team Agent startup."
|
|
700
|
+
if proxy_info.get("proxy_configured")
|
|
701
|
+
else "Check BASE_URL network connectivity from this machine."
|
|
702
|
+
),
|
|
703
|
+
}
|
|
704
|
+
if 200 <= status < 300:
|
|
705
|
+
return {
|
|
706
|
+
**base_result,
|
|
707
|
+
**proxy_info,
|
|
708
|
+
"ok": True,
|
|
709
|
+
"status": "smoke_passed",
|
|
710
|
+
"http_status": status,
|
|
711
|
+
"endpoint": _redacted_endpoint(endpoint),
|
|
712
|
+
}
|
|
713
|
+
return {
|
|
714
|
+
**base_result,
|
|
715
|
+
**proxy_info,
|
|
716
|
+
"ok": False,
|
|
717
|
+
"status": "smoke_failed",
|
|
718
|
+
"reason": "unexpected_status",
|
|
719
|
+
"http_status": status,
|
|
720
|
+
"endpoint": _redacted_endpoint(endpoint),
|
|
721
|
+
"error": redact_text(body).get("text"),
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _anthropic_messages_url(base_url: str) -> str:
|
|
726
|
+
base = base_url.rstrip("/")
|
|
727
|
+
if base.endswith("/messages"):
|
|
728
|
+
return base
|
|
729
|
+
if base.endswith("/v1"):
|
|
730
|
+
return f"{base}/messages"
|
|
731
|
+
return f"{base}/v1/messages"
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _openai_chat_url(base_url: str) -> str:
|
|
735
|
+
base = base_url.rstrip("/")
|
|
736
|
+
if base.endswith("/chat/completions"):
|
|
737
|
+
return base
|
|
738
|
+
if base.endswith("/v1"):
|
|
739
|
+
return f"{base}/chat/completions"
|
|
740
|
+
return f"{base}/v1/chat/completions"
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _redacted_endpoint(endpoint: str) -> str:
|
|
744
|
+
return endpoint.split("?", 1)[0]
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _proxy_info_for_endpoint(endpoint: str, values: dict[str, str]) -> dict[str, Any]:
|
|
748
|
+
parsed = urllib.parse.urlparse(endpoint)
|
|
749
|
+
if _profile_proxy_mode(values) == "direct":
|
|
750
|
+
return {"proxy_configured": False, "proxy_mode": "direct"}
|
|
751
|
+
profile_env = _compatible_api_network_exports("compatible_api", values)
|
|
752
|
+
proxy_url = _proxy_url_from_env(parsed.scheme, profile_env)
|
|
753
|
+
if proxy_url:
|
|
754
|
+
return {
|
|
755
|
+
"proxy_configured": True,
|
|
756
|
+
"proxy_scheme": parsed.scheme,
|
|
757
|
+
"proxy_url": _redact_proxy_url(proxy_url),
|
|
758
|
+
"proxy_source": "profile",
|
|
759
|
+
}
|
|
760
|
+
ambient_proxy_url = _proxy_url_from_env(parsed.scheme, os.environ)
|
|
761
|
+
if ambient_proxy_url:
|
|
762
|
+
return {
|
|
763
|
+
"proxy_configured": True,
|
|
764
|
+
"proxy_scheme": parsed.scheme,
|
|
765
|
+
"proxy_url": _redact_proxy_url(ambient_proxy_url),
|
|
766
|
+
"proxy_source": "ambient",
|
|
767
|
+
}
|
|
768
|
+
return {"proxy_configured": False}
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _proxy_url_from_env(scheme: str, env: Any) -> str | None:
|
|
772
|
+
upper = f"{scheme.upper()}_PROXY"
|
|
773
|
+
lower = f"{scheme.lower()}_proxy"
|
|
774
|
+
return env.get(upper) or env.get(lower) or env.get("ALL_PROXY") or env.get("all_proxy")
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _compatible_api_network_exports(auth_mode: str, values: dict[str, str]) -> dict[str, str]:
|
|
778
|
+
if auth_mode != "compatible_api" or _profile_proxy_mode(values) == "direct":
|
|
779
|
+
return {}
|
|
780
|
+
return {key: values[key] for key in COMPATIBLE_API_NETWORK_ENV_KEYS if values.get(key)}
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
@contextmanager
|
|
784
|
+
def _temporary_profile_network_env(values: dict[str, str]) -> Any:
|
|
785
|
+
profile_env = _compatible_api_network_exports("compatible_api", values)
|
|
786
|
+
direct = _profile_proxy_mode(values) == "direct"
|
|
787
|
+
touched_keys = COMPATIBLE_API_NETWORK_ENV_KEYS if direct else tuple(profile_env)
|
|
788
|
+
saved = {key: os.environ.get(key) for key in touched_keys}
|
|
789
|
+
try:
|
|
790
|
+
if direct:
|
|
791
|
+
for key in COMPATIBLE_API_NETWORK_ENV_KEYS:
|
|
792
|
+
os.environ.pop(key, None)
|
|
793
|
+
os.environ.update(profile_env)
|
|
794
|
+
yield
|
|
795
|
+
finally:
|
|
796
|
+
for key, value in saved.items():
|
|
797
|
+
if value is None:
|
|
798
|
+
os.environ.pop(key, None)
|
|
799
|
+
else:
|
|
800
|
+
os.environ[key] = value
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _profile_proxy_mode(values: dict[str, str]) -> str:
|
|
804
|
+
return str(values.get("PROXY_MODE") or values.get("NETWORK_MODE") or "inherit").strip().lower()
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _redact_proxy_url(proxy_url: str) -> str:
|
|
808
|
+
parsed = urllib.parse.urlparse(proxy_url)
|
|
809
|
+
if not parsed.netloc:
|
|
810
|
+
return proxy_url
|
|
811
|
+
host = parsed.hostname or ""
|
|
812
|
+
port = f":{parsed.port}" if parsed.port else ""
|
|
813
|
+
auth = "[redacted]@" if parsed.username or parsed.password else ""
|
|
814
|
+
return urllib.parse.urlunparse((parsed.scheme, f"{auth}{host}{port}", parsed.path, "", "", ""))
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _format_profile_check_failure(check: dict[str, Any]) -> str:
|
|
818
|
+
agent_id = check.get("agent_id") or "unknown"
|
|
819
|
+
profile = check.get("profile") or "-"
|
|
820
|
+
reason = check.get("reason") or "profile_invalid"
|
|
821
|
+
suggestion = check.get("suggestion") or f"Inspect safely with `team-agent profile show {profile} --workspace . --json`."
|
|
822
|
+
return f"profile validation failed for {agent_id} profile {profile}: {reason}. {suggestion}"
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _alternate_value(values: dict[str, str], key: str) -> str | None:
|
|
826
|
+
alternates = {
|
|
827
|
+
"BASE_URL": ["ANTHROPIC_BASE_URL", "OPENAI_BASE_URL"],
|
|
828
|
+
"API_KEY": ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "AUTH_TOKEN", "OPENAI_API_KEY", "GEMINI_API_KEY"],
|
|
829
|
+
}
|
|
830
|
+
for candidate in alternates.get(key, []):
|
|
831
|
+
if values.get(candidate):
|
|
832
|
+
return values[candidate]
|
|
833
|
+
return None
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _strip_env_value(value: str) -> str:
|
|
837
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
838
|
+
return value[1:-1]
|
|
839
|
+
return value
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _safe_codex_provider_id(value: str) -> bool:
|
|
843
|
+
return re.fullmatch(r"[A-Za-z0-9_-]+", value) is not None
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _is_secret_key(key: str) -> bool:
|
|
847
|
+
upper = key.upper()
|
|
848
|
+
return upper in SECRET_KEYS or "KEY" in upper or "TOKEN" in upper or "SECRET" in upper
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _safe_profile_value(key: str, value: str) -> dict[str, Any]:
|
|
852
|
+
if _is_secret_key(key):
|
|
853
|
+
return {"present": bool(value), "redacted": True}
|
|
854
|
+
return {"present": bool(value), "redacted": False, "value": _safe_plain_profile_value(value)}
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _common_missing_values(auth_mode: str | None, values: dict[str, str]) -> list[str]:
|
|
858
|
+
if auth_mode == "compatible_api":
|
|
859
|
+
required = ["BASE_URL", "API_KEY", "MODEL"]
|
|
860
|
+
elif auth_mode == "official_api":
|
|
861
|
+
required = ["API_KEY"]
|
|
862
|
+
else:
|
|
863
|
+
required = []
|
|
864
|
+
missing = []
|
|
865
|
+
for key in required:
|
|
866
|
+
if key == "MODEL":
|
|
867
|
+
if not (values.get("MODEL") or values.get("ANTHROPIC_MODEL")):
|
|
868
|
+
missing.append(key)
|
|
869
|
+
continue
|
|
870
|
+
if not values.get(key) and not _alternate_value(values, key):
|
|
871
|
+
missing.append(key)
|
|
872
|
+
return missing
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _safe_plain_profile_value(value: str) -> str:
|
|
876
|
+
parsed = urllib.parse.urlparse(value)
|
|
877
|
+
if parsed.scheme and parsed.netloc:
|
|
878
|
+
host = parsed.hostname or ""
|
|
879
|
+
port = f":{parsed.port}" if parsed.port else ""
|
|
880
|
+
auth = "[redacted]@" if parsed.username or parsed.password else ""
|
|
881
|
+
value = urllib.parse.urlunparse((parsed.scheme, f"{auth}{host}{port}", parsed.path, "", "", ""))
|
|
882
|
+
return str(redact_text(value).get("text") or "")
|