Skip to content

Commit 97b950a

Browse files
committed
feat(cli): find accounts.pwd.yaml in the working directory
- look for accounts.pwd.yaml in the CWD when no credential file is set - skip the lookup when OSW_USERNAME or OSW_PASSWORD is configured - ignore a discovered file with no entry for the configured domain - report the credential file and where its path came from - no parent-directory walk, and the MCP server never searches
1 parent 422d872 commit 97b950a

4 files changed

Lines changed: 480 additions & 17 deletions

File tree

‎docs/cli-and-mcp.md‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,11 +224,40 @@ in a file.
224224
the MCP client, so an implicit search would tie the credentials it loads to
225225
how the client happened to be launched.
226226

227-
Both print the sources they resolved to stderr before connecting:
227+
The credential file is looked up separately, in this order:
228+
229+
1. An explicit `OSW_CRED_FILEPATH` (or its aliases), whether it came from the
230+
real environment or from a `.env` file, always wins. If this file has no
231+
entry for the configured domain, that is an error. This check does not
232+
apply when `OSW_USERNAME` and `OSW_PASSWORD` are both set, because osw
233+
then builds credentials for any instance from those two variables.
234+
2. Otherwise, an `accounts.pwd.yaml` file in the working directory, if one
235+
exists there. This lookup does not walk up to parent directories, and it
236+
runs for the CLI only, never for the MCP server, for the same reason the
237+
`.env` search does not: the server's working directory belongs to the
238+
client, not to the operator. If this file has no entry for the configured
239+
domain, it is ignored rather than treated as an error, since it was only
240+
discovered, not configured.
241+
3. Otherwise, no credential file is used.
242+
243+
The working-directory lookup is skipped entirely when either `OSW_USERNAME`
244+
or `OSW_PASSWORD` is already set, including their `OSL_USERNAME` /
245+
`OSL_PASSWORD` aliases, so it never displaces credentials configured
246+
explicitly.
247+
248+
Both print the source they resolved to stderr before connecting, in one of
249+
these forms:
250+
251+
- `(from the OSW_CRED_FILEPATH environment variable)`
252+
- `(from OSW_CRED_FILEPATH in the env file)`
253+
- `(accounts.pwd.yaml found in the working directory)`
254+
- `(accounts.pwd.yaml found in the working directory, ignored: no entry for domain '<domain>')`, when the discovered file is rejected for the reason given in step 2 above.
255+
256+
For example, a verbose CLI run or the MCP server prints:
228257

229258
```text
259+
[osw] credential file: /home/me/project/accounts.pwd.yaml (accounts.pwd.yaml found in the working directory)
230260
[osw] env file : /home/me/project/.env (found from the working directory upward)
231-
[osw] credential file: /abs/path/to/accounts.pwd.yaml
232261
```
233262

234263
### Credentials
@@ -278,7 +307,7 @@ set wins:
278307
| `OSW_DOMAIN` | `OSL_DOMAIN` | Instance to connect to |
279308
| `OSW_USERNAME` | `OSL_USERNAME` | Login user |
280309
| `OSW_PASSWORD` | `OSL_PASSWORD` | Login password |
281-
| `OSW_CRED_FILEPATH` | `OSW_MCP_CRED_FILEPATH`, `OSL_CRED_FILEPATH` | YAML credential file, keyed by iri |
310+
| `OSW_CRED_FILEPATH` | `OSW_MCP_CRED_FILEPATH`, `OSL_CRED_FILEPATH` | YAML credential file, keyed by iri (falls back to `accounts.pwd.yaml` in the working directory, CLI only) |
282311
| `OSW_ENV_FILE` | `OSW_MCP_ENV_FILE` | `.env` file to load |
283312
| `OSW_READ_ONLY` | `OSW_MCP_READ_ONLY` | `true` refuses every write |
284313
| `OSW_SPARQL_ENDPOINT` | | Endpoint for `sparql` queries |

‎src/osw/service/config.py‎

Lines changed: 162 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
2121

2222
from osw.auth import CredentialManager
23+
from osw.defaults import CRED_FILENAME_DEFAULT
2324

2425
# Environment variable names. Each tuple lists the canonical ``OSW_*`` name
2526
# first, followed by every alias that must keep working. ``OSW_CRED_FILEPATH``
@@ -242,9 +243,32 @@ def _verify_cred_file_has_domain(cred_filepath: str, domain: str) -> None:
242243
_env_file_path: Optional[str] = None
243244
_env_file_origin: str = "not searched"
244245

246+
# Names that a .env file actually introduced (as opposed to names that were
247+
# already set in the real environment and therefore left untouched, since
248+
# load_dotenv() defaults to override=False). Used to tell the two apart in
249+
# the startup banner. Accumulates across calls within a process, so a
250+
# repeated _load_env_file() call cannot erase an earlier call's attribution;
251+
# reset() clears it.
252+
_env_file_supplied: set[str] = set()
253+
254+
# Where the credential file actually came from, for the startup banner. One
255+
# of "environment", "env file", "default" (the accounts.pwd.yaml fallback),
256+
# "none" (searched, nothing found) or "not searched". See _resolve_cred_file().
257+
_cred_file_path: Optional[str] = None
258+
_cred_file_origin: str = "not searched"
259+
_cred_file_var: Optional[str] = None
260+
245261

246262
def set_env_file_discovery(enabled: bool) -> None:
247-
"""Enable or disable the implicit ``.env`` search (default: disabled).
263+
"""Enable or disable implicit discovery (default: disabled).
264+
265+
This governs two searches of the current working directory, both skipped
266+
when disabled: the implicit ``.env`` search, and the ``accounts.pwd.yaml``
267+
credential file fallback in :func:`_resolve_cred_file`. Both searches
268+
depend on the working directory the process happens to run in, so both
269+
are gated by the same flag: the CLI's working directory is the one the
270+
user typed the command in, while the MCP server's is chosen by the MCP
271+
client, which it does not control.
248272
249273
Must be called before settings are first loaded, since the file is read
250274
exactly once per process; a call that would *change* the setting after
@@ -276,8 +300,17 @@ def _load_env_file() -> None:
276300
editable install that is the osw checkout and under a normal install it is
277301
site-packages. Neither is what a user standing in a project directory
278302
means by "the .env file", hence the explicit ``usecwd=True``.
303+
304+
Also records, in ``_env_file_supplied``, every environment variable name
305+
that a ``.env`` file has introduced into this process (as opposed to
306+
names already set in the real environment, which ``load_dotenv()``'s
307+
default ``override=False`` leaves untouched). The set accumulates across
308+
calls, so a repeated call within the same process cannot erase an
309+
earlier call's attribution; :func:`reset` clears it.
310+
:func:`_resolve_cred_file` uses it to report whether a resolved
311+
credential path came from the file or from the real environment.
279312
"""
280-
global _env_file_path, _env_file_origin
313+
global _env_file_path, _env_file_origin, _env_file_supplied
281314
path = _first_env(ENV_FILE)
282315
try:
283316
import dotenv
@@ -290,7 +323,9 @@ def _load_env_file() -> None:
290323
"Install python-dotenv (a dependency of osw) to use an env file."
291324
)
292325
if path:
326+
before = set(os.environ)
293327
dotenv.load_dotenv(path)
328+
_env_file_supplied |= set(os.environ) - before
294329
_env_file_path, _env_file_origin = path, "explicit"
295330
return
296331
if not _discover_env_file:
@@ -299,10 +334,87 @@ def _load_env_file() -> None:
299334
if not found:
300335
_env_file_origin = "none"
301336
return
337+
before = set(os.environ)
302338
dotenv.load_dotenv(found)
339+
_env_file_supplied |= set(os.environ) - before
303340
_env_file_path, _env_file_origin = found, "discovered"
304341

305342

343+
def _resolve_cred_file() -> Optional[str]:
344+
"""Resolve the credential file path, in this order, and record its origin.
345+
346+
1. An explicitly configured ``OSW_CRED_FILEPATH`` (or its
347+
``OSW_MCP_CRED_FILEPATH`` / ``OSL_CRED_FILEPATH`` aliases) always wins,
348+
whether it came from the real environment or from a ``.env`` file.
349+
Existence is not checked here; ``load()`` already reports a missing
350+
configured file with a specific error message.
351+
2. Otherwise, if implicit discovery is disabled (the MCP server; see
352+
:func:`set_env_file_discovery`), nothing is resolved: origin
353+
``"not searched"``.
354+
3. Otherwise, if either ``OSW_USERNAME`` or ``OSW_PASSWORD`` (or their
355+
``OSL_*`` aliases) is already configured, nothing is resolved either:
356+
also ``"not searched"``. Any explicitly named credential means the
357+
operator intends to authenticate that way; a half-configured pair
358+
must produce a visible "missing variable" error rather than silently
359+
switch to a different identity via the fallback file.
360+
4. Otherwise, look for ``accounts.pwd.yaml`` in the current working
361+
directory (no walk to parent directories). If present, and a domain is
362+
configured, verify that the file has an entry for it
363+
(:func:`_verify_cred_file_has_domain`): if that fails, origin
364+
``"rejected"`` and ``None`` is returned, but the path is kept in
365+
``_cred_file_path`` so callers can still report which file was
366+
ignored. This is not fatal, unlike the same check for an explicitly
367+
configured file: the operator never named this file, they only
368+
happened to have one lying around, and :func:`load` accepts
369+
``strict=False`` precisely so a status command can report "not
370+
configured" instead of crashing. If the file matches (or no domain is
371+
configured yet to check against), origin ``"default"``. If no such
372+
file exists, origin ``"none"``.
373+
374+
So ``"not searched"`` has two distinct causes: discovery disabled, or
375+
username/password already configured. Safe to call more than once, like
376+
:func:`_load_env_file`, which this assumes has already run.
377+
"""
378+
global _cred_file_path, _cred_file_origin, _cred_file_var
379+
path = _first_env(ENV_CRED_FILEPATH)
380+
if path:
381+
_cred_file_var = next(
382+
(n for n in ENV_CRED_FILEPATH if os.getenv(n) == path), ENV_CRED_FILEPATH[0]
383+
)
384+
_cred_file_origin = (
385+
"env file" if _cred_file_var in _env_file_supplied else "environment"
386+
)
387+
_cred_file_path = path
388+
return path
389+
if not _discover_env_file:
390+
_cred_file_path, _cred_file_origin, _cred_file_var = None, "not searched", None
391+
return None
392+
if _first_env(ENV_USERNAME) or _first_env(ENV_PASSWORD):
393+
_cred_file_path, _cred_file_origin, _cred_file_var = None, "not searched", None
394+
return None
395+
candidate = Path.cwd() / CRED_FILENAME_DEFAULT
396+
if candidate.is_file():
397+
domain = _first_env(ENV_DOMAIN)
398+
if domain:
399+
try:
400+
_verify_cred_file_has_domain(str(candidate), domain)
401+
except RuntimeError:
402+
_cred_file_path, _cred_file_origin, _cred_file_var = (
403+
str(candidate),
404+
"rejected",
405+
None,
406+
)
407+
return None
408+
_cred_file_path, _cred_file_origin, _cred_file_var = (
409+
str(candidate),
410+
"default",
411+
None,
412+
)
413+
return str(candidate)
414+
_cred_file_path, _cred_file_origin, _cred_file_var = None, "none", None
415+
return None
416+
417+
306418
def log_config_sources(stream=None) -> None:
307419
"""Print where configuration was read from, one line per source.
308420
@@ -323,9 +435,18 @@ def log_config_sources(stream=None) -> None:
323435
"not searched": f"not configured (set {ENV_FILE[0]} to use one)",
324436
}[_env_file_origin]
325437
print(f"[osw] env file : {described}", file=out)
326-
cred_filepath = _first_env(ENV_CRED_FILEPATH)
327-
if cred_filepath:
328-
print(f"[osw] credential file: {cred_filepath}", file=out)
438+
_resolve_cred_file()
439+
if _cred_file_path:
440+
cred_described = {
441+
"environment": f"(from the {_cred_file_var} environment variable)",
442+
"env file": f"(from {_cred_file_var} in the env file)",
443+
"default": f"({CRED_FILENAME_DEFAULT} found in the working directory)",
444+
"rejected": (
445+
f"({CRED_FILENAME_DEFAULT} found in the working directory, "
446+
f"ignored: no entry for domain '{_first_env(ENV_DOMAIN)}')"
447+
),
448+
}[_cred_file_origin]
449+
print(f"[osw] credential file: {_cred_file_path} {cred_described}", file=out)
329450

330451

331452
def load(strict: bool = True) -> Settings:
@@ -340,7 +461,11 @@ def load(strict: bool = True) -> Settings:
340461
their ``OSL_*`` aliases) or from a credential file configured via
341462
``OSW_CRED_FILEPATH`` (or its ``OSW_MCP_CRED_FILEPATH`` / ``OSL_CRED_FILEPATH``
342463
aliases). When a credential file is configured, it is validated here to
343-
actually contain an entry for the configured domain.
464+
actually contain an entry for the configured domain. When no credential
465+
file is configured, no username/password is set, and implicit discovery
466+
is enabled, an ``accounts.pwd.yaml`` file in the current working directory
467+
is used instead (see :func:`_resolve_cred_file`); this fallback never
468+
fires for the MCP server.
344469
345470
Parameters
346471
----------
@@ -373,7 +498,7 @@ def load(strict: bool = True) -> Settings:
373498
domain = _first_env(ENV_DOMAIN)
374499
username = _first_env(ENV_USERNAME)
375500
password = _first_env(ENV_PASSWORD)
376-
cred_filepath = _first_env(ENV_CRED_FILEPATH)
501+
cred_filepath = _resolve_cred_file()
377502

378503
cred_file_usable = False
379504
if cred_filepath:
@@ -387,6 +512,15 @@ def load(strict: bool = True) -> Settings:
387512
)
388513
cred_file_usable = True
389514

515+
# A discovered file (origin "default") was already checked against the
516+
# domain inside _resolve_cred_file: a mismatch set origin "rejected" and
517+
# cleared cred_filepath there, so cred_file_usable is already False in
518+
# that case below. An explicitly configured file is different: the
519+
# operator named it, so a mismatch is still a hard error, unconditionally.
520+
discovered_file_discarded = _cred_file_origin == "rejected"
521+
if cred_file_usable and domain and _cred_file_origin != "default":
522+
_verify_cred_file_has_domain(cred_filepath, domain)
523+
390524
# A usable credential file makes the domain optional: which instance to
391525
# use is then chosen later (auto-selected, or via the CLI's --instance).
392526
checks = []
@@ -396,20 +530,29 @@ def load(strict: bool = True) -> Settings:
396530
checks.append((ENV_PASSWORD, password))
397531
missing = [names[0] for names, value in checks if not value]
398532
if missing and strict:
533+
if discovered_file_discarded:
534+
fallback_hint = (
535+
" A file named 'accounts.pwd.yaml' was found in the current "
536+
f"working directory, but it has no entry for domain '{domain}'."
537+
)
538+
elif _discover_env_file:
539+
fallback_hint = (
540+
" Or place an 'accounts.pwd.yaml' file in the current working "
541+
"directory."
542+
)
543+
else:
544+
fallback_hint = ""
399545
raise RuntimeError(
400546
"Missing required OSW credential environment variables: "
401547
+ ", ".join(missing)
402548
+ ". Set them in your environment or a .env file "
403549
"(pointed to by OSW_ENV_FILE / OSW_MCP_ENV_FILE), or configure a "
404550
"credential file via OSW_CRED_FILEPATH (or its OSW_MCP_CRED_FILEPATH "
405-
"/ OSL_CRED_FILEPATH aliases). The server refuses to start without "
406-
"them to avoid an interactive credential prompt that would hang "
407-
"the stdio transport."
551+
"/ OSL_CRED_FILEPATH aliases)." + fallback_hint + " The server "
552+
"refuses to start without them to avoid an interactive credential "
553+
"prompt that would hang the stdio transport."
408554
)
409555

410-
if cred_file_usable and domain:
411-
_verify_cred_file_has_domain(cred_filepath, domain)
412-
413556
kwargs: dict = dict(
414557
domain=domain,
415558
username=username,
@@ -459,13 +602,18 @@ def get_settings() -> Settings:
459602
def reset() -> None:
460603
"""Drop cached settings and the active-instance selection (used by tests)."""
461604
global _settings, _active_iri, _active_resolved
462-
global _discover_env_file, _env_file_path, _env_file_origin
605+
global _discover_env_file, _env_file_path, _env_file_origin, _env_file_supplied
606+
global _cred_file_path, _cred_file_origin, _cred_file_var
463607
_settings = None
464608
_active_iri = None
465609
_active_resolved = False
466610
_discover_env_file = False
467611
_env_file_path = None
468612
_env_file_origin = "not searched"
613+
_env_file_supplied = set()
614+
_cred_file_path = None
615+
_cred_file_origin = "not searched"
616+
_cred_file_var = None
469617

470618

471619
# -- active-instance state ---------------------------------------------------

0 commit comments

Comments
 (0)