fix: profiles.create mirrors launch credentials so new profiles can run (#85111)

A profile created through the headless ws door (profiles.create, #85093)
was born with no inference provider: create_profile() seeds a comment-only
.env, never copies auth.json, and a fresh profile has no config.yaml. Its
first message failed with 'No inference provider configured' and the flow
has no interactive setup step to recover with.

New mirror_credentials param (default true): copy the launch profile's
.env (only over the seeded stub — never clobber cloned secrets) and
auth.json (only when absent), both chmod 600, and inherit
model.provider/model.default when the caller gave no explicit pin and no
config was cloned. mirror_credentials:false preserves the old isolated
behavior byte-for-byte. Result gains a mirrored:{env,auth,model_inherited}
receipt. CLI and REST create paths untouched.
This commit is contained in:
Teknium 2026-08-13 00:25:04 -07:00 committed by GitHub
parent 9deb0302ca
commit 9460cc11d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 80 additions and 1 deletions

View File

@ -102,8 +102,33 @@ def _(rid, params: dict) -> dict:
Params: ``name`` (required, lowercase slug), ``description``,
``clone_from`` (source profile; omitted = fresh profile with bundled
skills), ``clone_all``, ``no_skills``, ``soul`` (SOUL.md content),
``model`` + ``provider`` (optional model pin, best-effort).
``model`` + ``provider`` (optional model pin, best-effort), and
``mirror_credentials`` (default true) copy the launch profile's
``.env`` and ``auth.json`` into the new profile, and inherit its
model.provider/model.default when no explicit pin is given.
Credential mirroring exists because ``create_profile()`` deliberately
seeds a comment-only ``.env`` and never copies ``auth.json`` (OAuth
tokens / credential pools), so a profile created headlessly from a
plugin was born with NO inference provider the first message failed
with "No inference provider configured" and there is no interactive
``hermes setup`` in that flow to recover. A profile spawned as an
always-available teammate must be able to think out of the box; callers
that want an isolated/credential-free profile pass
``mirror_credentials: false``.
"""
def _has_real_env_content(env_path) -> bool:
"""True when .env has any non-comment, non-blank line."""
try:
for line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
stripped = line.strip()
if stripped and not stripped.startswith("#"):
return True
except Exception:
pass
return False
name = str(params.get("name") or "").strip()
if not name:
return _err(rid, 4061, "name required")
@ -147,6 +172,43 @@ def _(rid, params: dict) -> dict:
except Exception:
pass
# Credential + provider mirroring (default ON): a headless-created
# profile must be able to run a first turn. Copy the launch profile's
# .env (only over the seeded comment-only stub — never clobber real
# secrets a clone brought along) and auth.json (only when absent), then
# inherit model.provider/model.default unless the caller pinned a model.
mirrored = {"env": False, "auth": False, "model_inherited": False}
if is_truthy_value(params.get("mirror_credentials", True)):
import shutil
from hermes_constants import get_hermes_home
launch_home = get_hermes_home()
try:
src_env = launch_home / ".env"
dst_env = path / ".env"
if src_env.is_file() and _has_real_env_content(src_env) and not _has_real_env_content(dst_env):
shutil.copy2(src_env, dst_env)
try:
os.chmod(str(dst_env), 0o600)
except OSError:
pass
mirrored["env"] = True
except Exception:
pass
try:
src_auth = launch_home / "auth.json"
dst_auth = path / "auth.json"
if src_auth.is_file() and not dst_auth.exists():
shutil.copy2(src_auth, dst_auth)
try:
os.chmod(str(dst_auth), 0o600)
except OSError:
pass
mirrored["auth"] = True
except Exception:
pass
model = str(params.get("model") or "").strip()
provider = str(params.get("provider") or "").strip()
model_set = False
@ -158,6 +220,22 @@ def _(rid, params: dict) -> dict:
model_set = True
except Exception:
pass
elif is_truthy_value(params.get("mirror_credentials", True)) and not (path / "config.yaml").exists():
# No explicit pin and no cloned config: inherit the launch profile's
# provider+model so the first turn resolves. Same writer as the pin.
try:
from hermes_cli.config import load_config_readonly
from hermes_cli.web_routers.profiles import _write_profile_model
cfg = load_config_readonly() or {}
model_cfg = cfg.get("model") or {}
inherited_provider = str(model_cfg.get("provider") or "")
inherited_model = str(model_cfg.get("default") or "")
if inherited_provider and inherited_model:
_write_profile_model(path, inherited_provider, inherited_model)
mirrored["model_inherited"] = True
except Exception:
pass
return _ok(
rid,
@ -167,6 +245,7 @@ def _(rid, params: dict) -> dict:
"path": str(path),
"soul_written": soul_written,
"model_set": model_set,
"mirrored": mirrored,
},
)