Skip to content

Commit 15db92e

Browse files
johnteeeclaude
andcommitted
fix: TASK-005 review F-A — attribute config provenance to env-file vs shell env
Review of `doctor config` found values set in the workspace env file (.teaagent/env) were mislabeled `env:VAR`, indistinguishable from a real shell var — defeating the feature's purpose (accurate provenance) for exactly the "hidden config source" case it exists to surface. Fix: resolve_config_provenance now snapshots the shell env before loading the env file and parses the file's exports, so it can attribute each env-layer value to `env:VAR` (shell) or `env-file:.teaagent/env` (file). A var set in both is attributed to the shell, which wins (the file never overwrites the shell). Refactored _load_env_file to share a pure _parse_env_file_exports helper. - teaagent/ergonomics/workspace_defaults.py: _parse_env_file_exports + env-file/shell attribution in resolve_config_provenance. - doctor_config: precedence list + note mention env-file. - Test: env-file value -> env-file source; same key in shell -> shell wins. - docs/cli.md + spec updated. Residuals documented, not fixed (review F-C/F-D): automation_webhook_url is not redacted (could embed a token; consistent with existing redaction policy); a malformed numeric env var raises in resolve_config_provenance and load_workspace_defaults (pre-existing, unchanged). Constraint: provenance labeling only; load_workspace_defaults output unchanged (same values, same precedence); _load_env_file behavior unchanged (now via shared parser). Tested: tests/test_workspace_defaults_toml.py 23 passed (incl. new env-file attribution test); mypy clean; doctor config smoke shows env-file vs env: correctly. Not-tested: full suite not run on 3.12 (hypothesis missing in 3.14 sandbox). Confidence: high Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 26d930b commit 15db92e

6 files changed

Lines changed: 88 additions & 15 deletions

File tree

docs/cli.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -362,9 +362,11 @@ teaagent doctor env-order --root .
362362
```
363363

364364
Config provenance — show the effective value **and its source** for each config
365-
key (which layer won: `default`, `config:config.toml`, `config:config.json`, or
366-
`env:VAR`). CLI flags override these at run time but are noted, not resolved
367-
(doctor is not the agent run). Secrets are redacted while their source is kept:
365+
key (which layer won: `default`, `config:config.toml`, `config:config.json`,
366+
`env-file:.teaagent/env`, or `env:VAR` for the shell environment). A var set in
367+
both the shell and `.teaagent/env` is attributed to the shell, which wins. CLI
368+
flags override these at run time but are noted, not resolved (doctor is not the
369+
agent run). Secrets are redacted while their source is kept:
368370

369371
```bash
370372
teaagent doctor config --root .

docs/generated/docs-inventory.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ Do not edit this file manually — regenerate instead.
186186
| `architecture/seven-control-loops-teaagent-integration-map-2026-06-05.md` | archive | 9282 | `6d85e832f8eb` |
187187
| `audit-events.md` | working | 2376 | `b83066076fba` |
188188
| `backlog-priority.md` | working | 30003 | `a9576322cca1` |
189-
| `cli.md` | working | 36243 | `0e6c5bdeb9c8` |
189+
| `cli.md` | working | 36383 | `db50960ce4e2` |
190190
| `cloud-deployment.md` | working | 9431 | `50f6461a2077` |
191191
| `context-bus-and-federated-sync.md` | working | 1564 | `2dde6eed4bf8` |
192192
| `daily-driver-current-status.md` | working | 17015 | `3fc1c142eb49` |
@@ -562,7 +562,7 @@ Do not edit this file manually — regenerate instead.
562562
| `strategy/agent-ecosystem-core-values-2026-06-05.md` | archive | 12861 | `23194f9dc611` |
563563
| `strategy/competitive-analysis-and-positioning-2026-06-06.md` | archive | 67553 | `7c78b145750a` |
564564
| `strategy/daily-driver-roadmap-rationale-2026-06-04.md` | archive | 5053 | `3b6cdeed6d2e` |
565-
| `strategy/harness-first-direction-2026-06-13.md` | constitution | 20253 | `b632a377aa9e` |
565+
| `strategy/harness-first-direction-2026-06-13.md` | constitution | 20826 | `b4a9469900b8` |
566566
| `strategy/implementation-roadmap-with-effort-impact-2026-06-06.md` | archive | 58270 | `fb0af11ee7a6` |
567567
| `strategy/malleable-governed-agent-harness-2026-06-03.md` | archive | 15446 | `eccbc0177490` |
568568
| `strategy/phase-0-to-phase-1-outlook-2026-06-04.md` | archive | 5314 | `374a502902b6` |

docs/strategy/harness-first-direction-2026-06-13.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,14 @@ spine-wide.
317317
redaction). This command is the citeable closure mechanism for any
318318
owner-logged config-source-confusion friction (the log's evidence entries
319319
remain owner-seeded per TASK-007).
320+
- **Review fix (F-A):** provenance distinguishes the shell environment
321+
(`env:VAR`) from the workspace env file (`env-file:.teaagent/env`); a var set
322+
in both is attributed to the shell, which wins. (Initially both were
323+
mislabeled `env:` — defeating the point for env-file values.) Residuals,
324+
documented not fixed: `automation_webhook_url` is not redacted (could embed a
325+
token; consistent with existing redaction policy); a malformed numeric env var
326+
raises in both `resolve_config_provenance` and `load_workspace_defaults`
327+
(pre-existing, unchanged behavior).
320328

321329
### TASK-006: RunEvent taxonomy ADR + M0 dual-write spike
322330
- Goal: ADR for event schema; spine emitting alongside audit on the proof scenario.

teaagent/cli/_handlers/_doctor.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,13 +400,16 @@ def doctor_config(args: argparse.Namespace) -> int:
400400
'precedence': [
401401
'cli',
402402
'env',
403+
'env-file:.teaagent/env',
403404
'config:config.json',
404405
'config:config.toml',
405406
'default',
406407
],
407408
'note': (
408409
'CLI flags override env/config/default at run time; doctor shows the '
409-
'non-CLI layers (it is not the agent run).'
410+
'non-CLI layers (it is not the agent run). "env" is the shell '
411+
'environment; "env-file:.teaagent/env" is the workspace env file '
412+
'(the shell wins when both define a var).'
410413
),
411414
'config': config,
412415
}

teaagent/ergonomics/workspace_defaults.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,16 @@ def _read_json(path: Path) -> dict[str, Any]:
8989
return data if isinstance(data, dict) else {}
9090

9191

92-
def _load_env_file(root: str | Path) -> None:
93-
"""Load ``.teaagent/env`` into ``os.environ`` without overwriting existing vars.
92+
def _parse_env_file_exports(root: str | Path) -> dict[str, str]:
93+
"""Parse ``.teaagent/env`` ``export KEY=value`` lines into a dict.
9494
95-
This makes API keys written by ``teaagent setup --write-env`` available
96-
to the LLM adapter layer without requiring the user to manually
97-
``source .teaagent/env`` in their shell.
95+
Pure: does not touch ``os.environ``. Used both to load the file and to
96+
attribute config provenance to the env file vs. the real shell environment.
9897
"""
9998
env_path = Path(root).resolve() / '.teaagent' / 'env'
10099
if not env_path.is_file():
101-
return
100+
return {}
101+
exports: dict[str, str] = {}
102102
for raw_line in env_path.read_text(encoding='utf-8').splitlines():
103103
line = raw_line.strip()
104104
if not line.startswith('export '):
@@ -108,13 +108,26 @@ def _load_env_file(root: str | Path) -> None:
108108
if not sep:
109109
continue
110110
key = key.strip()
111-
if not key or key in os.environ:
111+
if not key:
112112
continue
113113
value = raw_value.strip()
114114
# shlex.quote() wraps in single quotes; unstrip them
115115
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
116116
value = value[1:-1]
117117
if value:
118+
exports[key] = value
119+
return exports
120+
121+
122+
def _load_env_file(root: str | Path) -> None:
123+
"""Load ``.teaagent/env`` into ``os.environ`` without overwriting existing vars.
124+
125+
This makes API keys written by ``teaagent setup --write-env`` available
126+
to the LLM adapter layer without requiring the user to manually
127+
``source .teaagent/env`` in their shell.
128+
"""
129+
for key, value in _parse_env_file_exports(root).items():
130+
if key not in os.environ:
118131
os.environ[key] = value
119132

120133

@@ -150,8 +163,23 @@ def resolve_config_provenance(root: str | Path = '.') -> dict[str, dict[str, Any
150163
"""
151164
root_path = Path(root).resolve()
152165
tea_dir = root_path / '.teaagent'
166+
167+
# Snapshot the real shell env BEFORE loading the workspace env file, and
168+
# parse the file's exports, so env-layer values can be attributed to the
169+
# right source: a var already in the shell is `env:VAR`; one only the file
170+
# provides is `env-file:.teaagent/env`. (The file never overwrites the shell,
171+
# so shell wins when both define a key.)
172+
shell_env_keys = set(os.environ)
173+
env_file_exports = _parse_env_file_exports(root_path)
153174
_load_env_file(root_path)
154175

176+
def _env_source(env_name: str) -> str:
177+
if env_name in shell_env_keys:
178+
return f'env:{env_name}'
179+
if env_name in env_file_exports:
180+
return 'env-file:.teaagent/env'
181+
return f'env:{env_name}'
182+
155183
prov: dict[str, dict[str, Any]] = {
156184
key: {'value': value, 'source': 'default'}
157185
for key, value in DEFAULT_KEYS.items()
@@ -165,12 +193,15 @@ def resolve_config_provenance(root: str | Path = '.') -> dict[str, dict[str, Any
165193

166194
for key, env_name in _ENV_MAP.items():
167195
if os.environ.get(env_name):
168-
prov[key] = {'value': os.environ[env_name], 'source': f'env:{env_name}'}
196+
prov[key] = {
197+
'value': os.environ[env_name],
198+
'source': _env_source(env_name),
199+
}
169200

170201
for key, (env_name, coerce) in _ENV_ONLY.items():
171202
raw = os.environ.get(env_name)
172203
if raw:
173-
prov[key] = {'value': coerce(raw), 'source': f'env:{env_name}'}
204+
prov[key] = {'value': coerce(raw), 'source': _env_source(env_name)}
174205

175206
return prov
176207

tests/test_workspace_defaults_toml.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,35 @@ def test_resolve_config_provenance_env_overrides_config(
273273
assert prov['provider'] == {'value': 'from_env', 'source': 'env:TEAAGENT_PROVIDER'}
274274

275275

276+
def test_resolve_config_provenance_distinguishes_env_file_from_shell(
277+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
278+
) -> None:
279+
"""A value from .teaagent/env is sourced to the file; a shell var wins and is
280+
sourced to the shell (review F-A)."""
281+
from teaagent.ergonomics.workspace_defaults import resolve_config_provenance
282+
283+
tea = tmp_path / '.teaagent'
284+
tea.mkdir()
285+
(tea / 'env').write_text(
286+
'export TEAAGENT_MODEL=model-from-envfile\n'
287+
'export TEAAGENT_PROVIDER=provider-from-envfile\n',
288+
encoding='utf-8',
289+
)
290+
# model only in the env file; provider also set in the shell (shell wins).
291+
monkeypatch.delenv('TEAAGENT_MODEL', raising=False)
292+
monkeypatch.setenv('TEAAGENT_PROVIDER', 'provider-from-shell')
293+
294+
prov = resolve_config_provenance(tmp_path)
295+
assert prov['model'] == {
296+
'value': 'model-from-envfile',
297+
'source': 'env-file:.teaagent/env',
298+
}
299+
assert prov['provider'] == {
300+
'value': 'provider-from-shell',
301+
'source': 'env:TEAAGENT_PROVIDER',
302+
}
303+
304+
276305
def test_load_workspace_defaults_matches_provenance_values(
277306
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
278307
) -> None:

0 commit comments

Comments
 (0)