Skip to content

Commit 9931d90

Browse files
committed
fix: auto-load .teaagent/env into os.environ at startup
The .teaagent/env file written by 'teaagent setup --write-env' was never automatically sourced into the Python process, causing LLM adapters to fail with 'requires OPENCODEZEN_API_KEY' when the user had not manually run 'source .teaagent/env' in their shell. - Add _load_env_file() to ergonomics/workspace_defaults.py that parses .teaagent/env and loads export KEY=VALUE into os.environ (without overwriting existing env vars) - Call it from load_workspace_defaults() so both TUI and CLI paths pick up the env file automatically - Fix mypy error in audit.py:151 (Fernet type narrowing on Optional[bytes]) - Add 13 unit tests covering all edge cases (missing file, quotes, overwrite protection, integration path, etc.) Constraint: .teaagent/env must not overwrite already-set os.environ values Tested: mypy clean (330 files), ruff clean, 3515 tests passed Confidence: high
1 parent 1a30af8 commit 9931d90

28 files changed

Lines changed: 594 additions & 241 deletions

scripts/audit_test_quality.py

Lines changed: 164 additions & 118 deletions
Large diffs are not rendered by default.

scripts/community_presence_monitor.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,7 @@ def update_metrics_tracking(metrics, update_docs=False):
7070

7171
# Check if all metrics are placeholders - skip document update if so
7272
all_placeholders = all(
73-
v == 'TBD' or (isinstance(v, list) and len(v) == 0)
74-
for v in metrics.values()
73+
v == 'TBD' or (isinstance(v, list) and len(v) == 0) for v in metrics.values()
7574
)
7675
if all_placeholders:
7776
print('All metrics are placeholders - skipping document update')

scripts/opencode_gap_watch.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ def is_placeholder_data(data):
104104
new_entry = f'\n## {datetime.now().strftime("%Y-%m-%d")}\n\n'
105105
new_entry += f'**Findings:** {findings}\n\n'
106106
if escalation_triggers:
107-
new_entry += f'**Escalation Required:** Yes\n**Triggers:** {escalation_triggers}\n\n'
107+
new_entry += (
108+
f'**Escalation Required:** Yes\n**Triggers:** {escalation_triggers}\n\n'
109+
)
108110
else:
109111
new_entry += '**Escalation Required:** No\n\n'
110112

scripts/validate_docs_consistency.py

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,7 @@ def validate_test_quality(tests_dir: Path, mode: str = 'report') -> list[str]:
155155

156156
# Warn if more than 10% of tests have no assertions
157157
if total_tests > 0 and no_assert_tests / total_tests > 0.1:
158-
finding = (
159-
f'Test quality: {no_assert_tests}/{total_tests} tests ({no_assert_tests/total_tests*100:.1f}%) have no assertions. Threshold is 10%.'
160-
)
158+
finding = f'Test quality: {no_assert_tests}/{total_tests} tests ({no_assert_tests / total_tests * 100:.1f}%) have no assertions. Threshold is 10%.'
161159
findings.append(finding)
162160
if mode == 'strict':
163161
errors.append(finding)
@@ -169,11 +167,11 @@ def validate_test_quality(tests_dir: Path, mode: str = 'report') -> list[str]:
169167

170168
path_str = _repo_relative(metrics.path)
171169
if 'security' in path_str or 'audit' in path_str:
172-
no_assert_count = sum(1 for count in metrics.assertion_counts.values() if count == 0)
170+
no_assert_count = sum(
171+
1 for count in metrics.assertion_counts.values() if count == 0
172+
)
173173
if no_assert_count > 0:
174-
finding = (
175-
f'Test quality: {path_str} has {no_assert_count} tests with no assertions in security/audit path.'
176-
)
174+
finding = f'Test quality: {path_str} has {no_assert_count} tests with no assertions in security/audit path.'
177175
findings.append(finding)
178176
if mode == 'strict':
179177
errors.append(finding)
@@ -243,7 +241,9 @@ def validate_test_quality(tests_dir: Path, mode: str = 'report') -> list[str]:
243241
# Only match project ticket/work-item IDs, not risk register IDs (SEC-*, DS-*, SC-*)
244242
_TICKET_ID_IN_TEXT = re.compile(r'\b(?:TASK|TICKET|GOV|P[0-9]-TR)-[A-Z0-9-]+\b')
245243
_PRIORITY_P0_P1 = re.compile(r'\bP[01]\b')
246-
_STATUS_FIXED = re.compile(r'\bFIXED\b|\bFixed\b|\bVERIFY/CLOSE\b|\bDOCUMENTED\b', re.IGNORECASE)
244+
_STATUS_FIXED = re.compile(
245+
r'\bFIXED\b|\bFixed\b|\bVERIFY/CLOSE\b|\bDOCUMENTED\b', re.IGNORECASE
246+
)
247247
_STATUS_OPEN = re.compile(r'\bOPEN\b', re.IGNORECASE)
248248

249249

@@ -1078,13 +1078,9 @@ def validate_risk_register_evidence(
10781078

10791079
# Print coverage summary
10801080
pct = int(100 * verified / total) if total else 0
1081-
print(
1082-
f'Risk register evidence coverage: {verified}/{total} rows verified ({pct}%)'
1083-
)
1081+
print(f'Risk register evidence coverage: {verified}/{total} rows verified ({pct}%)')
10841082
if uncovered_p0p1:
1085-
print(
1086-
f' High-risk uncovered P0/P1 rows: {", ".join(uncovered_p0p1)}'
1087-
)
1083+
print(f' High-risk uncovered P0/P1 rows: {", ".join(uncovered_p0p1)}')
10881084

10891085
return errors
10901086

teaagent/audit.py

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
try:
2020
from cryptography.fernet import Fernet
21+
2122
CRYPTO_AVAILABLE = True
2223
except ImportError:
2324
CRYPTO_AVAILABLE = False
@@ -136,17 +137,18 @@ def __init__(
136137
self._file_chmod_done = False # Track if chmod has been done
137138

138139
# Encryption setup for L3 audit logs
139-
self._encryption_key = encryption_key
140+
self._encryption_key: Optional[bytes] = encryption_key
140141
self._fernet: Optional[Any] = None
141142
if audit_level == 'L3':
142143
if not CRYPTO_AVAILABLE:
143144
raise ValueError(
144145
'L3 audit level requires cryptography library. Install with: pip install cryptography'
145146
)
146-
if encryption_key is None:
147+
if self._encryption_key is None:
147148
# Generate a new encryption key if not provided
148149
self._encryption_key = self._load_or_save_encryption_key()
149150
try:
151+
assert self._encryption_key is not None
150152
self._fernet = Fernet(self._encryption_key)
151153
except Exception as exc:
152154
raise ValueError(f'Failed to initialize L3 encryption: {exc}') from exc
@@ -231,7 +233,9 @@ def _load_or_save_encryption_key(self) -> bytes:
231233
if len(key) == 44: # Fernet key length
232234
return key
233235
except OSError as exc:
234-
raise ValueError(f'Failed to load encryption key from {key_path}: {exc}') from exc
236+
raise ValueError(
237+
f'Failed to load encryption key from {key_path}: {exc}'
238+
) from exc
235239

236240
key = Fernet.generate_key()
237241
try:
@@ -240,7 +244,9 @@ def _load_or_save_encryption_key(self) -> bytes:
240244
key_path.write_bytes(key)
241245
key_path.chmod(0o600)
242246
except OSError as exc:
243-
raise ValueError(f'Failed to save encryption key to {key_path}: {exc}') from exc
247+
raise ValueError(
248+
f'Failed to save encryption key to {key_path}: {exc}'
249+
) from exc
244250
return key
245251

246252
def get_chain_key(self) -> bytes:
@@ -384,8 +390,12 @@ def record(self, event_type: str, run_id: str, **payload: Any) -> AuditEvent:
384390
raise ValueError('L3 encryption not initialized')
385391
try:
386392
payload_json = json.dumps(event.payload, sort_keys=True)
387-
encrypted_bytes = self._fernet.encrypt(payload_json.encode('utf-8'))
388-
payload_to_write = {'encrypted': encrypted_bytes.decode('utf-8')}
393+
encrypted_bytes = self._fernet.encrypt(
394+
payload_json.encode('utf-8')
395+
)
396+
payload_to_write = {
397+
'encrypted': encrypted_bytes.decode('utf-8')
398+
}
389399
except Exception as exc:
390400
raise ValueError(f'L3 encryption failed: {exc}') from exc
391401

@@ -409,7 +419,9 @@ def record(self, event_type: str, run_id: str, **payload: Any) -> AuditEvent:
409419
entry_data = json.loads(canonical)
410420
entry_data['hash'] = current_hash
411421
entry_data['chain_hmac'] = chain_hmac
412-
handle.write(json.dumps(entry_data, sort_keys=True).rstrip('\n') + '\n')
422+
handle.write(
423+
json.dumps(entry_data, sort_keys=True).rstrip('\n') + '\n'
424+
)
413425
handle.flush()
414426
os.fsync(handle.fileno())
415427
# Only chmod once at file creation
@@ -543,7 +555,8 @@ def decrypt_audit_log(
543555
if encryption_key is None:
544556
run_id = audit_path.stem
545557
safe_id = (
546-
''.join(ch for ch in run_id if ch.isalnum() or ch in {'-', '_'}) or 'run'
558+
''.join(ch for ch in run_id if ch.isalnum() or ch in {'-', '_'})
559+
or 'run'
547560
)
548561
key_dir = Path.home() / '.teaagent' / 'audit-encryption'
549562
key_path = key_dir / f'{safe_id}.enc'
@@ -556,13 +569,17 @@ def decrypt_audit_log(
556569
if len(encryption_key) != 44: # Fernet key length
557570
raise ValueError(f'Invalid encryption key length at {key_path}')
558571
except OSError as exc:
559-
raise ValueError(f'Failed to load encryption key from {key_path}: {exc}') from exc
572+
raise ValueError(
573+
f'Failed to load encryption key from {key_path}: {exc}'
574+
) from exc
560575

561576
# Initialize Fernet with the key
562577
try:
563578
fernet = Fernet(encryption_key)
564579
except Exception as exc:
565-
raise ValueError(f'Failed to initialize Fernet with provided key: {exc}') from exc
580+
raise ValueError(
581+
f'Failed to initialize Fernet with provided key: {exc}'
582+
) from exc
566583

567584
# Read and decrypt the audit log
568585
try:
@@ -624,7 +641,9 @@ def decrypt_audit_log(
624641
decrypted_json = decrypted_bytes.decode('utf-8')
625642
event['payload'] = json.loads(decrypted_json)
626643
except Exception as exc:
627-
raise ValueError(f'Failed to decrypt event {event.get("event_id")}: {exc}') from exc
644+
raise ValueError(
645+
f'Failed to decrypt event {event.get("event_id")}: {exc}'
646+
) from exc
628647

629648
decrypted_events.append(event)
630649

@@ -636,7 +655,9 @@ def decrypt_audit_log(
636655
}
637656

638657
except OSError as exc:
639-
raise ValueError(f'Failed to read audit log from {audit_path}: {exc}') from exc
658+
raise ValueError(
659+
f'Failed to read audit log from {audit_path}: {exc}'
660+
) from exc
640661
except json.JSONDecodeError as exc:
641662
raise ValueError(f'Failed to parse audit log JSON: {exc}') from exc
642663

teaagent/cli/_handlers/_audit.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,10 @@ def audit_decrypt_command(args: argparse.Namespace) -> int:
249249
key_path = Path(args.key).expanduser()
250250
if not key_path.exists():
251251
print_json(
252-
{'status': 'error', 'message': f'Encryption key not found at {key_path}'}
252+
{
253+
'status': 'error',
254+
'message': f'Encryption key not found at {key_path}',
255+
}
253256
)
254257
return 1
255258
try:

teaagent/cli/_handlers/chat_repl.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -709,7 +709,9 @@ def show_effort_status() -> None:
709709
# Handle cost command
710710
if user_input == '/cost':
711711
print(f'[TeaAgent] Session cost: ${session_cost_cents / 100:.2f}')
712-
print(f'[TeaAgent] Budget limit: {_format_budget(max_cost_budget_cents)}')
712+
print(
713+
f'[TeaAgent] Budget limit: {_format_budget(max_cost_budget_cents)}'
714+
)
713715
print(
714716
f'[TeaAgent] Remaining: {_format_remaining(max_cost_budget_cents, session_cost_cents)}'
715717
)

teaagent/cli/_misc_parsers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -608,7 +608,9 @@ def _audit(
608608
'decrypt',
609609
help='Decrypt an L3 audit log file (requires cryptography library).',
610610
)
611-
decrypt_cmd.add_argument('audit_path', help='Path to the audit log file to decrypt.')
611+
decrypt_cmd.add_argument(
612+
'audit_path', help='Path to the audit log file to decrypt.'
613+
)
612614
decrypt_cmd.add_argument(
613615
'--key',
614616
default=None,

teaagent/ergonomics/workspace_defaults.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,40 @@ def _read_json(path: Path) -> dict[str, Any]:
8383
return data if isinstance(data, dict) else {}
8484

8585

86+
def _load_env_file(root: str | Path) -> None:
87+
"""Load ``.teaagent/env`` into ``os.environ`` without overwriting existing vars.
88+
89+
This makes API keys written by ``teaagent setup --write-env`` available
90+
to the LLM adapter layer without requiring the user to manually
91+
``source .teaagent/env`` in their shell.
92+
"""
93+
env_path = Path(root).resolve() / '.teaagent' / 'env'
94+
if not env_path.is_file():
95+
return
96+
for raw_line in env_path.read_text(encoding='utf-8').splitlines():
97+
line = raw_line.strip()
98+
if not line.startswith('export '):
99+
continue
100+
assignment = line[len('export '):]
101+
key, sep, raw_value = assignment.partition('=')
102+
if not sep:
103+
continue
104+
key = key.strip()
105+
if not key or key in os.environ:
106+
continue
107+
value = raw_value.strip()
108+
# shlex.quote() wraps in single quotes; unstrip them
109+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
110+
value = value[1:-1]
111+
if value:
112+
os.environ[key] = value
113+
114+
86115
def load_workspace_defaults(root: str | Path = '.') -> dict[str, Any]:
87116
"""Merge ``.teaagent/config.toml`` then ``config.json`` with env overrides."""
88117
root_path = Path(root).resolve()
89118
tea_dir = root_path / '.teaagent'
119+
_load_env_file(root_path)
90120
merged = dict(DEFAULT_KEYS)
91121
toml_path = tea_dir / 'config.toml'
92122
json_path = tea_dir / 'config.json'

teaagent/issue_intake.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
try:
2222
from github import Auth, Github
2323
from github.GithubException import GithubException
24+
2425
GITHUB_AVAILABLE = True
2526
except ImportError:
2627
GITHUB_AVAILABLE = False
@@ -247,7 +248,9 @@ def extract_github_issue(self, issue_url: str) -> ParsedIssue:
247248

248249
# Add labels as metadata
249250
if issue_obj.labels:
250-
issue_text += f'\n\nLabels: {", ".join(label.name for label in issue_obj.labels)}'
251+
issue_text += (
252+
f'\n\nLabels: {", ".join(label.name for label in issue_obj.labels)}'
253+
)
251254

252255
# Parse using the existing parse method
253256
return self.parse(issue_text, source='github')

0 commit comments

Comments
 (0)