diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3ae120f71f31..e9be508d3b32 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -170,3 +170,6 @@ jobs: - name: Run footgun checker run: python scripts/check-windows-footguns.py --all + + - name: Audit auth-store consumers + run: python scripts/check_auth_store_consumers.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 96dce03fae99..224aa3b59225 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -774,7 +774,10 @@ def _nous_extra_body() -> dict: _NOUS_MODEL = "google/gemini-3.6-flash" _NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1" _ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com" -_AUTH_JSON_PATH = get_hermes_home() / "auth.json" +def _auth_json_path(): + from hermes_cli.auth_authority import get_auth_store_path + + return get_auth_store_path() # Codex OAuth endpoint used when a caller explicitly requests # provider="openai-codex". There is deliberately no hardcoded default @@ -1814,9 +1817,9 @@ def _read_nous_auth() -> Optional[dict]: } try: - if not _AUTH_JSON_PATH.is_file(): + if not _auth_json_path().is_file(): return None - data = json.loads(_AUTH_JSON_PATH.read_text(encoding="utf-8")) + data = json.loads(_auth_json_path().read_text(encoding="utf-8")) if data.get("active_provider") != "nous": return None provider = data.get("providers", {}).get("nous", {}) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 3ca96898cf99..f7d24c4e8ba3 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -3856,7 +3856,11 @@ def _perform_api_call(next_api_kwargs): print(f"{agent.log_prefix} Troubleshooting:") print(f"{agent.log_prefix} • Re-authenticate: hermes auth add nous") print(f"{agent.log_prefix} • Check credits / billing: https://portal.nousresearch.com") - print(f"{agent.log_prefix} • Verify stored credentials: {_dhh}/auth.json") + from hermes_cli.auth_authority import describe_auth_store + print( + f"{agent.log_prefix} • Verify stored credentials in the " + f"{describe_auth_store()}" + ) print(f"{agent.log_prefix} • Switch providers temporarily: /model --provider openrouter") if ( agent.provider == "copilot" diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 08b0c0ea6b97..1f1e04712b00 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -1036,6 +1036,7 @@ def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None # device-code sources (nous, openai-codex, xAI) use ``device_code``. if entry.source != "device_code": return + write_through_state: Optional[Tuple[str, Dict[str, Any]]] = None try: with _auth_store_lock(): auth_store = _load_auth_store() @@ -1117,9 +1118,12 @@ def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None _save_auth_store(auth_store) if write_through_to_root and _wt_provider_id: - _write_through_provider_state_to_global_root( - _wt_provider_id, state - ) + # Defer the distinct root lock until the profile lock has + # been released. Authority-bound lock tracking correctly + # rejects nested lock acquisition for a different store. + write_through_state = (_wt_provider_id, dict(state)) + if write_through_state is not None: + _write_through_provider_state_to_global_root(*write_through_state) except Exception as exc: logger.debug("Failed to sync %s pool entry back to auth store: %s", self.provider, exc) @@ -1133,19 +1137,21 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po # sync→POST→write-back sequence below must run atomically across Hermes # processes: otherwise two processes can both adopt the same on-disk # token, both POST it, and the loser gets ``refresh_token_reused``. - # Serialize the whole sequence through the shared cross-process - # auth-store flock (the same lock and extended-timeout pattern used by - # resolve_codex_runtime_credentials()). When a waiter finally acquires - # the lock, the in-lock re-sync below picks up the rotated token the - # winner persisted and skips the POST. + # Serialize the whole sequence through the complete authority-bound + # auth-store lock set. In legacy profile->root fallback mode that means + # both stores: the token endpoint POST and write-back must own the root + # lock because root supplied the single-use token. When a waiter finally + # acquires the locks, the in-lock re-sync below picks up the rotated + # token the winner persisted and skips the POST. if self.provider in ("openai-codex", "xai-oauth"): sync_entry = ( self._sync_codex_entry_from_auth_store if self.provider == "openai-codex" else self._sync_xai_oauth_entry_from_pool_store ) - with _auth_store_lock( - timeout_seconds=self._single_use_refresh_lock_timeout() + with auth_mod._auth_store_locks( + include_legacy_fallback=True, + timeout_seconds=self._single_use_refresh_lock_timeout(), ): synced = sync_entry(entry) if self.provider == "openai-codex": @@ -1336,7 +1342,10 @@ def _refresh_entry_impl( try: with _auth_store_lock(): auth_store = _load_auth_store() - state = _load_provider_state(auth_store, "xai-oauth") or {} + state, source_path = auth_mod._load_provider_state_with_source( + auth_store, "xai-oauth" + ) + state = state or {} if isinstance(state, dict): tokens = state.get("tokens") or {} if isinstance(tokens, dict): @@ -1354,8 +1363,12 @@ def _refresh_entry_impl( "relogin_required": True, "at": datetime.now(timezone.utc).isoformat(), } - _save_provider_state(auth_store, "xai-oauth", state) - _save_auth_store(auth_store) + auth_mod._save_provider_state_to_source( + auth_store, + "xai-oauth", + state, + source_path, + ) except Exception as clear_exc: logger.debug( "Failed to clear terminal xAI OAuth state: %s", clear_exc @@ -1406,7 +1419,10 @@ def _refresh_entry_impl( try: with _auth_store_lock(): auth_store = _load_auth_store() - state = _load_provider_state(auth_store, "openai-codex") or {} + state, source_path = auth_mod._load_provider_state_with_source( + auth_store, "openai-codex" + ) + state = state or {} if isinstance(state, dict): tokens = state.get("tokens") or {} if isinstance(tokens, dict): @@ -1424,8 +1440,12 @@ def _refresh_entry_impl( "relogin_required": True, "at": datetime.now(timezone.utc).isoformat(), } - _save_provider_state(auth_store, "openai-codex", state) - _save_auth_store(auth_store) + auth_mod._save_provider_state_to_source( + auth_store, + "openai-codex", + state, + source_path, + ) except Exception as clear_exc: logger.debug( "Failed to clear terminal Codex OAuth state: %s", clear_exc @@ -1467,7 +1487,10 @@ def _refresh_entry_impl( try: with _auth_store_lock(): auth_store = _load_auth_store() - state = _load_provider_state(auth_store, "nous") or { + state, source_path = auth_mod._load_provider_state_with_source( + auth_store, "nous" + ) + state = state or { "client_id": entry.client_id, "portal_base_url": entry.portal_base_url, "inference_base_url": entry.inference_base_url, @@ -1488,8 +1511,12 @@ def _refresh_entry_impl( exc, reason="credential_pool_refresh_failure", ) - _save_provider_state(auth_store, "nous", state) - _save_auth_store(auth_store) + auth_mod._save_provider_state_to_source( + auth_store, + "nous", + state, + source_path, + ) except Exception as clear_exc: logger.debug("Failed to clear terminal Nous OAuth state: %s", clear_exc) @@ -2796,7 +2823,18 @@ def load_pool(provider: str) -> CredentialPool: ) changed |= _normalize_pool_priorities(provider, entries) - if changed: + # A shared-authority profile reads the canonical root pool directly. Keep + # load-time healing in memory, but do not let a read rewrite shared bytes. + # Explicit mutations and OAuth refreshes still use the normal write paths. + try: + authority = auth_mod.resolve_auth_authority() + shared_profile_read = bool( + authority.profile_id and authority.effective_mode == "shared" + ) + except Exception: + shared_profile_read = False + + if changed and not shared_profile_read: new_ids = {entry.id for entry in entries} write_credential_pool( provider, diff --git a/apps/desktop/scripts/perf/lib/launch.d.mts b/apps/desktop/scripts/perf/lib/launch.d.mts new file mode 100644 index 000000000000..acb5319110d3 --- /dev/null +++ b/apps/desktop/scripts/perf/lib/launch.d.mts @@ -0,0 +1,3 @@ +export function defaultHermesSourceHome(): string +export function resolveViteBin(): string +export function seedConfigFrom(sourceHome: string, targetHome: string): void diff --git a/apps/desktop/scripts/perf/lib/launch.mjs b/apps/desktop/scripts/perf/lib/launch.mjs index 08410bbf2869..2dcc2bfc55fd 100644 --- a/apps/desktop/scripts/perf/lib/launch.mjs +++ b/apps/desktop/scripts/perf/lib/launch.mjs @@ -51,27 +51,26 @@ async function waitFor(fn, { timeoutMs, label }) { // spawned instance reaches an empty chat view instead of the onboarding wizard. // A separate HERMES_HOME dir means a separate gateway lock — no collision with // the user's running app, which keeps its own sessions DB and state. -function seedConfigFrom(sourceHome, targetHome) { - if (!existsSync(sourceHome)) { - return - } - - for (const name of ['config.yaml', '.env', 'auth.json']) { - const from = join(sourceHome, name) +export function seedConfigFrom(sourceHome, targetHome) { + if (!existsSync(sourceHome)) return - if (existsSync(from)) { - try { - copyFileSync(from, join(targetHome, name)) - } catch { - // best-effort — a missing file just means onboarding may appear. - } + const from = join(sourceHome, 'config.yaml') + if (existsSync(from)) { + try { + copyFileSync(from, join(targetHome, 'config.yaml')) + } catch { + // best-effort — a missing file just means onboarding may appear. } } } +export function defaultHermesSourceHome() { + return join(homedir(), '.hermes') +} + // Resolve the vite CLI entry via its package.json `bin` (Vite 8's `exports` // blocks importing `vite/bin/vite.js` directly). -function resolveViteBin() { +export function resolveViteBin() { const pkgPath = require.resolve('vite/package.json') const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.vite @@ -188,7 +187,7 @@ export async function startIsolatedInstance({ const devUrl = prod ? null : `http://127.0.0.1:${devPort}` if (seedConfig && !hermesHome) { - seedConfigFrom(join(homedir(), '.hermes'), home) + seedConfigFrom(defaultHermesSourceHome(), home) } const teardown = () => { @@ -341,7 +340,7 @@ export async function coldStartSamples({ runs = 3, port = 9222, devPort = 5174, // runs 1..N are the representative warm samples. const home = mkdtempSync(join(tmpdir(), 'hermes-perf-cold-home-')) const userDataDir = mkdtempSync(join(tmpdir(), 'hermes-perf-cold-ud-')) - seedConfigFrom(join(homedir(), '.hermes'), home) + seedConfigFrom(defaultHermesSourceHome(), home) try { for (let i = 0; i <= runs; i++) { diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.test.tsx index 50ea8d09ecea..5d5e384f3385 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.test.tsx @@ -10,10 +10,21 @@ import { type ComposerScope, ComposerScopeProvider, MAIN_COMPOSER_SCOPE } from ' import { useComposerDraft } from './use-composer-draft' -const mockComposerApi = { setText: vi.fn(), getState: () => ({ text: '' }) } +const mockComposerApi = { setText: vi.fn(), getState: vi.fn(() => ({ text: '' })) } +const mockComposerAccessor = vi.fn(() => mockComposerApi) +let mockAuiSubscriber: (() => void) | null = null vi.mock('@assistant-ui/react', () => ({ - useAui: () => ({ composer: () => mockComposerApi, subscribe: () => () => undefined }), + useAui: () => ({ + composer: mockComposerAccessor, + subscribe: (subscriber: () => void) => { + mockAuiSubscriber = subscriber + + return () => { + mockAuiSubscriber = null + } + } + }), useAuiState: (selector: (state: { composer: { text: string } }) => unknown) => selector({ composer: { text: '' } }) })) @@ -44,6 +55,34 @@ function ProbeHarness({ activeQueueSessionKey, onLayoutSnapshot, sessionId }: Pr return null } +describe('useComposerDraft — assistant-ui client accessor contract', () => { + afterEach(() => { + cleanup() + mainComposerScope.clear() + mockComposerAccessor.mockClear() + mockComposerApi.getState.mockClear() + mockComposerApi.setText.mockClear() + mockAuiSubscriber = null + }) + + it('resolves the composer accessor for both draft writes and subscription reads', () => { + render( + undefined} + sessionId="session-accessor" + /> + ) + + expect(mockComposerAccessor).toHaveBeenCalled() + expect(mockComposerApi.setText).toHaveBeenCalledWith('') + + act(() => mockAuiSubscriber?.()) + + expect(mockComposerApi.getState).toHaveBeenCalled() + }) +}) + describe('useComposerDraft — attachment scope stays coherent with the committed session on switch (#59305)', () => { afterEach(() => { cleanup() diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index 034f180c423c..1cf5bc84a99d 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -78,7 +78,7 @@ export function useComposerDraft({ const setComposerText = useCallback( (value: string) => { try { - aui.composer.setText(value) + aui.composer().setText(value) } catch { // Composer core not bound yet — DOM/draftRef carry the text. } @@ -271,7 +271,7 @@ export function useComposerDraft({ // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const sync = () => { - const text = aui.composer.getState().text + const text = aui.composer().getState().text draftRef.current = text const editor = editorRef.current diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx index 3c9f7b0fe73f..286c87bb35b2 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -54,7 +54,7 @@ export const AssistantMessage: FC<{ onDismissError?: (messageId: string) => void }> = ({ onBranchInNewChat, onDismissError }) => { const messageId = useAuiState(s => s.message.id) - const messageRuntime = useAui().message + const messageRuntime = useAui().message() const { t } = useI18n() // PERF: this component must NOT subscribe to the streaming text. Every diff --git a/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts b/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts index 9120dbb491ac..3d1a01fda1f7 100644 --- a/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts +++ b/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts @@ -106,7 +106,7 @@ export function useTapbackDoubleClick( role: ChatMessage['role'] ): ((event: MouseEvent) => void) | undefined { const enabled = useStore($reactionsEnabled) - const messageRuntime = useAui().message + const messageRuntime = useAui().message() const onDoubleClick = useCallback( (event: MouseEvent) => { diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index 3af7ca53c838..1e6ec792d016 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -165,7 +165,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess const next = `${base}${sep}${value}` draftRef.current = next - aui.composer.setText(next) + aui.composer().setText(next) const editor = editorRef.current @@ -230,7 +230,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess if (nextDraft !== draftRef.current) { draftRef.current = nextDraft - aui.composer.setText(nextDraft) + aui.composer().setText(nextDraft) } return nextDraft @@ -338,7 +338,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess const finish = () => { draftRef.current = composerPlainText(editor) - aui.composer.setText(draftRef.current) + aui.composer().setText(draftRef.current) requestEditFocus() starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger() } @@ -381,7 +381,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess rememberInitialDraft() const nextDraft = composerPlainText(editor) draftRef.current = nextDraft - aui.composer.setText(nextDraft) + aui.composer().setText(nextDraft) requestEditFocus() return true @@ -581,7 +581,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess // and leave revert as the only way out (#49903 is the same unguarded-core // hazard on the main composer). try { - aui.composer.send() + aui.composer().send() } catch { setSubmitting(false) } @@ -624,7 +624,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess // down (a send/cancel raced this timer), cancel() throws "Composer is // not available" as an uncaught renderer error. Nothing to cancel then. try { - aui.composer.cancel() + aui.composer().cancel() } catch { // Composer core already gone — the edit is closing anyway. } @@ -690,7 +690,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess if (event.key === 'Escape') { event.preventDefault() - aui.composer.cancel() + aui.composer().cancel() return } diff --git a/apps/desktop/src/lib/perf-launch-auth-isolation.test.ts b/apps/desktop/src/lib/perf-launch-auth-isolation.test.ts new file mode 100644 index 000000000000..0210cb854b89 --- /dev/null +++ b/apps/desktop/src/lib/perf-launch-auth-isolation.test.ts @@ -0,0 +1,55 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { defaultHermesSourceHome, resolveViteBin, seedConfigFrom } from '../../scripts/perf/lib/launch.mjs' + +const roots: string[] = [] + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'hermes-desktop-auth-isolation-')) + roots.push(root) + + return root +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('desktop isolated perf launch', () => { + it('resolves the installed Vite CLI entry', () => { + const viteBin = resolveViteBin() + + expect(existsSync(viteBin)).toBe(true) + expect(basename(viteBin)).toBe('vite.js') + expect(basename(dirname(viteBin))).toBe('bin') + }) + + it('uses the current OS home for the default Hermes config source', () => { + expect(defaultHermesSourceHome()).toBe(join(homedir(), '.hermes')) + }) + + it('copies only non-secret config and leaves auth acquisition to the backend', () => { + const root = tempRoot() + const source = join(root, 'source') + const target = join(root, 'target') + mkdirSync(source) + mkdirSync(target) + writeFileSync(join(source, 'config.yaml'), 'model:\n provider: nous\n') + writeFileSync(join(source, '.env'), 'NOUS_API_KEY=secret\n') + writeFileSync(join(source, 'auth.json'), '{"access_token":"secret"}\n') + + seedConfigFrom(source, target) + + expect(readFileSync(join(target, 'config.yaml'), 'utf8')).toBe( + 'model:\n provider: nous\n' + ) + expect(existsSync(join(target, '.env'))).toBe(false) + expect(existsSync(join(target, 'auth.json'))).toBe(false) + }) +}) diff --git a/cli.py b/cli.py index 2b86f9745864..842421b491ce 100644 --- a/cli.py +++ b/cli.py @@ -1343,7 +1343,23 @@ def _finalize_single_query(cli) -> None: _notify_single_query_session_finalize(cli) _run_cleanup(notify_session_finalize=False) finally: - cli._release_active_session() + try: + close_result_metadata_fd = getattr(cli, "_close_result_metadata_fd", None) + if close_result_metadata_fd is not None: + try: + close_result_metadata_fd() + except Exception as exc: + from hermes_cli.result_metadata import ( + PUBLIC_ERROR_MESSAGE, + ResultMetadataError, + ) + + if not isinstance(exc, ResultMetadataError): + raise + print(PUBLIC_ERROR_MESSAGE, file=sys.stderr) + raise SystemExit(1) from None + finally: + cli._release_active_session() def _reset_terminal_input_modes_on_exit() -> None: @@ -4213,6 +4229,8 @@ def __init__( checkpoints: bool = False, pass_session_id: bool = False, ignore_rules: bool = False, + result_meta_file: str = None, + result_meta_fd=None, ): """ Initialize the Hermes CLI. @@ -4440,6 +4458,8 @@ def __init__( self.max_turns = 500 else: self.max_turns = 500 + self.result_meta_file = result_meta_file + self.result_meta_fd = result_meta_fd # Parse and validate toolsets self.enabled_toolsets = toolsets @@ -4720,6 +4740,47 @@ def __init__( self._background_tasks: Dict[str, threading.Thread] = {} self._background_task_counter = 0 + def _publish_result_metadata(self, result: Any) -> None: + """Publish a requested query sidecar or terminate on publication failure.""" + result_meta_fd = getattr(self, "result_meta_fd", None) + if not self.result_meta_file and result_meta_fd is None: + return + from hermes_cli.result_metadata import ( + PUBLIC_ERROR_MESSAGE, + ResultMetadataError, + build_result_metadata, + write_result_metadata, + write_result_metadata_fd, + ) + + publication_failed = False + try: + metadata = build_result_metadata( + result, + max_iterations=self.max_turns, + ) + if result_meta_fd is not None: + write_result_metadata_fd(result_meta_fd, metadata) + else: + write_result_metadata(self.result_meta_file, metadata) + except ResultMetadataError: + publication_failed = True + try: + self._close_result_metadata_fd() + except ResultMetadataError: + publication_failed = True + if publication_failed: + print(PUBLIC_ERROR_MESSAGE, file=sys.stderr) + raise SystemExit(1) from None + + def _close_result_metadata_fd(self) -> None: + """Release the owned result-metadata descriptor exactly once.""" + + owner = getattr(self, "result_meta_fd", None) + self.result_meta_fd = None + if owner is not None: + owner.close() + def _claim_active_session(self, surface: str = "cli", *, stderr: bool = False) -> bool: """Claim a global active-session slot for this CLI process.""" if self._active_session_lease is not None: @@ -17452,10 +17513,12 @@ def _block(reason: str) -> None: ) -def main( +def _main_impl( query: str = None, q: str = None, image: str = None, + result_meta_file: str = None, + result_meta_fd=None, toolsets: str = None, skills: str | list[str] | tuple[str, ...] = None, model: str = None, @@ -17476,6 +17539,7 @@ def main( pass_session_id: bool = False, ignore_user_config: bool = False, ignore_rules: bool = False, + _result_meta_fd_ownership=None, ): """ Hermes Agent CLI - Interactive AI Assistant @@ -17484,6 +17548,8 @@ def main( query: Single query to execute (then exit). Alias: -q q: Shorthand for --query image: Optional local image path to attach to a single query + result_meta_file: Absolute, absent path for a closed-world query-result sidecar + result_meta_fd: Owned or raw pre-opened POSIX FIFO write descriptor toolsets: Comma-separated list of toolsets to enable (e.g., "web,terminal") skills: Comma-separated or repeated list of skills to preload for the session model: Model to use (default: anthropic/claude-opus-4-20250514) @@ -17521,6 +17587,26 @@ def main( except Exception: pass + # Validate the automation contract before worktree setup, credential + # resolution, or model construction so invalid paths fail without a run. + query = query or q + result_meta_fd_owner = result_meta_fd + if result_meta_file: + if not query: + print("Error: --result-meta-file requires --query.", file=sys.stderr) + raise SystemExit(2) + from hermes_cli.result_metadata import ( + PUBLIC_ERROR_MESSAGE, + ResultMetadataError, + validate_result_metadata_destination, + ) + + try: + validate_result_metadata_destination(result_meta_file) + except ResultMetadataError: + print(PUBLIC_ERROR_MESSAGE, file=sys.stderr) + raise SystemExit(2) from None + # Signal to terminal_tool that we're in interactive mode # This enables interactive sudo password prompts with timeout os.environ["HERMES_INTERACTIVE"] = "1" @@ -17597,7 +17683,8 @@ def main( parsed_skills = _parse_skills_argument(skills) - # Create CLI instance + # Create CLI instance. Once construction succeeds, transfer descriptor + # responsibility mechanically from the outer guard to the CLI exactly once. cli = HermesCLI( model=model, toolsets=toolsets_list, @@ -17611,7 +17698,12 @@ def main( checkpoints=checkpoints, pass_session_id=pass_session_id, ignore_rules=ignore_rules, + result_meta_file=result_meta_file, + result_meta_fd=result_meta_fd_owner, ) + if _result_meta_fd_ownership is not None: + _result_meta_fd_ownership.transfer_to(cli) + result_meta_fd_owner = None if parsed_skills: skills_prompt, loaded_skills, missing_skills = build_preloaded_skills_prompt( @@ -17881,6 +17973,11 @@ def _signal_handler_q(signum, frame): and cli.agent.session_id != cli.session_id ): cli.session_id = cli.agent.session_id + if ( + getattr(cli, "result_meta_file", None) + or getattr(cli, "result_meta_fd", None) is not None + ): + cli._publish_result_metadata(result) response = result.get("final_response", "") if isinstance(result, dict) else str(result) # Surface backend errors that produced no visible output # (e.g. invalid model slug → provider 4xx). Mirrors the @@ -17971,6 +18068,114 @@ def _signal_handler_q(signum, frame): cli.run() +class _ResultMetadataFDOwnershipGuard: + """Close a claimed result-metadata descriptor across every main() exit.""" + + __slots__ = ("_pending_owner", "_cli_owner") + + def __init__(self, owner) -> None: + self._pending_owner = owner + self._cli_owner = None + + def transfer_to(self, cli) -> None: + if self._pending_owner is None or self._cli_owner is not None: + raise RuntimeError("result metadata descriptor ownership already transferred") + if getattr(cli, "result_meta_fd", None) is not self._pending_owner: + raise RuntimeError("result metadata descriptor ownership transfer mismatch") + self._cli_owner = cli + self._pending_owner = None + + def close(self) -> None: + cli = self._cli_owner + self._cli_owner = None + if cli is not None: + cli._close_result_metadata_fd() + return + + owner = self._pending_owner + self._pending_owner = None + if owner is not None: + owner.close() + + +def main( + query: str = None, + q: str = None, + image: str = None, + result_meta_file: str = None, + result_meta_fd=None, + toolsets: str = None, + skills: str | list[str] | tuple[str, ...] = None, + model: str = None, + provider: str = None, + api_key: str = None, + base_url: str = None, + max_turns: int = None, + verbose: Optional[bool] = None, + quiet: bool = False, + compact: bool = False, + list_tools: bool = False, + list_toolsets: bool = False, + gateway: bool = False, + resume: str = None, + worktree: bool = False, + w: bool = False, + checkpoints: bool = False, + pass_session_id: bool = False, + ignore_user_config: bool = False, + ignore_rules: bool = False, +): + """Run Hermes while guarding direct-API result-metadata FD ownership.""" + + call_kwargs = locals().copy() + query = query or q + call_kwargs["query"] = query + call_kwargs["q"] = None + + if result_meta_file is not None and result_meta_fd is not None: + print("Error: choose only one result metadata transport.", file=sys.stderr) + raise SystemExit(2) + if result_meta_fd is None: + return _main_impl(**call_kwargs) + + from hermes_cli.result_metadata import ( + PUBLIC_ERROR_MESSAGE, + ResultMetadataError, + ResultMetadataFD, + claim_result_metadata_fd, + ) + + try: + owner = ( + result_meta_fd + if isinstance(result_meta_fd, ResultMetadataFD) + else claim_result_metadata_fd(result_meta_fd) + ) + except ResultMetadataError: + print(PUBLIC_ERROR_MESSAGE, file=sys.stderr) + raise SystemExit(2) from None + + ownership = _ResultMetadataFDOwnershipGuard(owner) + call_kwargs["result_meta_fd"] = owner + try: + if not query: + print("Error: --result-meta-fd requires --query.", file=sys.stderr) + raise SystemExit(2) + return _main_impl( + **call_kwargs, + _result_meta_fd_ownership=ownership, + ) + finally: + try: + ownership.close() + except ResultMetadataError: + print(PUBLIC_ERROR_MESSAGE, file=sys.stderr) + raise SystemExit(1) from None + + +main.__doc__ = _main_impl.__doc__ + + if __name__ == "__main__": import fire diff --git a/contributors/emails/DavidMetcalfe@users.noreply.github.com b/contributors/emails/DavidMetcalfe@users.noreply.github.com new file mode 100644 index 000000000000..ff4020f158b1 --- /dev/null +++ b/contributors/emails/DavidMetcalfe@users.noreply.github.com @@ -0,0 +1,2 @@ +DavidMetcalfe +# API commit 31385a01 / salvage provenance diff --git a/contributors/emails/hermes-agent@local b/contributors/emails/hermes-agent@local new file mode 100644 index 000000000000..7b40667e1659 --- /dev/null +++ b/contributors/emails/hermes-agent@local @@ -0,0 +1,2 @@ +cermm +# fork PR #4 author/head owner diff --git a/contributors/emails/nousbot@nousresearch.com b/contributors/emails/nousbot@nousresearch.com new file mode 100644 index 000000000000..f1a604776214 --- /dev/null +++ b/contributors/emails/nousbot@nousresearch.com @@ -0,0 +1,2 @@ +nousbot-eng +# API commit aa274364 diff --git a/docker/stage2-hook.sh b/docker/stage2-hook.sh index 05474ca927b4..5b782c07d639 100755 --- a/docker/stage2-hook.sh +++ b/docker/stage2-hook.sh @@ -445,17 +445,28 @@ if [ -f "$HERMES_HOME/config.yaml" ]; then || echo "[stage2] Warning: docker_config_migrate.py failed; continuing" fi -# auth.json: bootstrap from env on first boot only. Same semantics as the -# pre-s6 entrypoint — the [ ! -f ] guard is critical to avoid clobbering -# rotated refresh tokens on container restart. -if [ ! -f "$HERMES_HOME/auth.json" ] && [ -n "${HERMES_AUTH_JSON_BOOTSTRAP:-}" ]; then - if refuse_symlinked_path "seed" "$HERMES_HOME/auth.json"; then - : - else - printf '%s' "$HERMES_AUTH_JSON_BOOTSTRAP" > "$HERMES_HOME/auth.json" - chown hermes:hermes "$HERMES_HOME/auth.json" 2>/dev/null || true - chmod 600 "$HERMES_HOME/auth.json" - fi +# Resolve auth topology after config migration and fail closed on invalid +# authority config. The helper stays isolated from application imports and uses +# the PyYAML dependency already installed in the application venv. +AUTHORITY_AUTH_PATH="$("$INSTALL_DIR/.venv/bin/python" \ + "$INSTALL_DIR/scripts/docker_auth_authority.py" "$HERMES_HOME" auth_path)" || { + echo "[stage2] ERROR: invalid auth authority; refusing auth bootstrap" >&2 + exit 1 +} +AUTHORITY_AUTH_DIR="$(dirname "$AUTHORITY_AUTH_PATH")" +as_hermes mkdir -p "$AUTHORITY_AUTH_DIR" + +# auth.json: bootstrap from env on first boot only. The helper takes the +# canonical auth lock, re-checks existence under that lock, rejects symlinks, +# and atomically creates a private file. This avoids a check/write race with a +# concurrently starting Hermes process and never clobbers rotated credentials. +if [ -n "${HERMES_AUTH_JSON_BOOTSTRAP:-}" ]; then + s6-setuidgid hermes "$INSTALL_DIR/.venv/bin/python" \ + "$INSTALL_DIR/scripts/docker_auth_authority.py" "$HERMES_HOME" seed \ + >/dev/null || { + echo "[stage2] ERROR: auth bootstrap failed" >&2 + exit 1 + } fi # auth.json: re-seed a TERMINALLY-DEAD Nous bootstrap session (self-heal). @@ -473,13 +484,13 @@ fi # local session. Older/incomparable seeds remain no-ops, so leaving the env set # cannot roll a healthy rotated token backward. Runs as its own stdlib-only # subprocess (no app imports) and always exits 0. -if [ -f "$HERMES_HOME/auth.json" ] && [ -n "${HERMES_AUTH_JSON_REBOOTSTRAP:-}" ]; then - if refuse_symlinked_path "reseed" "$HERMES_HOME/auth.json"; then +if [ -f "$AUTHORITY_AUTH_PATH" ] && [ -n "${HERMES_AUTH_JSON_REBOOTSTRAP:-}" ]; then + if refuse_symlinked_path "reseed" "$AUTHORITY_AUTH_PATH"; then : else s6-setuidgid hermes "$INSTALL_DIR/.venv/bin/python" \ "$INSTALL_DIR/scripts/docker_rebootstrap_nous_session.py" \ - "$HERMES_HOME/auth.json" \ + "$AUTHORITY_AUTH_PATH" \ || echo "[stage2] Warning: docker_rebootstrap_nous_session.py failed; continuing" fi fi diff --git a/gateway/run.py b/gateway/run.py index 4870a187cfc9..5a522598264e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3347,6 +3347,18 @@ def _reconnect_backoff(attempt: int) -> int: return min(30 * (2 ** (attempt - 1)), _RECONNECT_BACKOFF_CAP) +def _auth_migration_startup_ready() -> bool: + """Fail closed before gateway runtime state is acquired during migration.""" + try: + from hermes_cli.auth_authority import resolve_auth_authority + + resolve_auth_authority() + except Exception as exc: + logger.error("Gateway authentication precondition failed: %s", exc) + return False + return True + + class TurnRunner: """Per-turn collaborator carrying the tool-progress callbacks that used to be nested closures inside ``GatewayRunner._run_agent_inner``. @@ -25043,6 +25055,9 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = from gateway.code_skew import record_boot_fingerprint record_boot_fingerprint() + if not _auth_migration_startup_ready(): + return False + # ── Duplicate-instance guard ────────────────────────────────────── # Prevent two gateways from running under the same HERMES_HOME. # The PID file is scoped to HERMES_HOME, so future multi-profile diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index cb5be6b93589..4b71c9efcc25 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -12,6 +12,8 @@ import argparse +from hermes_cli.result_metadata import parse_result_metadata_fd + # `--profile` / `-p` is consumed by ``main._apply_profile_override`` before # argparse runs (it sets ``HERMES_HOME`` and strips itself from ``sys.argv``), @@ -274,6 +276,24 @@ def build_top_level_parser(): chat_parser.add_argument( "-q", "--query", help="Single query (non-interactive mode)" ) + result_metadata_transport = chat_parser.add_mutually_exclusive_group() + result_metadata_transport.add_argument( + "--result-meta-file", + metavar="ABSOLUTE_PATH", + help=( + "Atomically create a private JSON metadata sidecar for a single " + "--query (classic CLI only; destination must not exist)" + ), + ) + result_metadata_transport.add_argument( + "--result-meta-fd", + type=parse_result_metadata_fd, + metavar="FD", + help=( + "Write JSON metadata as one frame to a pre-opened blocking FIFO write " + "descriptor after --query (classic CLI, POSIX/WSL only)" + ), + ) chat_parser.add_argument( "--image", help="Optional local image path to attach to a single query" ) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 0d08007ef33e..748be5271533 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -33,7 +33,7 @@ import time import uuid import webbrowser -from contextlib import contextmanager +from contextlib import ExitStack, contextmanager from dataclasses import dataclass, field from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, HTTPServer @@ -49,7 +49,8 @@ read_raw_config, require_readable_config_before_write, ) -from hermes_constants import OPENROUTER_BASE_URL, secure_parent_dir +from hermes_constants import OPENROUTER_BASE_URL, get_default_hermes_root, secure_parent_dir +from hermes_cli.auth_authority import describe_auth_store, resolve_auth_authority from agent.credential_persistence import sanitize_borrowed_credential_payload from utils import atomic_replace, atomic_yaml_write, env_float, is_truthy_value @@ -903,14 +904,21 @@ def _oauth_trace(event: str, *, sequence_id: Optional[str] = None, **fields: Any # ============================================================================= def _auth_file_path() -> Path: - path = get_hermes_home() / "auth.json" + pinned_path = getattr(_auth_transaction_target, "path", None) + path = pinned_path if pinned_path is not None else resolve_auth_authority().auth_path # Seat belt: if pytest is running and HERMES_HOME resolves to the real # user's auth store, refuse rather than silently corrupt it. This catches # tests that forgot to monkeypatch HERMES_HOME, tests invoked without the # hermetic conftest, or sandbox escapes via threads/subprocesses. In # production (no PYTEST_CURRENT_TEST) this is a single dict lookup. if os.environ.get("PYTEST_CURRENT_TEST"): - real_home_auth = (Path.home() / ".hermes" / "auth.json").resolve(strict=False) + # Use the unmodified HOME environment, not Path.home(): profile tests + # intentionally monkeypatch Path.home() so the synthetic shared root is + # still resolvable. The guard must compare against the actual operator + # home, matching _load_global_auth_store(). + real_home_auth = ( + Path(os.environ.get("HOME", str(Path.home()))) / ".hermes" / "auth.json" + ).resolve(strict=False) try: resolved = path.resolve(strict=False) except Exception: @@ -934,25 +942,17 @@ def _global_auth_file_path() -> Optional[Path]: See issue #18594 follow-up (credential_pool shadowing). """ - try: - from hermes_constants import get_default_hermes_root - global_root = get_default_hermes_root() - except Exception: + authority = resolve_auth_authority() + # A configured authority is singular: shared/profile modes never + # overlay one store on another. Preserve the old profile-to-root fallback + # only while an existing profile-local store is selected by the bounded + # legacy compatibility rule (auth.authority is absent). + if not authority.legacy_compatibility: return None - profile_home = get_hermes_home() - try: - if profile_home.resolve(strict=False) == global_root.resolve(strict=False): - return None - except Exception: - if profile_home == global_root: - return None # No pytest seat belt here: this is a pure read-only path, and # ``_load_global_auth_store()`` wraps the read in a try/except so an - # unreadable global file can never break the profile process. The - # write-side seat belt still lives on ``_auth_file_path()`` where it - # belongs (that's what protects the real user's auth store from being - # corrupted by a mis-configured test). - return global_root / "auth.json" + # unreadable global file can never break the profile process. + return authority.shared_root / "auth.json" def _load_global_auth_store() -> Dict[str, Any]: @@ -997,6 +997,8 @@ def _auth_lock_path() -> Path: _auth_target_lock_holders: Dict[str, threading.local] = {} _auth_target_lock_holders_guard = threading.Lock() +_auth_transaction_target = threading.local() +_auth_transition_lock_holder = threading.local() def _same_path(left: Path, right: Path) -> bool: @@ -1016,6 +1018,14 @@ def _auth_lock_holder_for(target_path: Path) -> threading.local: return _auth_target_lock_holders.setdefault(key, threading.local()) +def _validate_locked_store_path(path: Path) -> None: + """Fail closed if a locked transaction target was replaced by a link.""" + if path.is_symlink(): + raise RuntimeError(f"Refusing symlink auth transaction path: {path}") + if path.exists() and not path.is_file(): + raise RuntimeError(f"Auth transaction path must be a regular file: {path}") + + @contextmanager def _file_lock( lock_path: Path, @@ -1088,55 +1098,177 @@ def _file_lock( pass +@contextmanager +def _auth_transition_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS): + """Gate topology changes ahead of every auth-store acquisition. + + Runtime transactions hold this lock only until their complete, canonically + ordered store-lock set has been acquired. Migrations hold it for the whole + topology transition. This gives one total order (transition -> stores) + without serializing unrelated profile-local transactions for their full + duration. + """ + lock_path = get_default_hermes_root().resolve(strict=False) / "auth-transition.lock" + with _file_lock( + lock_path, + _auth_transition_lock_holder, + timeout_seconds, + "Timed out waiting for auth authority transition lock", + ): + yield + + +@contextmanager +def _auth_store_locks( + target_paths: Optional[Iterable[Path]] = None, + *, + transaction_target: Optional[Path] = None, + include_legacy_fallback: bool = False, + timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS, +): + """Acquire a transaction's complete store-lock set in canonical order.""" + pinned_path = getattr(_auth_transaction_target, "path", None) + if pinned_path is not None: + if target_paths is None: + paths = {Path(pinned_path).resolve(strict=False)} + else: + raw_paths = {Path(path) for path in target_paths} + for path in raw_paths: + _validate_locked_store_path(path) + paths = {path.resolve(strict=False) for path in raw_paths} + held_paths = getattr(_auth_transaction_target, "locked_paths", frozenset()) + if not paths.issubset(held_paths): + raise RuntimeError( + "Nested auth transaction cannot expand its store-lock set" + ) + active_path = Path(transaction_target or pinned_path).resolve(strict=False) + with ExitStack() as nested_stack: + for path in sorted(paths, key=lambda item: os.fsencode(str(item))): + nested_stack.enter_context( + _file_lock( + path.with_suffix(".lock"), + _auth_lock_holder_for(path), + timeout_seconds, + "Timed out waiting for auth store lock", + ) + ) + for path in paths: + _validate_locked_store_path(path) + yield active_path, getattr(_auth_transaction_target, "fallback_path", None) + return + + stack = ExitStack() + try: + with _auth_transition_lock(timeout_seconds): + fallback_path: Optional[Path] = None + if target_paths is None: + active_path = _auth_file_path() + paths = {active_path} + if include_legacy_fallback: + fallback_path = _global_auth_file_path() + if fallback_path is not None: + paths.add(fallback_path) + else: + raw_paths = {Path(path) for path in target_paths} + for path in raw_paths: + _validate_locked_store_path(path) + paths = {path.resolve(strict=False) for path in raw_paths} + if not paths: + raise ValueError("at least one auth-store target is required") + active_path = Path( + transaction_target + or min(paths, key=lambda path: os.fsencode(str(path))) + ).resolve(strict=False) + for path in sorted(paths, key=lambda item: os.fsencode(str(item))): + stack.enter_context( + _file_lock( + path.with_suffix(".lock"), + _auth_lock_holder_for(path), + timeout_seconds, + "Timed out waiting for auth store lock", + ) + ) + for path in paths: + _validate_locked_store_path(path) + + previous = getattr(_auth_transaction_target, "path", None) + _auth_transaction_target.path = active_path + _auth_transaction_target.locked_paths = frozenset(paths) + _auth_transaction_target.fallback_path = fallback_path + try: + yield active_path, fallback_path + finally: + if previous is None: + try: + del _auth_transaction_target.path + except AttributeError: + pass + for attribute in ("locked_paths", "fallback_path"): + try: + delattr(_auth_transaction_target, attribute) + except AttributeError: + pass + else: + _auth_transaction_target.path = previous + finally: + stack.close() + + @contextmanager def _auth_store_lock( timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS, *, target_path: Optional[Path] = None, ): - """Cross-process advisory lock for one auth.json read/write transaction. - - ``target_path`` is required for profile-to-global write-throughs. A profile - lock does not protect the distinct global auth store; each path therefore - uses its own reentrancy tracker and kernel lock. - - Lock ordering invariant: when this lock is held together with - ``_nous_shared_store_lock``, acquire ``_auth_store_lock`` FIRST - (outer) and the shared Nous lock SECOND (inner). All runtime - refresh paths follow this order; violating it risks deadlock - against a concurrent import on the shared store. - """ - auth_path = target_path if target_path is not None else _auth_file_path() - lock_path = auth_path.with_suffix(".lock") if target_path is not None else _auth_lock_path() - with _file_lock( - lock_path, - _auth_lock_holder_for(auth_path), - timeout_seconds, - "Timed out waiting for auth store lock", + """Lock one auth store after pinning its authority under the transition gate.""" + paths = None if target_path is None else (target_path,) + with _auth_store_locks( + paths, + transaction_target=target_path, + timeout_seconds=timeout_seconds, ): yield +def _validate_auth_store_structure(raw: Any) -> Dict[str, Any]: + """Reject section types that alternate auth-store loaders could misread.""" + if not isinstance(raw, dict): + raise ValueError("auth store must be a JSON object") + for section in ("providers", "credential_pool"): + if section in raw and not isinstance(raw[section], dict): + raise ValueError(f"auth store {section} section must be a mapping") + return raw + + +def _quarantine_auth_store(auth_file: Path, reason: Exception) -> Dict[str, Any]: + corrupt_path = auth_file.with_suffix(".json.corrupt") + try: + import shutil + + shutil.copy2(auth_file, corrupt_path) + except Exception: + pass + logger.warning( + "auth: failed to parse %s (%s) — starting with empty store. " + "Corrupt file preserved at %s", + auth_file, + reason, + corrupt_path, + ) + return {"version": AUTH_STORE_VERSION, "providers": {}} + + def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]: auth_file = auth_file or _auth_file_path() if not auth_file.exists(): return {"version": AUTH_STORE_VERSION, "providers": {}} try: - raw = json.loads(auth_file.read_text(encoding="utf-8")) - except Exception as exc: - corrupt_path = auth_file.with_suffix(".json.corrupt") - try: - import shutil - shutil.copy2(auth_file, corrupt_path) - except Exception: - pass - logger.warning( - "auth: failed to parse %s (%s) — starting with empty store. " - "Corrupt file preserved at %s", - auth_file, exc, corrupt_path, + raw = _validate_auth_store_structure( + json.loads(auth_file.read_text(encoding="utf-8")) ) - return {"version": AUTH_STORE_VERSION, "providers": {}} + except Exception as exc: + return _quarantine_auth_store(auth_file, exc) if isinstance(raw, dict) and ( isinstance(raw.get("providers"), dict) @@ -1159,7 +1291,12 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]: return {"version": AUTH_STORE_VERSION, "providers": {}} -def _save_auth_store(auth_store: Dict[str, Any], target_path: Optional[Path] = None) -> Path: +def _save_auth_store( + auth_store: Dict[str, Any], + target_path: Optional[Path] = None, + *, + updated_at: Optional[str] = None, +) -> Path: # target_path=None preserves the existing contract (write the active # store at _auth_file_path()). An explicit path lets callers persist a # specific store — e.g. the global-root write-through for rotating xAI @@ -1172,7 +1309,7 @@ def _save_auth_store(auth_store: Dict[str, Any], target_path: Optional[Path] = N # secure_parent_dir refuses to chmod / or top-level dirs (#25821). secure_parent_dir(auth_file) auth_store["version"] = AUTH_STORE_VERSION - auth_store["updated_at"] = datetime.now(timezone.utc).isoformat() + auth_store["updated_at"] = updated_at or datetime.now(timezone.utc).isoformat() payload = json.dumps(auth_store, indent=2) + "\n" tmp_path = auth_file.with_name(f"{auth_file.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}") try: @@ -1252,26 +1389,17 @@ def _provider_state_transaction(provider_id: str): target lock is acquired prevents both stale refreshes and whole-file lost updates without inverting the documented auth -> shared lock order. """ - with _auth_store_lock(): - auth_store = _load_auth_store() - state, source_path = _load_provider_state_with_source( - auth_store, - provider_id, + with _auth_store_locks(include_legacy_fallback=True) as (active_path, _fallback_path): + auth_store = _load_auth_store(active_path) + source_state, source_path = _load_provider_state_with_source( + auth_store, provider_id ) - active_path = _auth_file_path() - if source_path is None or _same_path(source_path, active_path): - yield auth_store, state, source_path - return - - with _auth_store_lock(target_path=source_path): - source_store = _load_auth_store(source_path) - source_providers = source_store.get("providers") - source_state = None - if isinstance(source_providers, dict): - raw_state = source_providers.get(provider_id) - if isinstance(raw_state, dict): - source_state = dict(raw_state) - yield auth_store, source_state, source_path + # Test doubles and compatibility callers may provide state without a + # source path; while this transaction owns the active lock, that state + # is necessarily active-store state for persistence purposes. + if source_state is not None and source_path is None: + source_path = active_path + yield auth_store, source_state, source_path def _load_provider_state(auth_store: Dict[str, Any], provider_id: str) -> Optional[Dict[str, Any]]: @@ -4465,14 +4593,21 @@ def _save_xai_oauth_tokens( """ if last_refresh is None: last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - with _auth_store_lock(): - auth_store = _load_auth_store() + with _provider_state_transaction("xai-oauth") as ( + auth_store, + inherited_state, + source_path, + ): # A profile that lacks its own xai-oauth block is reading the root # grant through _load_provider_state's fallback. When such a profile # refreshes the (rotating) grant, we must write the rotated chain back # to root too, or root is left holding a revoked refresh token (#43589). - write_through_to_root = not _profile_has_own_xai_oauth_state(auth_store) - state = _load_provider_state(auth_store, "xai-oauth") or {} + active_path = _auth_file_path() + write_through_to_root = ( + source_path is not None + and source_path.resolve(strict=False) != active_path.resolve(strict=False) + ) + state = inherited_state or {} state["tokens"] = tokens state["last_refresh"] = last_refresh state["auth_mode"] = auth_mode @@ -7597,8 +7732,7 @@ def _login_openai_codex( config_path = _update_config_for_provider("openai-codex", creds.get("base_url", DEFAULT_CODEX_BASE_URL)) print() print("Login successful!") - from hermes_constants import display_hermes_home as _dhh - print(f" Auth state: {_dhh()}/auth.json") + print(f" Auth state: {describe_auth_store()}") print(f" Config updated: {config_path} (model.provider=openai-codex)") @@ -7665,8 +7799,7 @@ def _login_xai_oauth( config_path = _update_config_for_provider("xai-oauth", creds.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL)) print() print("Login successful!") - from hermes_constants import display_hermes_home as _dhh - print(f" Auth state: {_dhh()}/auth.json") + print(f" Auth state: {describe_auth_store()}") print(f" Config updated: {config_path} (model.provider=xai-oauth)") diff --git a/hermes_cli/auth_authority.py b/hermes_cli/auth_authority.py new file mode 100644 index 000000000000..b7636a6e51f5 --- /dev/null +++ b/hermes_cli/auth_authority.py @@ -0,0 +1,348 @@ +"""Canonical resolver for Hermes' authentication-store authority. + +The active profile still owns configuration, but authentication may be shared +across profiles or isolated to one profile. +Every auth.json reader/writer must resolve through this module so the data file +and its advisory lock cannot diverge. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import os +from pathlib import Path +import stat +from typing import Any, Mapping, Optional + +from hermes_constants import get_default_hermes_root, get_hermes_home +from utils import fast_safe_load + + +AUTH_MODES = frozenset({"shared", "profile"}) + + +class AuthAuthorityConfigError(RuntimeError): + """Raised when auth authority configuration is malformed or incomplete.""" + + +_TERMINAL_MIGRATION_PHASES = frozenset( + {"committed", "committed_state_changed", "rolled_back", "aborted"} +) + + +def _newest_journal_candidates(journals: Path) -> list[Path]: + """Return newest-first journals, retaining unreadable entries to fail closed.""" + candidates: list[tuple[int | None, Path]] = [] + try: + paths = list(journals.glob("*.json")) + except OSError: + return [journals / "unreadable.json"] + for path in paths: + try: + candidates.append((path.stat().st_mtime_ns, path)) + except OSError: + candidates.append((None, path)) + candidates.sort( + key=lambda item: (item[0] is None, item[0] or 0), + reverse=True, + ) + return [item[1] for item in candidates] + +def incomplete_auth_migration(shared_root: Path) -> Optional[dict[str, str]]: + """Return the newest incomplete migration journal without exposing secrets.""" + journals = shared_root / "state-snapshots" / "auth-migrations" / "journals" + for path in _newest_journal_candidates(journals): + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {"plan_id": path.stem, "phase": "unreadable"} + if not isinstance(raw, dict): + return {"plan_id": path.stem, "phase": "malformed"} + phase = str(raw.get("phase") or "unknown") + if phase not in _TERMINAL_MIGRATION_PHASES: + return {"plan_id": str(raw.get("plan_id") or path.stem), "phase": phase} + return None + + +_TERMINAL_RESTORE_PHASES = frozenset({"committed", "rolled_back", "aborted"}) + + +def incomplete_auth_restore(shared_root: Path) -> Optional[dict[str, str]]: + """Return the newest interrupted auth/config restore journal.""" + journals = shared_root / "state-snapshots" / "auth-restores" / "journals" + for path in _newest_journal_candidates(journals): + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {"operation_id": path.stem, "phase": "unreadable"} + if not isinstance(raw, dict): + return {"operation_id": path.stem, "phase": "malformed"} + phase = str(raw.get("phase") or "unknown") + if phase not in _TERMINAL_RESTORE_PHASES: + return { + "operation_id": str(raw.get("operation_id") or path.stem), + "phase": phase, + } + return None + + +@dataclass(frozen=True) +class AuthAuthority: + """Resolved authentication authority and non-secret diagnostic metadata.""" + + requested_mode: str + effective_mode: str + auth_path: Path + lock_path: Path + profile_home: Path + shared_root: Path + profile_id: Optional[str] + config_path: Path + legacy_compatibility: bool = False + conflicting_store: Optional[Path] = None + + @property + def provenance(self) -> str: + if self.legacy_compatibility: + return "legacy-profile-store" + if self.effective_mode == "shared": + return "shared-root" + if self.effective_mode == "profile": + return f"profile:{self.profile_id or 'default'}" + raise AssertionError(f"unsupported auth authority mode: {self.effective_mode}") + + +def _same_path(left: Path, right: Path) -> bool: + try: + return left.resolve(strict=False) == right.resolve(strict=False) + except Exception: + return left == right + + +def _profile_id(profile_home: Path, shared_root: Path) -> Optional[str]: + if profile_home.parent.name == "profiles" and _same_path( + profile_home.parent.parent, shared_root + ): + return profile_home.name + return None + + +def _read_authority_config(config_path: Path) -> tuple[Mapping[str, Any], bool]: + """Return the raw ``auth`` section and whether ``auth.authority`` was explicit. + + Unlike the best-effort general config reader, authority resolution fails + closed: silently treating malformed YAML as the default could make a writer + mutate a different credential store than the operator intended. + """ + if not config_path.exists(): + return {}, False + try: + with config_path.open("r", encoding="utf-8") as handle: + raw = fast_safe_load(handle) or {} + except Exception as exc: + raise AuthAuthorityConfigError( + f"Cannot resolve authentication authority because {config_path} " + f"is unreadable or invalid: {exc}" + ) from exc + if not isinstance(raw, dict): + raise AuthAuthorityConfigError( + f"Cannot resolve authentication authority: {config_path} must contain a mapping." + ) + section = raw.get("auth") + if section is None: + return {}, False + if not isinstance(section, dict): + raise AuthAuthorityConfigError("auth must be a mapping in config.yaml") + return section, "authority" in section + + +def resolve_auth_authority( + *, + profile_home: Optional[Path] = None, + shared_root: Optional[Path] = None, + config: Optional[Mapping[str, Any]] = None, + enforce_migration: bool = True, + enforce_restore: bool = True, +) -> AuthAuthority: + """Resolve the one authoritative auth store for the current process. + + ``shared`` is the default for new installs. For compatibility, an existing + profile-local auth.json remains authoritative while ``auth.authority`` is absent; + setting a mode explicitly exits that compatibility path. + """ + active_home = Path(profile_home or get_hermes_home()).expanduser() + root = Path(shared_root or get_default_hermes_root()).expanduser() + pending = incomplete_auth_migration(root) + if enforce_migration and pending: + raise AuthAuthorityConfigError( + "Authentication is blocked by incomplete migration " + f"{pending['plan_id']} ({pending['phase']}); run " + f"`hermes auth migrate-shared --recover --plan-id {pending['plan_id']}`" + ) + pending_restore = incomplete_auth_restore(root) + if enforce_restore and pending_restore: + raise AuthAuthorityConfigError( + "Authentication is blocked by incomplete restore " + f"{pending_restore['operation_id']} ({pending_restore['phase']}); rerun the " + "same snapshot restore command while gateways are stopped" + ) + config_path = active_home / "config.yaml" + + if config is None: + section, explicit_mode = _read_authority_config(config_path) + else: + raw_section = config.get("auth") if "auth" in config else config + if raw_section is None: + section = {} + elif not isinstance(raw_section, Mapping): + raise AuthAuthorityConfigError("auth must be a mapping in config.yaml") + else: + section = raw_section + explicit_mode = "authority" in section + + raw_mode = section.get("authority", "shared") + if not isinstance(raw_mode, str): + raise AuthAuthorityConfigError( + "auth.authority must be shared or profile" + ) + requested_mode = raw_mode.strip().lower() + if requested_mode not in AUTH_MODES: + raise AuthAuthorityConfigError( + f"Invalid auth.authority {raw_mode!r}; expected shared or profile" + ) + + profile_path = active_home / "auth.json" + shared_path = root / "auth.json" + legacy = False + + if requested_mode == "profile": + auth_path = profile_path + effective_mode = "profile" + else: + auth_path = shared_path + effective_mode = "shared" + if ( + not explicit_mode + and not _same_path(profile_path, shared_path) + and profile_path.is_file() + ): + auth_path = profile_path + effective_mode = "profile" + legacy = True + + for candidate in (profile_path, shared_path): + if candidate.is_symlink(): + raise AuthAuthorityConfigError( + f"Authentication authority path must not be a symlink: {candidate}" + ) + + auth_path = auth_path.resolve(strict=False) + if auth_path.exists() and not auth_path.is_file(): + raise AuthAuthorityConfigError( + f"Authentication authority path must be a file, not {auth_path}" + ) + lock_path = auth_path.with_suffix(".lock") + + conflicting_store: Optional[Path] = None + for candidate in (profile_path, shared_path): + if candidate.is_file() and not _same_path(candidate, auth_path): + conflicting_store = candidate.resolve(strict=False) + break + + return AuthAuthority( + requested_mode=requested_mode, + effective_mode=effective_mode, + auth_path=auth_path, + lock_path=lock_path, + profile_home=active_home.resolve(strict=False), + shared_root=root.resolve(strict=False), + profile_id=_profile_id(active_home, root), + config_path=config_path.resolve(strict=False), + legacy_compatibility=legacy, + conflicting_store=conflicting_store, + ) + + +def get_auth_store_path() -> Path: + """Return the canonical data-file path for authentication state.""" + return resolve_auth_authority().auth_path + + +def get_auth_lock_path() -> Path: + """Return the lock path paired with the canonical authentication store.""" + return resolve_auth_authority().lock_path + + +def _display_authority_path(path: Path, shared_root: Path) -> str: + """Render an authority path without exposing an operator-specific HOME.""" + try: + relative = path.resolve(strict=False).relative_to( + shared_root.resolve(strict=False) + ) + except ValueError: + relative = Path(path.name) + return str(Path("~/.hermes") / relative) + + +def describe_auth_store() -> str: + """Return a normalized, non-secret location label for user-facing errors.""" + authority = resolve_auth_authority(enforce_migration=False) + location = _display_authority_path(authority.auth_path, authority.shared_root) + if authority.legacy_compatibility: + return f"legacy profile-local auth store ({location})" + if authority.effective_mode == "shared": + return f"shared auth store ({location})" + return f"profile-local auth store ({location})" + + +def auth_authority_status( + *, + profile_home: Optional[Path] = None, + shared_root: Optional[Path] = None, +) -> dict[str, Any]: + """Return redacted authority metadata for CLI/doctor diagnostics.""" + authority = resolve_auth_authority( + profile_home=profile_home, + shared_root=shared_root, + enforce_migration=False, + ) + migration = incomplete_auth_migration(authority.shared_root) + exists = authority.auth_path.is_file() + permissions: Optional[str] = None + owner_ok: Optional[bool] = None + writable = False + if exists: + try: + file_stat = authority.auth_path.stat() + permissions = stat.filemode(file_stat.st_mode) + owner_ok = file_stat.st_uid == authority.auth_path.parent.stat().st_uid + writable = bool(file_stat.st_mode & stat.S_IWUSR) + except OSError: + pass + else: + try: + writable = authority.auth_path.parent.exists() and os.access( + authority.auth_path.parent, os.W_OK + ) + except OSError: + writable = False + return { + "requested_mode": authority.requested_mode, + "effective_mode": authority.effective_mode, + "path": _display_authority_path(authority.auth_path, authority.shared_root), + "lock_path": _display_authority_path(authority.lock_path, authority.shared_root), + "profile_id": authority.profile_id, + "provenance": authority.provenance, + "exists": exists, + "permissions": permissions, + "owner_ok": owner_ok, + "writable": writable, + "legacy_compatibility": authority.legacy_compatibility, + "conflicting_store": ( + _display_authority_path(authority.conflicting_store, authority.shared_root) + if authority.conflicting_store + else None + ), + "migration": migration, + } diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index a5b3e7210b8e..3a01a2f081fb 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -508,8 +508,54 @@ def auth_reset_command(args) -> None: def auth_status_command(args) -> None: provider = _normalize_provider(getattr(args, "provider", "") or "") + if getattr(args, "all_profiles", False): + if provider: + raise SystemExit("--all-profiles cannot be combined with a provider") + from hermes_cli.auth_authority import auth_authority_status + from hermes_cli.profiles import _get_default_hermes_home + + root = _get_default_hermes_home() + profiles_root = root / "profiles" + homes = [("default", root)] + if profiles_root.is_dir(): + homes.extend( + (path.name, path) + for path in sorted(profiles_root.iterdir(), key=lambda item: item.name) + if path.is_dir() + ) + print("Authentication authority by profile") + for name, home in homes: + status = auth_authority_status(profile_home=home, shared_root=root) + store = "present" if status["exists"] else "not created" + print( + f" {name}: mode={status['effective_mode']} " + f"provenance={status['provenance']} store={store}" + ) + return if not provider: - raise SystemExit("Provider is required. Example: `hermes auth status spotify`.") + from hermes_cli.auth_authority import auth_authority_status + + status = auth_authority_status() + print("Authentication authority") + print(f" mode: {status['effective_mode']} (requested: {status['requested_mode']})") + print(f" provenance: {status['provenance']}") + print(f" store: {'present' if status['exists'] else 'not created'}") + if status["permissions"]: + print(f" permissions: {status['permissions']}") + if status["legacy_compatibility"]: + print(" warning: legacy profile-local compatibility mode is active") + if status["conflicting_store"]: + print(" warning: a non-authoritative auth store also exists") + migration = status.get("migration") + if migration: + print( + f" migration: plan {migration['plan_id']} is {migration['phase']}" + ) + print( + " recovery: hermes auth migrate-shared --recover " + f"--plan-id {migration['plan_id']}" + ) + return status = auth_mod.get_auth_status(provider) if not status.get("logged_in"): reason = status.get("error") @@ -530,6 +576,73 @@ def auth_logout_command(args) -> None: auth_mod.logout_command(SimpleNamespace(provider=getattr(args, "provider", None))) +def auth_migrate_shared_command(args) -> None: + from hermes_cli.auth_migration import ( + AuthMigrationError, + apply_shared_migration, + plan_shared_migration, + recover_shared_migration, + rollback_shared_migration, + ) + + try: + if getattr(args, "recover", False): + plan_id = getattr(args, "plan_id", None) + if not plan_id: + raise AuthMigrationError("--recover requires --plan-id") + print(f"Auth migration recovery state: {recover_shared_migration(plan_id=plan_id)}") + return + if getattr(args, "rollback", False): + plan_id = getattr(args, "plan_id", None) + if not plan_id: + raise AuthMigrationError("--rollback requires --plan-id") + print( + "Auth migration rollback state: " + f"{rollback_shared_migration(plan_id=plan_id)}" + ) + return + if getattr(args, "dry_run", False): + plan = plan_shared_migration( + all_profiles=bool(getattr(args, "all_profiles", False)), + profile=getattr(args, "profile", None), + ) + print("Auth migration dry-run") + print(f" plan_id: {plan.plan_id}") + print(f" plan_digest: {plan.plan_digest}") + print(f" target: {plan.manifest['target_class']}") + for source in plan.manifest["sources"]: + providers = ", ".join(source["providers"]) or "none" + overlaps = ", ".join(source["overlapping_providers"]) or "none" + print( + f" profile {source['profile']}: providers={providers}; " + f"overlap={overlaps}" + ) + print("Review this plan, then rerun with --apply, both plan values, and an explicit conflict policy.") + return + plan_id = getattr(args, "plan_id", None) + plan_digest = getattr(args, "plan_digest", None) + if not plan_id or not plan_digest: + raise AuthMigrationError("--apply requires --plan-id and --plan-digest from a dry-run") + applied = apply_shared_migration( + plan_id=plan_id, + plan_digest=plan_digest, + conflict_policy=getattr(args, "conflict_policy", "abort"), + ) + print(f"Auth migration committed: {applied}") + except AuthMigrationError as exc: + raise SystemExit(str(exc)) from exc + + +def auth_migrate_recover_command(args) -> None: + from hermes_cli.auth_migration import AuthMigrationError, recover_shared_migration + + try: + phase = recover_shared_migration(plan_id=getattr(args, "plan_id", "")) + print(f"Auth migration recovery state: {phase}") + except AuthMigrationError as exc: + raise SystemExit(str(exc)) from exc + + def auth_spotify_command(args) -> None: action = str(getattr(args, "spotify_action", "") or "login").strip().lower() if action in {"", "login"}: @@ -795,6 +908,12 @@ def auth_command(args) -> None: if action == "logout": auth_logout_command(args) return + if action == "migrate-shared": + auth_migrate_shared_command(args) + return + if action == "migrate-recover": + auth_migrate_recover_command(args) + return if action == "spotify": auth_spotify_command(args) return diff --git a/hermes_cli/auth_migration.py b/hermes_cli/auth_migration.py new file mode 100644 index 000000000000..27353c37c3ee --- /dev/null +++ b/hermes_cli/auth_migration.py @@ -0,0 +1,778 @@ +"""Dry-run-first migration of legacy profile auth stores to shared authority. + +Public output contains only topology and provider names. Full credential hashes +are kept solely in mode-0600 plan artifacts and are never used as public plan +digests or printed. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import hashlib +import io +import json +import os +from pathlib import Path +import shutil +from typing import Any, Callable, Iterable, Optional +import uuid +import yaml + +from hermes_constants import get_default_hermes_root +from hermes_cli.auth import ( + AUTH_STORE_VERSION, + _auth_store_locks, + _auth_transition_lock, + _load_auth_store, + _save_auth_store, +) +from hermes_cli.auth_authority import resolve_auth_authority +from utils import IndentDumper, atomic_yaml_write, fast_safe_load + + +class AuthMigrationError(RuntimeError): + """A migration precondition, conflict, or recovery check failed.""" + + +@dataclass(frozen=True) +class MigrationPlan: + plan_id: str + plan_digest: str + manifest: dict[str, Any] + artifact_path: Path + + +_POLICIES = frozenset({"abort", "prefer-shared", "prefer-profile"}) + + +def _root() -> Path: + return get_default_hermes_root().resolve(strict=False) + + +def _state_dir() -> Path: + return _root() / "state-snapshots" / "auth-migrations" + + +def _private_bytes_write(path: Path, raw: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}") + fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(fd, "wb") as handle: + handle.write(raw) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + path.chmod(0o600) + finally: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + + +def _private_json_write(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}") + fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + path.chmod(0o600) + dir_fd = os.open(str(path.parent), os.O_RDONLY) + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) + finally: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + + +def _read_json_object(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + raise AuthMigrationError(f"Unreadable auth store at {path}: {exc}") from exc + if not isinstance(value, dict): + raise AuthMigrationError(f"Auth store at {path} is not a JSON object") + return value + + +def _content_precondition(path: Path) -> dict[str, Any]: + if not path.exists(): + return {"exists": False, "sha256": None} + if not path.is_file() or path.is_symlink(): + raise AuthMigrationError(f"Refusing non-regular or symlink auth path: {path}") + raw = path.read_bytes() + return { + "exists": True, + "sha256": hashlib.sha256(raw).hexdigest(), + "size": len(raw), + "mtime_ns": path.stat().st_mtime_ns, + } + + +def _raw_identity(raw: bytes) -> dict[str, Any]: + return { + "exists": True, + "sha256": hashlib.sha256(raw).hexdigest(), + "size": len(raw), + } + + +def _matches_identity(path: Path, expected: Optional[dict[str, Any]]) -> bool: + if expected is None: + return False + current = _content_precondition(path) + return all( + current.get(key) == expected.get(key) + for key in ("exists", "sha256", "size") + if key in expected + ) + + +def _providers(store: dict[str, Any]) -> set[str]: + names: set[str] = set() + for key in ("providers", "credential_pool"): + section = store.get(key) + if isinstance(section, dict): + names.update(str(name) for name in section) + return names + + +def _selected_profiles(*, all_profiles: bool, profile: Optional[str]) -> list[Path]: + if all_profiles == bool(profile): + raise AuthMigrationError( + "Choose exactly one of --all-profiles or --profile NAME" + ) + profiles_root = _root() / "profiles" + if profile: + if Path(profile).name != profile or profile in {".", ".."}: + raise AuthMigrationError("Invalid profile name") + candidates = [profiles_root / profile] + else: + candidates = ( + sorted( + (path for path in profiles_root.iterdir() if path.is_dir()), + key=lambda path: path.name, + ) + if profiles_root.exists() + else [] + ) + profiles_root_resolved = profiles_root.resolve(strict=False) + result: list[Path] = [] + for home in candidates: + resolved_home = home.resolve(strict=False) + try: + resolved_home.relative_to(profiles_root_resolved) + except ValueError as exc: + raise AuthMigrationError( + f"Profile {home.name!r} resolves outside the Hermes profiles root" + ) from exc + source = resolved_home / "auth.json" + if source.is_file(): + result.append(resolved_home) + return result + + +def _gateway_homes_for_target( + selected_homes: Iterable[Path], target: Path +) -> list[Path]: + """Enumerate selected and already-shared homes whose gateways can write target.""" + root = _root() + selected = {home.resolve(strict=False) for home in selected_homes} + candidates = {root, *selected} + profiles_root = root / "profiles" + if profiles_root.exists(): + candidates.update( + path.resolve(strict=False) + for path in profiles_root.iterdir() + if path.is_dir() + ) + relevant: set[Path] = set() + target = target.resolve(strict=False) + for home in candidates: + try: + authority = resolve_auth_authority( + profile_home=home, + shared_root=root, + enforce_migration=False, + ) + except Exception as exc: + raise AuthMigrationError( + f"Cannot determine auth authority for gateway home {home}" + ) from exc + if home in selected or authority.auth_path.resolve(strict=False) == target: + relevant.add(home) + return sorted(relevant, key=lambda path: os.fsencode(str(path))) + + +def _gateway_snapshot(profile_homes: Iterable[Path]) -> dict[str, Optional[int]]: + """Capture live gateway PIDs without cleaning or mutating runtime state.""" + from gateway.status import ( + get_running_pid, + read_runtime_status, + runtime_status_pid_is_live, + ) + + snapshot: dict[str, Optional[int]] = {} + for home in profile_homes: + pid = get_running_pid(home / "gateway.pid", cleanup_stale=False) + if pid is None: + runtime = read_runtime_status(home / "gateway_state.json") + if ( + isinstance(runtime, dict) + and runtime.get("gateway_state") in {"starting", "running", "degraded"} + and runtime_status_pid_is_live(runtime) + ): + raw_pid = runtime.get("pid") + if isinstance(raw_pid, int) and raw_pid > 0: + pid = raw_pid + snapshot[str(home)] = pid + return snapshot + + +def _redacted_manifest(profile_homes: Iterable[Path], target: Path) -> dict[str, Any]: + target_store = _read_json_object(target) if target.exists() else {} + sources: list[dict[str, Any]] = [] + target_providers = _providers(target_store) + for home in profile_homes: + source = home / "auth.json" + config = home / "config.yaml" + profile_id = resolve_auth_authority( + profile_home=home, + shared_root=_root(), + enforce_migration=False, + ).profile_id + store = _read_json_object(source) + providers = sorted(_providers(store)) + sources.append({ + "profile": home.name, + "profile_id": profile_id, + "source_class": "profile-local", + "providers": providers, + "overlapping_providers": sorted(set(providers) & target_providers), + "artifacts": [ + { + "artifact_class": "profile-auth", + "exists": source.exists(), + "profile_id": profile_id, + }, + { + "artifact_class": "profile-config", + "exists": config.exists(), + "profile_id": profile_id, + }, + ], + }) + return { + "operation": "migrate-shared", + "target_class": "shared-root", + "target_exists": target.exists(), + "target_artifact": { + "artifact_class": "shared-auth", + "exists": target.exists(), + }, + "target_providers": sorted(target_providers), + "sources": sources, + } + + +def _manifest_digest(manifest: dict[str, Any]) -> str: + public = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(public).hexdigest() + + +def plan_shared_migration( + *, all_profiles: bool = False, profile: Optional[str] = None +) -> MigrationPlan: + homes = _selected_profiles(all_profiles=all_profiles, profile=profile) + target = (_root() / "auth.json").resolve(strict=False) + gateway_homes = _gateway_homes_for_target(homes, target) + gateway_preconditions = _gateway_snapshot(gateway_homes) + manifest = _redacted_manifest(homes, target) + plan_id = uuid.uuid4().hex + plan_digest = _manifest_digest(manifest) + preconditions = { + str(target): _content_precondition(target), + **{ + str(home / "auth.json"): _content_precondition(home / "auth.json") + for home in homes + }, + **{ + str(home / "config.yaml"): _content_precondition(home / "config.yaml") + for home in homes + }, + } + artifact = _state_dir() / "plans" / f"{plan_id}.json" + _private_json_write( + artifact, + { + "version": 1, + "plan_id": plan_id, + "plan_digest": plan_digest, + "created_at": datetime.now(timezone.utc).isoformat(), + "manifest": manifest, + "target": str(target), + "profile_homes": [str(home) for home in homes], + "gateway_homes": [str(home) for home in gateway_homes], + "gateway_preconditions": gateway_preconditions, + "preconditions": preconditions, + }, + ) + return MigrationPlan(plan_id, plan_digest, manifest, artifact) + + +def _load_plan(plan_id: str, plan_digest: str) -> dict[str, Any]: + if not plan_id or Path(plan_id).name != plan_id: + raise AuthMigrationError("A valid --plan-id is required") + path = _state_dir() / "plans" / f"{plan_id}.json" + if not path.is_file(): + raise AuthMigrationError("Migration plan was not found; run a new dry-run") + plan = _read_json_object(path) + if plan.get("plan_digest") != plan_digest: + raise AuthMigrationError("Plan digest does not match the reviewed dry-run") + if _manifest_digest(plan.get("manifest") or {}) != plan_digest: + raise AuthMigrationError("Migration plan artifact failed integrity validation") + return plan + + +def _merge_section( + target: dict[str, Any], source: dict[str, Any], section: str, policy: str +) -> None: + source_values = source.get(section) + if not isinstance(source_values, dict): + return + target_values = target.setdefault(section, {}) + if not isinstance(target_values, dict): + raise AuthMigrationError(f"Shared store {section} is not a mapping") + for provider, value in source_values.items(): + if provider not in target_values: + target_values[provider] = value + elif target_values[provider] == value or policy == "prefer-shared": + continue + elif policy == "prefer-profile": + target_values[provider] = value + else: + raise AuthMigrationError( + f"Divergent {section} entry for provider {provider!r}; choose an explicit conflict policy" + ) + + +def _shared_authority_config(config_path: Path) -> tuple[dict[str, Any], bytes]: + if config_path.exists(): + with config_path.open("r", encoding="utf-8") as handle: + config = fast_safe_load(handle) or {} + else: + config = {} + if not isinstance(config, dict): + raise AuthMigrationError(f"Config is not a mapping: {config_path}") + auth = config.get("auth") + if auth is None: + auth = {} + if not isinstance(auth, dict): + raise AuthMigrationError(f"auth config is not a mapping: {config_path}") + auth["authority"] = "shared" + auth.pop("path", None) + config["auth"] = auth + stream = io.StringIO() + yaml.dump( + config, + stream, + Dumper=IndentDumper, + default_flow_style=False, + allow_unicode=True, + sort_keys=False, + ) + return config, stream.getvalue().encode("utf-8") + + +def _set_shared_authority( + config_path: Path, config: Optional[dict[str, Any]] = None +) -> None: + if config is None: + config, _ = _shared_authority_config(config_path) + atomic_yaml_write(config_path, config) + try: + config_path.chmod(0o600) + except OSError: + pass + + +def apply_shared_migration( + *, + plan_id: str, + plan_digest: str, + conflict_policy: str, + failure_injector: Optional[Callable[[str], None]] = None, +) -> str: + if conflict_policy not in _POLICIES: + raise AuthMigrationError( + "--conflict-policy must be abort, prefer-shared, or prefer-profile" + ) + plan = _load_plan(plan_id, plan_digest) + target = Path(plan["target"]) + homes = [Path(item) for item in plan.get("profile_homes", [])] + gateway_homes = [Path(item) for item in plan.get("gateway_homes", [])] + paths = sorted( + {target, *(home / "auth.json" for home in homes)}, + key=lambda path: os.fsencode(str(path.resolve(strict=False))), + ) + journal_path = _state_dir() / "journals" / f"{plan_id}.json" + backup_dir = _state_dir() / "backups" / plan_id + journal: dict[str, Any] = { + "version": 1, + "plan_id": plan_id, + "plan_digest": plan_digest, + "phase": "planned", + "target": str(target), + "profile_homes": [str(home) for home in homes], + "backup_dir": str(backup_dir), + "preconditions": plan.get("preconditions", {}), + "gateway_preconditions": plan.get("gateway_preconditions", {}), + } + _private_json_write(journal_path, journal) + if failure_injector: + failure_injector("planned") + + locked_paths = sorted( + {*paths, *(home / "config.yaml" for home in homes)}, + key=lambda path: os.fsencode(str(path.resolve(strict=False))), + ) + with _auth_transition_lock(), _auth_store_locks( + locked_paths, transaction_target=target + ): + gateway_state = _gateway_snapshot(gateway_homes) + running = next( + ((home, pid) for home, pid in gateway_state.items() if pid is not None), + None, + ) + if running is not None: + gateway_home, gateway_pid = running + journal["phase"] = "aborted" + journal["reason"] = "gateway_running" + journal["gateway_home"] = gateway_home + journal["gateway_pid"] = gateway_pid + _private_json_write(journal_path, journal) + raise AuthMigrationError( + f"Relevant gateway PID {gateway_pid} is running for {gateway_home}; " + "stop it and create a new migration plan" + ) + if gateway_state != plan.get("gateway_preconditions", {}): + journal["phase"] = "aborted" + journal["reason"] = "gateway_process_state_changed" + _private_json_write(journal_path, journal) + raise AuthMigrationError( + "Relevant gateway process state changed after dry-run; create a new plan" + ) + journal["phase"] = "locked" + _private_json_write(journal_path, journal) + if failure_injector: + failure_injector("locked") + for raw_path, expected in plan.get("preconditions", {}).items(): + if _content_precondition(Path(raw_path)) != expected: + journal["phase"] = "aborted" + journal["reason"] = "precondition_changed" + _private_json_write(journal_path, journal) + raise AuthMigrationError( + "Migration inputs changed after dry-run; create a new plan" + ) + + backup_dir.mkdir(parents=True, exist_ok=True) + if target.exists(): + shutil.copy2(target, backup_dir / "shared-auth.json") + (backup_dir / "shared-auth.json").chmod(0o600) + for home in homes: + profile_dir = backup_dir / "profiles" / home.name + profile_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(home / "auth.json", profile_dir / "auth.json") + (profile_dir / "auth.json").chmod(0o600) + if (home / "config.yaml").exists(): + shutil.copy2(home / "config.yaml", profile_dir / "config.yaml") + (profile_dir / "config.yaml").chmod(0o600) + journal["phase"] = "backed_up" + journal["backup_preconditions"] = { + str(path): _content_precondition(path) + for path in backup_dir.rglob("*") + if path.is_file() + } + _private_json_write(journal_path, journal) + if failure_injector: + failure_injector("backed_up") + + merged = _load_auth_store(target) + for home in homes: + source = _load_auth_store(home / "auth.json") + _merge_section(merged, source, "providers", conflict_policy) + _merge_section(merged, source, "credential_pool", conflict_policy) + updated_at = datetime.now(timezone.utc).isoformat() + merged["version"] = AUTH_STORE_VERSION + merged["updated_at"] = updated_at + target_expected = _raw_identity( + (json.dumps(merged, indent=2) + "\n").encode("utf-8") + ) + journal["phase"] = "target_write_pending" + journal["target_written_postcondition"] = target_expected + _private_json_write(journal_path, journal) + if failure_injector: + failure_injector("target_write_pending") + _save_auth_store(merged, target_path=target, updated_at=updated_at) + journal["phase"] = "target_written" + _private_json_write(journal_path, journal) + if failure_injector: + failure_injector("target_written") + + journal["profile_config_postconditions"] = {} + for home in homes: + config_path = home / "config.yaml" + _config, config_raw = _shared_authority_config(config_path) + journal["phase"] = "profile_write_pending" + journal["pending_profile_config"] = str(config_path) + journal["profile_config_postconditions"][str(config_path)] = _raw_identity( + config_raw + ) + _private_json_write(journal_path, journal) + if failure_injector: + failure_injector("profile_write_pending") + _set_shared_authority(config_path) + if failure_injector: + failure_injector("profile_written") + journal.pop("pending_profile_config", None) + _private_json_write(journal_path, journal) + journal["phase"] = "profiles_configured" + _private_json_write(journal_path, journal) + if failure_injector: + failure_injector("profiles_configured") + journal["phase"] = "committed" + journal["committed_at"] = datetime.now(timezone.utc).isoformat() + journal["postcondition"] = _content_precondition(target) + journal["config_postconditions"] = { + str(home / "config.yaml"): _content_precondition(home / "config.yaml") + for home in homes + } + _private_json_write(journal_path, journal) + return plan_id + + +def recover_shared_migration(*, plan_id: str) -> str: + """Roll back an incomplete migration from its private recovery journal.""" + if not plan_id or Path(plan_id).name != plan_id: + raise AuthMigrationError("A valid --plan-id is required") + journal_path = _state_dir() / "journals" / f"{plan_id}.json" + if not journal_path.is_file(): + raise AuthMigrationError("Migration journal was not found") + journal = _read_json_object(journal_path) + phase = journal.get("phase") + if phase in {"rolled_back", "aborted", "committed_state_changed"}: + return str(phase) + if phase == "manual_required": + phase = journal.get("resume_phase") + if not phase: + raise AuthMigrationError( + "Migration recovery requires manual intervention; preserved current state" + ) + target = Path(journal["target"]) + homes = [Path(item) for item in journal.get("profile_homes", [])] + backup_dir = Path(journal["backup_dir"]) + + paths = sorted( + { + target, + *(home / "auth.json" for home in homes), + *(home / "config.yaml" for home in homes), + }, + key=lambda path: os.fsencode(str(path.resolve(strict=False))), + ) + with _auth_transition_lock(), _auth_store_locks(paths, transaction_target=target): + + def require_manual(reason: str) -> None: + journal["phase"] = "manual_required" + journal["resume_phase"] = phase + journal["reason"] = reason + journal["manual_required_at"] = datetime.now(timezone.utc).isoformat() + _private_json_write(journal_path, journal) + raise AuthMigrationError( + "Migration state changed after interruption; refusing automatic recovery" + ) + + preconditions = journal.get("preconditions") or {} + if phase == "committed": + committed_ok = _matches_identity(target, journal.get("postcondition")) + expected_configs = journal.get("config_postconditions") or {} + committed_ok = committed_ok and all( + _matches_identity(home / "config.yaml", expected_configs.get(str(home / "config.yaml"))) + for home in homes + ) + if not committed_ok: + journal["phase"] = "committed_state_changed" + journal["reason"] = "committed_state_changed" + journal["committed_state_changed_at"] = datetime.now( + timezone.utc + ).isoformat() + _private_json_write(journal_path, journal) + return "committed_state_changed" + return "committed" + + if phase in {"planned", "locked"}: + changed = any( + not _matches_identity(Path(raw_path), expected) + for raw_path, expected in preconditions.items() + ) + journal["phase"] = "aborted" + journal["reason"] = ( + "precondition_changed" if changed else "interrupted_before_mutation" + ) + journal["aborted_at"] = datetime.now(timezone.utc).isoformat() + _private_json_write(journal_path, journal) + return "aborted" + + if phase == "backed_up": + changed = any( + not _matches_identity(Path(raw_path), expected) + for raw_path, expected in preconditions.items() + ) + journal["phase"] = "aborted" + journal["reason"] = ( + "external_change_after_backup" + if changed + else "interrupted_before_mutation" + ) + journal["aborted_at"] = datetime.now(timezone.utc).isoformat() + _private_json_write(journal_path, journal) + return "aborted" + + target_is_migration = _matches_identity( + target, journal.get("target_written_postcondition") + ) + target_is_original = _matches_identity( + target, preconditions.get(str(target)) + ) + if phase == "target_write_pending": + if not (target_is_migration or target_is_original): + require_manual("external_change_after_backup") + elif not target_is_migration: + require_manual("committed_state_changed") + + expected_configs = journal.get("profile_config_postconditions") or {} + migrated_configs: list[Path] = [] + for home in homes: + config_path = home / "config.yaml" + if _matches_identity(config_path, expected_configs.get(str(config_path))): + migrated_configs.append(config_path) + elif not _matches_identity(config_path, preconditions.get(str(config_path))): + require_manual("committed_state_changed") + + for raw_path, expected in (journal.get("backup_preconditions") or {}).items(): + if not _matches_identity(Path(raw_path), expected): + require_manual("backup_invalid") + + changed_by_migration = target_is_migration or bool(migrated_configs) + if target_is_migration: + target_backup = backup_dir / "shared-auth.json" + if target_backup.is_file(): + _private_bytes_write(target, target_backup.read_bytes()) + else: + target.unlink(missing_ok=True) + for config in migrated_configs: + home = config.parent + config_backup = backup_dir / "profiles" / home.name / "config.yaml" + if config_backup.is_file(): + _private_bytes_write(config, config_backup.read_bytes()) + else: + config.unlink(missing_ok=True) + journal["phase"] = "rolled_back" if changed_by_migration else "aborted" + journal[f"{journal['phase']}_at"] = datetime.now(timezone.utc).isoformat() + journal.pop("resume_phase", None) + _private_json_write(journal_path, journal) + return str(journal["phase"]) + + +def rollback_shared_migration(*, plan_id: str) -> str: + """Explicitly undo a committed migration when post-state is unchanged.""" + if not plan_id or Path(plan_id).name != plan_id: + raise AuthMigrationError("A valid --plan-id is required") + journal_path = _state_dir() / "journals" / f"{plan_id}.json" + if not journal_path.is_file(): + raise AuthMigrationError("Migration journal was not found") + journal = _read_json_object(journal_path) + phase = journal.get("phase") + if phase == "rolled_back": + return "rolled_back" + if phase != "committed": + raise AuthMigrationError( + "Only a committed migration can use --rollback; recover incomplete migrations instead" + ) + + target = Path(journal["target"]) + homes = [Path(item) for item in journal.get("profile_homes", [])] + backup_dir = Path(journal["backup_dir"]) + paths = sorted( + { + target, + *(home / "auth.json" for home in homes), + *(home / "config.yaml" for home in homes), + }, + key=lambda path: os.fsencode(str(path.resolve(strict=False))), + ) + with _auth_transition_lock(), _auth_store_locks(paths, transaction_target=target): + if _content_precondition(target) != journal.get("postcondition"): + raise AuthMigrationError( + "Committed shared auth changed after migration; refusing rollback" + ) + expected_configs = journal.get("config_postconditions") or {} + for home in homes: + config_path = home / "config.yaml" + if _content_precondition(config_path) != expected_configs.get( + str(config_path) + ): + raise AuthMigrationError( + f"Profile config changed after migration: {home.name}; refusing rollback" + ) + for raw_path, expected in (journal.get("backup_preconditions") or {}).items(): + if _content_precondition(Path(raw_path)) != expected: + raise AuthMigrationError( + "Migration backup changed after commit; refusing rollback" + ) + + target_backup = backup_dir / "shared-auth.json" + if target_backup.is_file(): + _private_bytes_write(target, target_backup.read_bytes()) + else: + target.unlink(missing_ok=True) + for home in homes: + config = home / "config.yaml" + config_backup = backup_dir / "profiles" / home.name / "config.yaml" + if config_backup.is_file(): + _private_bytes_write(config, config_backup.read_bytes()) + else: + config.unlink(missing_ok=True) + journal["phase"] = "rolled_back" + journal["rolled_back_at"] = datetime.now(timezone.utc).isoformat() + journal["rollback_kind"] = "explicit_committed_rollback" + _private_json_write(journal_path, journal) + return "rolled_back" + + +def latest_migration_status() -> Optional[dict[str, Any]]: + journals = _state_dir() / "journals" + if not journals.exists(): + return None + candidates = sorted( + journals.glob("*.json"), key=lambda path: path.stat().st_mtime_ns + ) + if not candidates: + return None + journal = _read_json_object(candidates[-1]) + return { + "plan_id": journal.get("plan_id"), + "phase": journal.get("phase"), + "reason": journal.get("reason"), + } diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 3a5b7cd4d3cf..83cfdd0558ba 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -8,6 +8,8 @@ HERMES_HOME root. """ +import base64 +import hashlib import json import logging import os @@ -16,10 +18,12 @@ import sys import tempfile import time +import uuid import zipfile +from contextlib import ExitStack from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional from hermes_constants import get_default_hermes_root, get_hermes_home, display_hermes_home @@ -130,6 +134,470 @@ # relative to the user's home directory, and restored to their original # home-relative location on import. Anything not under home is skipped. _EXTERNAL_PREFIX = "_external/" +_AUTH_MANIFEST = "_auth/manifest.json" +_AUTH_ENVELOPE = "_auth/authority.enc" + + +def _auth_passphrase(args) -> bytes: + raw_path = getattr(args, "auth_passphrase_file", None) + if not raw_path: + raise ValueError( + "encrypted auth passphrase file is required (--auth-passphrase-file)" + ) + value = Path(raw_path).expanduser().read_bytes().rstrip(b"\r\n") + if not value: + raise ValueError("auth passphrase file is empty") + return value + + +def _encrypt_auth(raw: bytes, passphrase: bytes) -> tuple[bytes, dict[str, Any]]: + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + from cryptography.hazmat.primitives.kdf.scrypt import Scrypt + + salt = os.urandom(16) + nonce = os.urandom(12) + key = Scrypt(salt=salt, length=32, n=2**15, r=8, p=1).derive(passphrase) + encrypted = AESGCM(key).encrypt(nonce, raw, b"hermes-auth-backup-v1") + manifest = { + "schema": 1, + "cipher": "AES-256-GCM", + "kdf": "scrypt", + "scrypt": {"n": 2**15, "r": 8, "p": 1}, + "salt": base64.b64encode(salt).decode("ascii"), + "nonce": base64.b64encode(nonce).decode("ascii"), + "sha256": hashlib.sha256(raw).hexdigest(), + } + return encrypted, manifest + + +def _decrypt_auth(encrypted: bytes, manifest: dict[str, Any], passphrase: bytes) -> bytes: + from cryptography.exceptions import InvalidTag + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + from cryptography.hazmat.primitives.kdf.scrypt import Scrypt + + params = manifest["scrypt"] + salt = base64.b64decode(manifest["salt"], validate=True) + nonce = base64.b64decode(manifest["nonce"], validate=True) + key = Scrypt( + salt=salt, + length=32, + n=int(params["n"]), + r=int(params["r"]), + p=int(params["p"]), + ).derive(passphrase) + try: + raw = AESGCM(key).decrypt(nonce, encrypted, b"hermes-auth-backup-v1") + except InvalidTag as exc: + raise ValueError("auth backup passphrase or ciphertext is invalid") from exc + if hashlib.sha256(raw).hexdigest() != manifest["sha256"]: + raise ValueError("decrypted auth digest does not match backup manifest") + return raw + + +def _atomic_private_write(path: Path, raw: bytes) -> None: + """Atomically replace a private state file with mode 0600.""" + path.parent.mkdir(parents=True, exist_ok=True) + staged_path: Optional[Path] = None + try: + with tempfile.NamedTemporaryFile(dir=str(path.parent), delete=False) as staged: + staged.write(raw) + staged.flush() + os.fsync(staged.fileno()) + staged_path = Path(staged.name) + os.chmod(staged_path, 0o600) + os.replace(staged_path, path) + directory_fd = os.open( + str(path.parent), os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) + ) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + if staged_path is not None: + staged_path.unlink(missing_ok=True) + + +_AUTH_RESTORE_TERMINAL_PHASES = frozenset({"committed", "rolled_back", "aborted"}) + + +def _restore_identity(path: Path) -> dict[str, Any]: + if not path.is_file(): + return {"exists": False} + raw = path.read_bytes() + return { + "exists": True, + "sha256": hashlib.sha256(raw).hexdigest(), + "size": len(raw), + } + + +def _restore_matches(path: Path, expected: Optional[dict[str, Any]]) -> bool: + return expected is not None and _restore_identity(path) == expected + + +def _restore_journals_root() -> Path: + return get_default_hermes_root() / "state-snapshots" / "auth-restores" + + +def _write_restore_journal(path: Path, journal: dict[str, Any]) -> None: + _atomic_private_write( + path, (json.dumps(journal, indent=2, sort_keys=True) + "\n").encode("utf-8") + ) + + +def _restore_previous(path: Path, backup: Path, expected: dict[str, Any]) -> None: + if expected.get("exists"): + if not backup.is_file(): + raise RuntimeError(f"auth restore backup is missing: {backup}") + _atomic_private_write(path, backup.read_bytes()) + else: + path.unlink(missing_ok=True) + directory_fd = os.open( + str(path.parent), os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) + ) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + if not _restore_matches(path, expected): + raise RuntimeError(f"auth restore rollback verification failed: {path.name}") + + +def _recover_auth_restore_journal(journal_path: Path) -> str: + from hermes_cli.auth import _auth_transition_lock + + with _auth_transition_lock(): + return _recover_auth_restore_journal_locked(journal_path) + + +def _recover_auth_restore_journal_locked(journal_path: Path) -> str: + from hermes_cli.auth import _auth_store_locks + + journal = json.loads(journal_path.read_text(encoding="utf-8")) + phase = str(journal.get("phase") or "unknown") + if phase in _AUTH_RESTORE_TERMINAL_PHASES: + return phase + + root = get_default_hermes_root().resolve(strict=False) + auth_target = Path(journal["auth_target"]).resolve(strict=False) + config_path = Path(journal["config_path"]).resolve(strict=False) + for path in (auth_target, config_path): + try: + path.relative_to(root) + except ValueError as exc: + raise RuntimeError("auth restore journal target escapes Hermes root") from exc + + current_dir = Path(journal["current_dir"]).resolve(strict=False) + try: + current_dir.relative_to(root) + except ValueError as exc: + raise RuntimeError("auth restore backup path escapes Hermes root") from exc + + old_auth = journal["preconditions"]["auth"] + old_config = journal["preconditions"]["config"] + new_auth = journal["postconditions"]["auth"] + new_config = journal["postconditions"]["config"] + lock_paths = sorted( + {auth_target, config_path}, + key=lambda path: os.fsencode(str(path.resolve(strict=False))), + ) + + with _auth_store_locks(lock_paths, transaction_target=auth_target): + auth_is_old = _restore_matches(auth_target, old_auth) + auth_is_new = _restore_matches(auth_target, new_auth) + config_is_old = _restore_matches(config_path, old_config) + config_is_new = _restore_matches(config_path, new_config) + + if phase in {"planned", "backed_up"} and auth_is_old and config_is_old: + journal["phase"] = "aborted" + journal["aborted_at"] = datetime.now(timezone.utc).isoformat() + _write_restore_journal(journal_path, journal) + return "aborted" + + if auth_is_new and config_is_new: + journal["phase"] = "committed" + journal["committed_at"] = datetime.now(timezone.utc).isoformat() + _write_restore_journal(journal_path, journal) + return "committed" + + if not (auth_is_old or auth_is_new) or not (config_is_old or config_is_new): + journal["phase"] = "manual_required" + journal["reason"] = "restore_state_changed" + journal["manual_required_at"] = datetime.now(timezone.utc).isoformat() + _write_restore_journal(journal_path, journal) + raise RuntimeError( + "auth restore state changed after interruption; manual recovery required" + ) + + backup_identities = journal.get("backup_identities") or {} + backup_paths = { + "auth": current_dir / "auth.json", + "config": current_dir / "config.yaml", + } + invalid_backups = [ + name + for name, backup_path in backup_paths.items() + if not _restore_matches(backup_path, backup_identities.get(name)) + ] + if invalid_backups: + journal["phase"] = "manual_required" + journal["reason"] = "backup_verification_failed" + journal["manual_required_at"] = datetime.now(timezone.utc).isoformat() + _write_restore_journal(journal_path, journal) + raise RuntimeError( + "auth restore backup verification failed: " + + ", ".join(invalid_backups) + ) + + try: + _restore_previous(auth_target, backup_paths["auth"], old_auth) + _restore_previous(config_path, backup_paths["config"], old_config) + except Exception: + journal["phase"] = "manual_required" + journal["reason"] = "rollback_verification_failed" + journal["manual_required_at"] = datetime.now(timezone.utc).isoformat() + _write_restore_journal(journal_path, journal) + raise + + journal["phase"] = ( + "aborted" if phase in {"planned", "backed_up"} else "rolled_back" + ) + journal[f"{journal['phase']}_at"] = datetime.now(timezone.utc).isoformat() + _write_restore_journal(journal_path, journal) + return str(journal["phase"]) + + +def _recover_incomplete_auth_restores() -> list[str]: + journals_dir = _restore_journals_root() / "journals" + if not journals_dir.is_dir(): + return [] + results: list[str] = [] + for journal_path in sorted(journals_dir.glob("*.json")): + journal = json.loads(journal_path.read_text(encoding="utf-8")) + if journal.get("phase") not in _AUTH_RESTORE_TERMINAL_PHASES: + results.append(_recover_auth_restore_journal(journal_path)) + return results + + +def _restore_auth_transactionally( + raw: bytes, + auth_action: str, + *, + config_home: Optional[Path] = None, + restored_config_raw: Optional[bytes] = None, + failure_injector: Optional[Callable[[str], None]] = None, +) -> None: + from hermes_cli.auth import _auth_transition_lock + + with _auth_transition_lock(): + _restore_auth_transaction_locked( + raw, + auth_action, + config_home=config_home, + restored_config_raw=restored_config_raw, + failure_injector=failure_injector, + ) + + +def _restore_auth_transaction_locked( + raw: bytes, + auth_action: str, + *, + config_home: Optional[Path] = None, + restored_config_raw: Optional[bytes] = None, + failure_injector: Optional[Callable[[str], None]] = None, +) -> None: + """Durably commit auth plus topology config, recovering interrupted restores.""" + import yaml + from hermes_cli.auth import _auth_store_locks, _validate_auth_store_structure + + if auth_action not in {"restore-shared", "restore-profile"}: + raise ValueError(f"unsupported auth restore action: {auth_action}") + _validate_auth_store_structure(json.loads(raw.decode("utf-8"))) + + _recover_incomplete_auth_restores() + home = Path(config_home or get_hermes_home()).resolve(strict=False) + authority = "shared" if auth_action == "restore-shared" else "profile" + auth_target = ( + get_default_hermes_root() / "auth.json" + if authority == "shared" + else home / "auth.json" + ).resolve(strict=False) + config_path = home / "config.yaml" + lock_paths = sorted( + {auth_target, config_path}, + key=lambda path: os.fsencode(str(path.resolve(strict=False))), + ) + + with _auth_store_locks(lock_paths, transaction_target=auth_target): + # Close the gateway-start window between the unlocked preflight and + # authority mutation. The transition gate and both store/config locks + # are held here, so a successful check remains authoritative through + # the commit below. + _assert_auth_restore_quiescent(home, auth_action) + old_auth = auth_target.read_bytes() if auth_target.exists() else None + old_config = config_path.read_bytes() if config_path.exists() else None + config: dict[str, Any] = {} + config_source = ( + restored_config_raw if restored_config_raw is not None else old_config + ) + if config_source is not None: + loaded = yaml.safe_load(config_source.decode("utf-8")) or {} + if not isinstance(loaded, dict): + raise ValueError("config.yaml must contain a YAML mapping") + config = loaded + auth_config = config.setdefault("auth", {}) + if not isinstance(auth_config, dict): + auth_config = {} + config["auth"] = auth_config + auth_config["authority"] = authority + config_raw = yaml.safe_dump( + config, sort_keys=False, default_flow_style=False + ).encode("utf-8") + + operation_id = uuid.uuid4().hex + restore_root = _restore_journals_root() + current_dir = restore_root / "current-store" / operation_id + current_dir.mkdir(parents=True, mode=0o700) + current_dir.chmod(0o700) + journal_path = restore_root / "journals" / f"{operation_id}.json" + journal = { + "version": 1, + "operation_id": operation_id, + "phase": "planned", + "auth_action": auth_action, + "auth_target": str(auth_target), + "config_path": str(config_path), + "current_dir": str(current_dir), + "preconditions": { + "auth": _restore_identity(auth_target), + "config": _restore_identity(config_path), + }, + "postconditions": { + "auth": { + "exists": True, + "sha256": hashlib.sha256(raw).hexdigest(), + "size": len(raw), + }, + "config": { + "exists": True, + "sha256": hashlib.sha256(config_raw).hexdigest(), + "size": len(config_raw), + }, + }, + } + _write_restore_journal(journal_path, journal) + if failure_injector: + failure_injector("planned") + + if old_auth is not None: + _atomic_private_write(current_dir / "auth.json", old_auth) + if old_config is not None: + _atomic_private_write(current_dir / "config.yaml", old_config) + journal["phase"] = "backed_up" + journal["backup_identities"] = { + "auth": _restore_identity(current_dir / "auth.json"), + "config": _restore_identity(current_dir / "config.yaml"), + } + _write_restore_journal(journal_path, journal) + if failure_injector: + failure_injector("backed_up") + + try: + journal["phase"] = "auth_write_pending" + _write_restore_journal(journal_path, journal) + if failure_injector: + failure_injector("auth_write_pending") + _atomic_private_write(auth_target, raw) + if failure_injector: + failure_injector("auth_written") + journal["phase"] = "auth_written" + _write_restore_journal(journal_path, journal) + + journal["phase"] = "config_write_pending" + _write_restore_journal(journal_path, journal) + if failure_injector: + failure_injector("config_write_pending") + _atomic_private_write(config_path, config_raw) + if failure_injector: + failure_injector("config_written") + journal["phase"] = "config_written" + _write_restore_journal(journal_path, journal) + + if not _restore_matches(auth_target, journal["postconditions"]["auth"]): + raise RuntimeError("restored auth postcondition verification failed") + if not _restore_matches(config_path, journal["postconditions"]["config"]): + raise RuntimeError("restored config postcondition verification failed") + journal["phase"] = "committed" + journal["committed_at"] = datetime.now(timezone.utc).isoformat() + _write_restore_journal(journal_path, journal) + except Exception: + _restore_previous( + auth_target, current_dir / "auth.json", journal["preconditions"]["auth"] + ) + _restore_previous( + config_path, + current_dir / "config.yaml", + journal["preconditions"]["config"], + ) + journal["phase"] = "rolled_back" + journal["rolled_back_at"] = datetime.now(timezone.utc).isoformat() + _write_restore_journal(journal_path, journal) + raise + + +def _assert_auth_restore_quiescent(home: Path, auth_action: str) -> None: + """Fail closed if any gateway can write the selected auth authority.""" + from gateway.status import ( + get_running_pid, + read_runtime_status, + runtime_status_pid_is_live, + ) + from hermes_cli.auth_authority import resolve_auth_authority + + if auth_action not in {"restore-shared", "restore-profile"}: + raise ValueError(f"unsupported auth restore action: {auth_action}") + root = get_default_hermes_root() + target = ( + root / "auth.json" + if auth_action == "restore-shared" + else home / "auth.json" + ).resolve(strict=False) + candidates = {root.resolve(strict=False), home.resolve(strict=False)} + profiles_root = root / "profiles" + if profiles_root.is_dir(): + candidates.update( + path.resolve(strict=False) + for path in profiles_root.iterdir() + if path.is_dir() + ) + + for candidate in sorted(candidates, key=lambda path: os.fsencode(str(path))): + authority = resolve_auth_authority( + profile_home=candidate, + shared_root=root, + enforce_migration=False, + enforce_restore=False, + ) + if authority.auth_path.resolve(strict=False) != target: + continue + pid = get_running_pid(candidate / "gateway.pid", cleanup_stale=False) + if pid is None: + runtime = read_runtime_status(candidate / "gateway_state.json") + if ( + isinstance(runtime, dict) + and runtime.get("gateway_state") in {"starting", "running", "degraded"} + and runtime_status_pid_is_live(runtime) + ): + raw_pid = runtime.get("pid") + if isinstance(raw_pid, int) and raw_pid > 0: + pid = raw_pid + if pid is not None: + raise RuntimeError( + f"gateway for {candidate} is running (PID {pid}); stop all " + "gateways sharing the auth authority before restore" + ) def _collect_memory_provider_external_paths() -> List[Path]: @@ -235,6 +703,8 @@ def _should_exclude(rel_path: Path) -> bool: def _should_skip_backup_file(abs_path: Path, rel_path: Path, out_path: Path) -> bool: """Return True when a candidate file should not be written to a backup zip.""" + if rel_path.name in {"auth.json", "auth.lock"}: + return True if _should_exclude(rel_path): return True @@ -509,6 +979,22 @@ def run_backup(args) -> None: print(f"Error: Hermes home directory not found at {hermes_root}") sys.exit(1) + auth_mode = getattr(args, "auth_mode", "exclude") + encrypted_auth: Optional[tuple[bytes, dict[str, Any]]] = None + if auth_mode == "include-encrypted": + from hermes_cli.auth import _auth_store_locks + from hermes_cli.auth_authority import resolve_auth_authority + + with _auth_store_locks() as (auth_path, _fallback): + authority = resolve_auth_authority() + if not auth_path.is_file(): + raise ValueError("authoritative auth store does not exist") + encrypted, manifest = _encrypt_auth( + auth_path.read_bytes(), _auth_passphrase(args) + ) + manifest["authority"] = authority.effective_mode + encrypted_auth = encrypted, manifest + # Determine output path if args.output: out_path = Path(args.output).expanduser().resolve() @@ -552,6 +1038,8 @@ def run_backup(args) -> None: fpath = dp / fname rel = fpath.relative_to(hermes_root) + if fpath.name in {"auth.json", "auth.lock"}: + continue if _should_skip_backup_file(fpath, rel, out_path): continue @@ -581,12 +1069,16 @@ def run_backup(args) -> None: arcname = _EXTERNAL_PREFIX + rel_to_home.as_posix() external_to_add.append((fpath, arcname)) - if not files_to_add and not external_to_add: + if not files_to_add and not external_to_add and encrypted_auth is None: print("No files to back up.") return # Create the zip - file_count = len(files_to_add) + len(external_to_add) + file_count = ( + len(files_to_add) + + len(external_to_add) + + (2 if encrypted_auth else 0) + ) print(f"Backing up {file_count} files ...") total_bytes = 0 @@ -594,6 +1086,13 @@ def run_backup(args) -> None: t0 = time.monotonic() with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf: + if encrypted_auth is not None: + encrypted, manifest = encrypted_auth + zf.writestr(_AUTH_ENVELOPE, encrypted) + zf.writestr( + _AUTH_MANIFEST, + json.dumps(manifest, sort_keys=True) + "\n", + ) for i, (abs_path, rel_path) in enumerate(files_to_add, 1): try: # Safe copy for SQLite databases (handles WAL mode) @@ -760,6 +1259,56 @@ def run_import(args) -> None: members = [n for n in zf.namelist() if not n.endswith("/")] file_count = len(members) + legacy_auth = [ + name + for name in members + if Path(name).name == "auth.json" and not name.startswith("_auth/") + ] + if legacy_auth: + print( + "Error: backup contains auth.json without an unambiguous " + "authority manifest; import refused" + ) + sys.exit(1) + + auth_action = getattr(args, "auth_action", "skip") + has_encrypted_auth = ( + _AUTH_MANIFEST in members and _AUTH_ENVELOPE in members + ) + restored_auth_raw: Optional[bytes] = None + if auth_action != "skip": + if not has_encrypted_auth: + print("Error: backup contains no encrypted auth authority") + sys.exit(1) + try: + manifest = json.loads(zf.read(_AUTH_MANIFEST)) + restored_auth_raw = _decrypt_auth( + zf.read(_AUTH_ENVELOPE), + manifest, + _auth_passphrase(args), + ) + from hermes_cli.auth import _validate_auth_store_structure + + _validate_auth_store_structure(json.loads(restored_auth_raw)) + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: + print(f"Error: encrypted auth restore failed: {exc}") + sys.exit(1) + expected_authority = ( + "shared" if auth_action == "restore-shared" else "profile" + ) + if manifest.get("authority") != expected_authority: + print( + "Error: auth backup topology does not match the selected " + f"restore action ({manifest.get('authority')!r} vs " + f"{expected_authority!r})" + ) + sys.exit(1) + try: + _assert_auth_restore_quiescent(get_hermes_home(), auth_action) + except (OSError, RuntimeError, ValueError) as exc: + print(f"Error: auth restore preflight failed: {exc}") + sys.exit(1) + print(f"Backup contains {file_count} files") print(f"Target: {display_hermes_home()}") @@ -791,11 +1340,14 @@ def run_import(args) -> None: errors = [] restored = 0 restored_external = 0 + restored_config_raw: Optional[bytes] = None skipped_runtime: list[str] = [] home_dir = Path.home().resolve() t0 = time.monotonic() for member in members: + if member in {_AUTH_MANIFEST, _AUTH_ENVELOPE}: + continue # External memory-provider state captured under the reserved # ``_external/`` arc prefix restores to its original home-relative # location (e.g. ~/.honcho/config.json), NOT under HERMES_HOME. @@ -837,6 +1389,23 @@ def run_import(args) -> None: if not rel: continue + # When auth is restored, config.yaml is part of the authority + # transaction below. Do not overwrite its rollback preimage during + # ordinary archive extraction. + if restored_auth_raw is not None: + active_home = get_hermes_home().resolve(strict=False) + try: + active_config_rel = str( + (active_home / "config.yaml").relative_to( + hermes_root.resolve(strict=False) + ) + ) + except ValueError: + active_config_rel = "config.yaml" + if rel == active_config_rel: + restored_config_raw = zf.read(member) + continue + # Never overwrite volatile gateway/process runtime state. These are # namespaced to the machine/container the backup was taken on; # clobbering them (especially gateway_state.json) breaks the gateway @@ -871,6 +1440,18 @@ def run_import(args) -> None: elapsed = time.monotonic() - t0 + if restored_auth_raw is not None: + try: + _restore_auth_transactionally( + restored_auth_raw, + auth_action, + config_home=get_hermes_home(), + restored_config_raw=restored_config_raw, + ) + except Exception as exc: + print(f"Error: transactional auth restore failed: {exc}") + sys.exit(1) + # Summary print() print(f"Import complete: {restored} files restored in {elapsed:.1f}s") @@ -972,7 +1553,6 @@ def run_import(args) -> None: "state.db", "config.yaml", ".env", - "auth.json", "cron/jobs.json", "cron/executions.db", "gateway_state.json", @@ -1013,6 +1593,8 @@ def create_quick_snapshot( hermes_home: Optional[Path] = None, keep: Optional[int] = None, max_file_size: Optional[int] = None, + auth_mode: str = "exclude", + auth_passphrase_file: Optional[str] = None, ) -> Optional[str]: """Create a quick state snapshot of critical files. @@ -1034,6 +1616,15 @@ def create_quick_snapshot( """ home = hermes_home or get_hermes_home() root = _quick_snapshot_root(home) + auth_passphrase: Optional[bytes] = None + if auth_mode == "include-encrypted": + from types import SimpleNamespace + + # Fail before creating a partial snapshot when encrypted auth was + # explicitly requested but cannot be produced. + auth_passphrase = _auth_passphrase( + SimpleNamespace(auth_passphrase_file=auth_passphrase_file) + ) def _too_large(path: Path, rel_name: str) -> bool: """True (and warn) when ``path`` exceeds the max_file_size cap.""" @@ -1063,6 +1654,7 @@ def _too_large(path: Path, rel_name: str) -> bool: snap_dir.mkdir(parents=True, exist_ok=True) manifest: Dict[str, int] = {} # rel_path -> file size + authority_manifest: Optional[Dict[str, Any]] = None failed_dbs: list[str] = [] # present *.db that could not be snapshotted # #68805: track protected DB files skipped for size — they are snapshot # incompleteness just like a failed copy, so pruning must be suppressed @@ -1149,6 +1741,31 @@ def _too_large(path: Path, rel_name: str) -> bool: except (OSError, PermissionError) as exc: logger.warning("Could not snapshot %s: %s", rel, exc) + if auth_mode == "include-encrypted": + assert auth_passphrase is not None + try: + from hermes_cli.auth import _auth_store_locks + from hermes_cli.auth_authority import resolve_auth_authority + + encrypted_result: Optional[tuple[bytes, dict[str, Any]]] = None + with _auth_store_locks() as (auth_src, _fallback): + authority = resolve_auth_authority(profile_home=home) + if auth_src.is_file() and not _too_large(auth_src, "auth authority"): + encrypted_result = _encrypt_auth( + auth_src.read_bytes(), + auth_passphrase, + ) + if encrypted_result is not None: + encrypted, authority_manifest = encrypted_result + authority_manifest["authority"] = authority.effective_mode + auth_dst = snap_dir / _AUTH_ENVELOPE + auth_dst.parent.mkdir(parents=True, exist_ok=True) + auth_dst.write_bytes(encrypted) + auth_dst.chmod(0o600) + manifest[_AUTH_ENVELOPE] = auth_dst.stat().st_size + except (OSError, PermissionError, RuntimeError, ValueError) as exc: + logger.warning("Could not snapshot auth authority: %s", exc) + if failed_dbs: # Critical: update path used to log-and-continue with exit 0, so a # missing state.db backup looked like a successful pre-update snapshot @@ -1184,6 +1801,7 @@ def _too_large(path: Path, rel_name: str) -> bool: "file_count": len(manifest), "total_size": sum(manifest.values()), "files": manifest, + "auth_authority": authority_manifest, "failed_dbs": failed_dbs, "oversized_skipped": oversized_skipped, } @@ -1249,6 +1867,10 @@ def list_quick_snapshots( def restore_quick_snapshot( snapshot_id: str, hermes_home: Optional[Path] = None, + *, + include_auth: bool = False, + auth_action: str = "skip", + auth_passphrase_file: Optional[str] = None, ) -> bool: """Restore state from a quick snapshot. @@ -1283,8 +1905,49 @@ def restore_quick_snapshot( with open(manifest_path, encoding="utf-8") as f: meta = json.load(f) + auth_restore_raw: Optional[bytes] = None + if include_auth: + if auth_action not in {"restore-shared", "restore-profile"}: + logger.error("include_auth requires an explicit auth_action") + return False + archived = meta.get("auth_authority") + if not isinstance(archived, dict): + logger.error("Snapshot contains no encrypted auth authority") + return False + expected_authority = ( + "shared" if auth_action == "restore-shared" else "profile" + ) + if archived.get("authority") != expected_authority: + logger.error( + "auth backup topology does not match the selected restore action" + ) + return False + try: + from types import SimpleNamespace + + envelope = snap_dir / _AUTH_ENVELOPE + envelope.resolve().relative_to(snap_dir.resolve()) + passphrase = _auth_passphrase( + SimpleNamespace(auth_passphrase_file=auth_passphrase_file) + ) + auth_restore_raw = _decrypt_auth( + envelope.read_bytes(), + archived, + passphrase, + ) + from hermes_cli.auth import _validate_auth_store_structure + + _validate_auth_store_structure(json.loads(auth_restore_raw)) + _assert_auth_restore_quiescent(home, auth_action) + except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc: + logger.error("Auth restore preflight failed: %s", exc) + return False + restored = 0 + restored_config_raw: Optional[bytes] = None for rel in meta.get("files", {}): + if rel.startswith(("_auth_authority/", "_auth/")): + continue # Security: reject absolute paths and traversals in manifest entries src = snap_dir / rel try: @@ -1303,6 +1966,10 @@ def restore_quick_snapshot( if not src.exists(): continue + if auth_restore_raw is not None and rel == "config.yaml": + restored_config_raw = src.read_bytes() + continue + dst.parent.mkdir(parents=True, exist_ok=True) try: @@ -1318,6 +1985,19 @@ def restore_quick_snapshot( except (OSError, PermissionError) as exc: logger.error("Failed to restore %s: %s", rel, exc) + if auth_restore_raw is not None: + try: + _restore_auth_transactionally( + auth_restore_raw, + auth_action, + config_home=home, + restored_config_raw=restored_config_raw, + ) + restored += 1 + except (OSError, PermissionError, RuntimeError, ValueError) as exc: + logger.error("Failed to restore auth authority: %s", exc) + return False + logger.info("Restored %d files from snapshot %s", restored, snapshot_id) return restored > 0 @@ -1467,7 +2147,11 @@ def prune_quick_snapshots( def run_quick_backup(args) -> None: """CLI entry point for hermes backup --quick.""" label = getattr(args, "label", None) - snap_id = create_quick_snapshot(label=label) + snap_id = create_quick_snapshot( + label=label, + auth_mode=getattr(args, "auth_mode", "exclude"), + auth_passphrase_file=getattr(args, "auth_passphrase_file", None), + ) if snap_id: print(f"State snapshot created: {snap_id}") snaps = list_quick_snapshots() diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index c9ae87938048..6611eb14b6c3 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -282,7 +282,8 @@ def _handle_snapshot_command(self, command: str): Syntax: /snapshot — list recent snapshots /snapshot create [label] — create a snapshot - /snapshot restore — restore state from snapshot + /snapshot restore — restore state from snapshot (auth skipped) + /snapshot restore --include-auth --auth-action /snapshot prune [N] — prune to N snapshots (default 20) """ from hermes_cli.backup import ( @@ -324,7 +325,11 @@ def _handle_snapshot_command(self, command: str): elif subcmd in {"restore", "rewind"}: if len(parts) < 3: - print(" Usage: /snapshot restore ") + print( + " Usage: /snapshot restore " + "[--include-auth --auth-action restore-shared|restore-profile " + "--auth-passphrase-file PATH]" + ) # Show hint with most recent snapshot snaps = list_quick_snapshots(limit=1) if snaps: @@ -342,8 +347,57 @@ def _handle_snapshot_command(self, command: str): return except ValueError: pass - if restore_quick_snapshot(snap_id): + + options = parts[3:] + include_auth = "--include-auth" in options + auth_action = "skip" + passphrase_file = None + try: + if "--auth-action" in options: + index = options.index("--auth-action") + auth_action = options[index + 1] + if "--auth-passphrase-file" in options: + index = options.index("--auth-passphrase-file") + passphrase_file = options[index + 1] + except IndexError: + print(" Missing value for auth restore option.") + return + known_options = { + "--include-auth", + "--auth-action", + "restore-shared", + "restore-profile", + "--auth-passphrase-file", + passphrase_file, + } + unknown = [option for option in options if option not in known_options] + if unknown: + print(f" Unknown restore option: {unknown[0]}") + return + if include_auth and ( + auth_action not in {"restore-shared", "restore-profile"} + or not passphrase_file + ): + print( + " --include-auth requires --auth-action restore-shared|restore-profile " + "and --auth-passphrase-file PATH." + ) + return + if not include_auth and ( + auth_action != "skip" or passphrase_file is not None + ): + print(" Auth restore options require --include-auth.") + return + + if restore_quick_snapshot( + snap_id, + include_auth=include_auth, + auth_action=auth_action, + auth_passphrase_file=passphrase_file, + ): print(f" Restored state from: {snap_id}") + if not include_auth: + print(" Authentication was skipped.") print(" Restart recommended for state.db changes to take effect.") else: print(f" Snapshot not found: {snap_id}") @@ -361,7 +415,11 @@ def _handle_snapshot_command(self, command: str): else: print(f" Unknown subcommand: {subcmd}") - print(" Usage: /snapshot [list|create [label]|restore |prune [N]]") + print( + " Usage: /snapshot [list|create [label]|restore " + "[--include-auth --auth-action restore-shared|restore-profile " + "--auth-passphrase-file PATH]|prune [N]]" + ) def _handle_stop_command(self): """Handle /stop — kill all running background processes and diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 18ea422f3e24..2cf571d0fc43 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -752,6 +752,67 @@ def run_doctor(args): print(color("│ 🩺 Hermes Doctor │", Colors.CYAN)) print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN)) + _section("Authentication Authority") + try: + from hermes_cli.auth_authority import auth_authority_status + + authorities = [("active", auth_authority_status())] + if getattr(args, "all_profiles", False): + from hermes_cli.profiles import _get_default_hermes_home + + root = _get_default_hermes_home() + profiles_root = root / "profiles" + homes = [("default", root)] + if profiles_root.is_dir(): + homes.extend( + (path.name, path) + for path in sorted(profiles_root.iterdir(), key=lambda item: item.name) + if path.is_dir() + ) + authorities = [ + ( + name, + auth_authority_status(profile_home=home, shared_root=root), + ) + for name, home in homes + ] + for label, authority in authorities: + suffix = f" [{label}]" if getattr(args, "all_profiles", False) else "" + check_ok( + f"Auth authority{suffix}: {authority['effective_mode']}", + f"({authority['provenance']})", + ) + if authority["legacy_compatibility"]: + check_warn( + f"Legacy profile-local auth store is active{suffix}", + "set auth.authority explicitly or run hermes auth migrate", + ) + if authority["conflicting_store"]: + check_warn( + f"A non-authoritative auth store also exists{suffix}", + "remove it or migrate it explicitly", + ) + if authority["exists"] and authority["permissions"] not in { + "-rw-------", + None, + }: + check_warn( + f"Auth store permissions are broader than 0600{suffix}", + authority["permissions"], + ) + if authority.get("migration"): + migration = authority["migration"] + check_fail( + f"Auth migration {migration['plan_id']} is incomplete{suffix}", + "run hermes auth migrate-shared --recover --plan-id " + f"{migration['plan_id']}", + ) + except Exception as exc: + check_fail("Authentication authority cannot be resolved", str(exc)) + manual_issues.append( + "Fix auth.authority in config.yaml before using authentication commands" + ) + _section("Security Advisories") try: from hermes_cli.security_advisories import ( diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 69651b671a8e..d39abb22c75c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1024,20 +1024,18 @@ def _has_any_provider_configured() -> bool: except Exception: pass - # Check for Nous Portal OAuth credentials - auth_file = get_hermes_home() / "auth.json" - if auth_file.exists(): - try: - import json + # Check for configured auth credentials through the canonical authority. + try: + from hermes_cli.auth import get_auth_status, _load_auth_store + from hermes_cli.auth_authority import get_auth_store_path - auth = json.loads(auth_file.read_text(encoding="utf-8")) - active = auth.get("active_provider") - if active: - status = get_auth_status(active) - if status.get("logged_in"): - return True - except Exception: - pass + auth_file = get_auth_store_path() + auth = _load_auth_store(auth_file) + active = auth.get("active_provider") + if active and get_auth_status(active).get("logged_in"): + return True + except Exception: + pass # Check config.yaml — if model is a dict with an explicit provider set, # the user has gone through setup (fresh installs have model as a plain @@ -2507,9 +2505,65 @@ def _resolve_use_tui(args) -> bool: def cmd_chat(args): - """Run interactive chat CLI.""" + """Claim result-metadata descriptors before config or agent startup.""" + + from hermes_cli import result_metadata + + owner = None + raw_fd = getattr(args, "result_meta_fd", None) + if raw_fd is not None: + try: + owner = result_metadata.claim_result_metadata_fd(raw_fd) + except result_metadata.ResultMetadataError: + print(result_metadata.PUBLIC_ERROR_MESSAGE, file=sys.stderr) + raise SystemExit(2) from None + args.result_meta_fd = owner + try: + return _cmd_chat(args) + finally: + if owner is not None: + try: + owner.close() + except result_metadata.ResultMetadataError: + print(result_metadata.PUBLIC_ERROR_MESSAGE, file=sys.stderr) + raise SystemExit(1) from None + + +def _cmd_chat(args): + """Run interactive chat CLI after descriptor ownership is established.""" use_tui = _resolve_use_tui(args) + result_meta_file = getattr(args, "result_meta_file", None) + result_meta_fd = getattr(args, "result_meta_fd", None) + if result_meta_file is not None and result_meta_fd is not None: + print("Error: choose only one result metadata transport.", file=sys.stderr) + raise SystemExit(2) + if result_meta_file: + if not getattr(args, "query", None): + print("Error: --result-meta-file requires --query.", file=sys.stderr) + raise SystemExit(2) + if use_tui: + print("Error: --result-meta-file is available only in the classic CLI.", file=sys.stderr) + raise SystemExit(2) + from hermes_cli.result_metadata import ( + PUBLIC_ERROR_MESSAGE, + ResultMetadataError, + validate_result_metadata_destination, + ) + + try: + validate_result_metadata_destination(result_meta_file) + except ResultMetadataError: + print(PUBLIC_ERROR_MESSAGE, file=sys.stderr) + raise SystemExit(2) from None + if result_meta_fd is not None: + if not getattr(args, "query", None): + print("Error: --result-meta-fd requires --query.", file=sys.stderr) + raise SystemExit(2) + if use_tui: + print("Error: --result-meta-fd is available only in the classic CLI.", file=sys.stderr) + raise SystemExit(2) + _apply_safe_mode(args) # Resolve --continue into --resume with the latest session or by name @@ -2698,6 +2752,8 @@ def cmd_chat(args): "verbose": getattr(args, "verbose", None), "quiet": getattr(args, "quiet", False), "query": args.query, + "result_meta_file": result_meta_file, + "result_meta_fd": result_meta_fd, "image": getattr(args, "image", None), "resume": getattr(args, "resume", None), "worktree": getattr(args, "worktree", False), @@ -9296,6 +9352,7 @@ def cmd_profile(args): clone_config=clone_config, no_alias=no_alias, no_skills=no_skills, + auth_mode=getattr(args, "auth_mode", "shared"), description=getattr(args, "description", None), ) print(f"\nProfile '{name}' created at {profile_dir}") @@ -9393,8 +9450,9 @@ def cmd_profile(args): elif action == "delete": name = args.profile_name yes = getattr(args, "yes", False) + auth_action = getattr(args, "auth_action", None) try: - delete_profile(name, yes=yes) + delete_profile(name, yes=yes, auth_action=auth_action) except (ValueError, FileNotFoundError) as e: print(f"Error: {e}") sys.exit(1) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index bbeea8a5f0c8..b6502f08561f 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -3081,9 +3081,12 @@ def _credential_fingerprint(provider: str) -> str: # OAuth / external-file mtimes that change on re-auth try: + from hermes_cli.auth_authority import get_auth_store_path from hermes_constants import get_hermes_home - for rel in ("auth.json", "credentials.json"): - p = get_hermes_home() / rel + for rel, p in ( + ("auth.json", get_auth_store_path()), + ("credentials.json", get_hermes_home() / "credentials.json"), + ): try: parts.append(f"{rel}@{p.stat().st_mtime_ns}") except FileNotFoundError: diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 4ed717668aab..703f9f2c393b 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -19,6 +19,7 @@ hermes profile delete coder # remove profile + alias + service """ +import contextlib import json import os import re @@ -34,6 +35,9 @@ from agent.skill_utils import is_excluded_skill_path +_IS_WINDOWS = os.name == "nt" +_IS_LINUX = sys.platform.startswith("linux") +_IS_DARWIN = sys.platform == "darwin" _PROFILE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") # Directories bootstrapped inside every new profile @@ -123,6 +127,11 @@ "backups", "state-snapshots", "checkpoints", + # Credential stores are controlled by auth authority and are never copied + # implicitly. A profile-local store must be moved with the explicit + # auth migration/restore workflow. + "auth.json", + "auth.lock", }) # Marker file written by `hermes profile create --no-skills`. When present in @@ -838,11 +847,274 @@ def read_profile_meta(profile_dir: Path) -> dict: } +def _write_profile_meta_descriptor_relative( + directory_fd: int, + *, + expected_leaf_identity: tuple[int, int] | None, + description: Optional[str], + description_auto: Optional[bool], +) -> None: + """Stage and publish creation-time metadata without following the leaf.""" + import yaml + + metadata_fd = staged_fd = -1 + staged_name: str | None = None + leaf_mode = 0o644 + try: + try: + leaf = os.stat("profile.yaml", dir_fd=directory_fd, follow_symlinks=False) + except FileNotFoundError: + leaf = None + + if expected_leaf_identity is None: + if leaf is not None: + raise ValueError("cloned profile.yaml changed before secure metadata write") + elif leaf is None: + raise ValueError("cloned profile.yaml changed before secure metadata write") + elif not stat.S_ISREG(leaf.st_mode): + raise ValueError( + "cloned profile.yaml must be a regular file inside the new profile" + ) + elif (leaf.st_dev, leaf.st_ino) != expected_leaf_identity: + raise ValueError("cloned profile.yaml changed before secure metadata write") + + existing: dict = {} + if leaf is not None: + read_flags = os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0) + metadata_fd = os.open("profile.yaml", read_flags, dir_fd=directory_fd) + opened = os.fstat(metadata_fd) + if ( + not stat.S_ISREG(opened.st_mode) + or (opened.st_dev, opened.st_ino) != expected_leaf_identity + ): + raise ValueError("cloned profile.yaml changed during secure open") + leaf_mode = stat.S_IMODE(opened.st_mode) & 0o777 + with os.fdopen(metadata_fd, "r", encoding="utf-8") as stream: + metadata_fd = -1 + try: + loaded = yaml.safe_load(stream.read()) or {} + except Exception: + loaded = {} + if isinstance(loaded, dict): + existing = loaded + + if description is not None: + existing["description"] = description.strip() + if description_auto is not None: + existing["description_auto"] = bool(description_auto) + payload = yaml.safe_dump( + existing, sort_keys=False, default_flow_style=False + ).encode("utf-8") + + create_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW + create_flags |= getattr(os, "O_CLOEXEC", 0) + for attempt in range(16): + candidate = ( + f".profile.yaml.{os.getpid()}.{time.time_ns()}.{attempt}.tmp" + ) + try: + staged_fd = os.open( + candidate, create_flags, leaf_mode, dir_fd=directory_fd + ) + staged_name = candidate + break + except FileExistsError: + continue + if staged_fd < 0 or staged_name is None: + raise OSError("could not reserve a safe staged profile.yaml file") + + if hasattr(os, "fchmod"): + os.fchmod(staged_fd, leaf_mode) + view = memoryview(payload) + written = 0 + while written < len(view): + count = os.write(staged_fd, view[written:]) + if count <= 0: + raise OSError("short profile.yaml write made no progress") + written += count + os.fsync(staged_fd) + staged = os.fstat(staged_fd) + + try: + current = os.stat( + "profile.yaml", dir_fd=directory_fd, follow_symlinks=False + ) + except FileNotFoundError: + current = None + if expected_leaf_identity is None: + if current is not None: + raise ValueError("cloned profile.yaml changed before secure publication") + elif current is None or ( + current.st_dev, + current.st_ino, + ) != expected_leaf_identity: + raise ValueError("cloned profile.yaml changed before secure publication") + + os.replace( + staged_name, + "profile.yaml", + src_dir_fd=directory_fd, + dst_dir_fd=directory_fd, + ) + staged_name = None + published = os.stat( + "profile.yaml", dir_fd=directory_fd, follow_symlinks=False + ) + if not stat.S_ISREG(published.st_mode) or ( + published.st_dev, + published.st_ino, + ) != (staged.st_dev, staged.st_ino): + raise ValueError("published profile.yaml identity changed") + os.fsync(directory_fd) + finally: + if staged_name is not None: + try: + os.unlink(staged_name, dir_fd=directory_fd) + except OSError: + pass + for fd in (metadata_fd, staged_fd): + if fd >= 0: + try: + os.close(fd) + except OSError: + pass + + +def _write_profile_meta_portable_creation( + profile_dir: Path, + *, + expected_leaf_identity: tuple[int, int] | None, + description: Optional[str], + description_auto: Optional[bool], +) -> None: + """Safely materialize creation-time metadata on guarded Windows paths.""" + import yaml + + if not _IS_WINDOWS: + raise RuntimeError( + "secure profile metadata creation requires descriptor-relative " + "operations or a Windows no-delete directory handle" + ) + + metadata_path = profile_dir / "profile.yaml" + staged_path: Path | None = None + staged_fd = -1 + leaf_mode = 0o644 + try: + try: + leaf = metadata_path.lstat() + except FileNotFoundError: + leaf = None + + if expected_leaf_identity is None: + if leaf is not None: + raise ValueError("cloned profile.yaml changed before secure metadata write") + elif leaf is None: + raise ValueError("cloned profile.yaml changed before secure metadata write") + elif ( + getattr(leaf, "st_file_attributes", 0) & 0x00000400 + or not stat.S_ISREG(leaf.st_mode) + ): + raise ValueError( + "cloned profile.yaml must be a regular file inside the new profile" + ) + elif (leaf.st_dev, leaf.st_ino) != expected_leaf_identity: + raise ValueError("cloned profile.yaml changed before secure metadata write") + + existing: dict = {} + if leaf is not None: + leaf_mode = stat.S_IMODE(leaf.st_mode) & 0o777 + try: + loaded = yaml.safe_load( + _read_windows_regular_file_no_follow( + metadata_path, expected_leaf_identity + ).decode("utf-8") + ) or {} + except (UnicodeDecodeError, yaml.YAMLError): + loaded = {} + if isinstance(loaded, dict): + existing = loaded + + if description is not None: + existing["description"] = description.strip() + if description_auto is not None: + existing["description_auto"] = bool(description_auto) + payload = yaml.safe_dump( + existing, sort_keys=False, default_flow_style=False + ).encode("utf-8") + + create_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + create_flags |= getattr(os, "O_BINARY", 0) + for attempt in range(16): + candidate = profile_dir / ( + f".profile.yaml.{os.getpid()}.{time.time_ns()}.{attempt}.tmp" + ) + try: + staged_fd = os.open(str(candidate), create_flags, leaf_mode) + staged_path = candidate + break + except FileExistsError: + continue + if staged_fd < 0 or staged_path is None: + raise OSError("could not reserve a safe staged profile.yaml file") + + if hasattr(os, "fchmod"): + os.fchmod(staged_fd, leaf_mode) + view = memoryview(payload) + written = 0 + while written < len(view): + count = os.write(staged_fd, view[written:]) + if count <= 0: + raise OSError("short profile.yaml write made no progress") + written += count + os.fsync(staged_fd) + staged = os.fstat(staged_fd) + os.close(staged_fd) + staged_fd = -1 + + try: + current = metadata_path.lstat() + except FileNotFoundError: + current = None + if expected_leaf_identity is None: + if current is not None: + raise ValueError("cloned profile.yaml changed before secure publication") + elif current is None or ( + current.st_dev, + current.st_ino, + ) != expected_leaf_identity: + raise ValueError("cloned profile.yaml changed before secure publication") + + # Replacing the directory entry never follows its current target. A + # last-moment same-user symlink swap is therefore materialized safely + # as this staged regular file without touching the external target. + os.replace(staged_path, metadata_path) + staged_path = None + published = metadata_path.lstat() + if ( + getattr(published, "st_file_attributes", 0) & 0x00000400 + or not stat.S_ISREG(published.st_mode) + or (published.st_dev, published.st_ino) != (staged.st_dev, staged.st_ino) + ): + raise ValueError("published profile.yaml identity changed") + finally: + if staged_fd >= 0: + os.close(staged_fd) + if staged_path is not None: + try: + staged_path.unlink() + except OSError: + pass + + def write_profile_meta( profile_dir: Path, *, description: Optional[str] = None, description_auto: Optional[bool] = None, + creation_directory_fd: int | None = None, + expected_leaf_identity: tuple[int, int] | None = None, + creation_safe: bool = False, ) -> None: """Update ``/profile.yaml`` in place. @@ -850,6 +1122,22 @@ def write_profile_meta( fields preserve existing values. Creates the file if missing. Profile directory itself must exist. """ + if creation_directory_fd is not None: + _write_profile_meta_descriptor_relative( + creation_directory_fd, + expected_leaf_identity=expected_leaf_identity, + description=description, + description_auto=description_auto, + ) + return + if creation_safe: + _write_profile_meta_portable_creation( + profile_dir, + expected_leaf_identity=expected_leaf_identity, + description=description, + description_auto=description_auto, + ) + return if not profile_dir.is_dir(): raise FileNotFoundError(f"profile directory does not exist: {profile_dir}") import yaml @@ -988,6 +1276,838 @@ def profiles_to_serve(multiplex: bool) -> List[Tuple[str, Path]]: return serve +def _write_profile_auth_authority( + profile_dir: Path, + auth_mode: str, + created_identity: tuple[int, int], + *, + creation_directory_fd: int | None = None, +) -> None: + """Safely inject auth authority into a new profile's config. + + Clone-all deliberately preserves symlinks, but config mutation must never + follow one outside the new profile. On POSIX, anchor every operation to a + no-follow directory descriptor bound to the identity created by this + transaction. Platforms without those primitives fail closed. + """ + import yaml + + profiles_root = _get_profiles_root() + if ( + creation_directory_fd is None + and not _IS_WINDOWS + and os.path.abspath(profile_dir.parent) != os.path.abspath(profiles_root) + ): + raise ValueError("new profile must remain contained under the profiles directory") + + secure_dir_fd = ( + os.name == "posix" + and hasattr(os, "O_DIRECTORY") + and hasattr(os, "O_NOFOLLOW") + and os.open in os.supports_dir_fd + and os.stat in os.supports_dir_fd + and os.rename in os.supports_dir_fd + ) + if not secure_dir_fd: + _write_profile_auth_authority_portable( + profile_dir, auth_mode, created_identity + ) + return + + root_fd = directory_fd = config_fd = staged_fd = -1 + staged_name: str | None = None + try: + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + directory_flags |= getattr(os, "O_CLOEXEC", 0) + if creation_directory_fd is None: + root_fd = os.open(profiles_root, directory_flags) + directory_fd = os.open( + profile_dir.name, directory_flags, dir_fd=root_fd + ) + else: + directory_fd = os.dup(creation_directory_fd) + directory_stat = os.fstat(directory_fd) + if not stat.S_ISDIR(directory_stat.st_mode) or ( + directory_stat.st_dev, + directory_stat.st_ino, + ) != created_identity: + raise ValueError("new profile directory identity changed before authority injection") + + config: dict = {} + try: + leaf_stat = os.stat("config.yaml", dir_fd=directory_fd, follow_symlinks=False) + except FileNotFoundError: + leaf_stat = None + if leaf_stat is not None: + if not stat.S_ISREG(leaf_stat.st_mode): + raise ValueError("cloned config.yaml must be a regular file inside the new profile") + read_flags = os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0) + config_fd = os.open("config.yaml", read_flags, dir_fd=directory_fd) + opened_stat = os.fstat(config_fd) + if not stat.S_ISREG(opened_stat.st_mode) or ( + opened_stat.st_dev, + opened_stat.st_ino, + ) != (leaf_stat.st_dev, leaf_stat.st_ino): + raise ValueError("cloned config.yaml changed during secure open") + with os.fdopen(config_fd, "r", encoding="utf-8") as config_stream: + config_fd = -1 + loaded = yaml.safe_load(config_stream.read()) or {} + if isinstance(loaded, dict): + config = loaded + + auth_config = config.setdefault("auth", {}) + if not isinstance(auth_config, dict): + auth_config = {} + config["auth"] = auth_config + auth_config["authority"] = auth_mode + payload = yaml.safe_dump( + config, sort_keys=False, default_flow_style=False + ).encode("utf-8") + + create_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW + create_flags |= getattr(os, "O_CLOEXEC", 0) + for attempt in range(16): + candidate = f".config.yaml.{os.getpid()}.{time.time_ns()}.{attempt}.tmp" + try: + staged_fd = os.open(candidate, create_flags, 0o600, dir_fd=directory_fd) + staged_name = candidate + break + except FileExistsError: + continue + if staged_fd < 0 or staged_name is None: + raise OSError("could not reserve a safe staged config file") + + os.fchmod(staged_fd, 0o600) + staged_stat = os.fstat(staged_fd) + if not stat.S_ISREG(staged_stat.st_mode): + raise ValueError("staged config.yaml is not a regular file") + view = memoryview(payload) + written = 0 + while written < len(view): + count = os.write(staged_fd, view[written:]) + if count <= 0: + raise OSError("short config.yaml write made no progress") + written += count + os.fsync(staged_fd) + + os.replace( + staged_name, + "config.yaml", + src_dir_fd=directory_fd, + dst_dir_fd=directory_fd, + ) + staged_name = None + published_stat = os.stat( + "config.yaml", dir_fd=directory_fd, follow_symlinks=False + ) + if (published_stat.st_dev, published_stat.st_ino) != ( + staged_stat.st_dev, + staged_stat.st_ino, + ): + raise ValueError("published config.yaml identity changed") + os.fsync(directory_fd) + finally: + if staged_name is not None and directory_fd >= 0: + try: + os.unlink(staged_name, dir_fd=directory_fd) + except OSError: + pass + for fd in (config_fd, staged_fd, directory_fd, root_fd): + if fd >= 0: + try: + os.close(fd) + except OSError: + pass + + +@contextlib.contextmanager +def _windows_profile_directory_guard( + profile_dir: Path, + created_identity: tuple[int, int], +): + """Hold a Windows directory handle that denies rename/delete replacement.""" + if not _IS_WINDOWS: + raise RuntimeError( + "secure portable profile creation is supported only on Windows" + ) + + import ctypes + from ctypes import wintypes + + kernel32 = getattr(ctypes, "WinDLL")("kernel32", use_last_error=True) + create_file = kernel32.CreateFileW + create_file.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ] + create_file.restype = wintypes.HANDLE + close_handle = kernel32.CloseHandle + close_handle.argtypes = [wintypes.HANDLE] + close_handle.restype = wintypes.BOOL + + handle = create_file( + str(profile_dir), + 0x0080, # FILE_READ_ATTRIBUTES + 0x00000001 | 0x00000002, # FILE_SHARE_READ | FILE_SHARE_WRITE + None, + 3, # OPEN_EXISTING + 0x02000000 | 0x00200000, # BACKUP_SEMANTICS | OPEN_REPARSE_POINT + None, + ) + if handle == ctypes.c_void_p(-1).value: + raise getattr(ctypes, "WinError")(getattr(ctypes, "get_last_error")()) + try: + current = profile_dir.lstat() + if ( + getattr(current, "st_file_attributes", 0) & 0x00000400 + or not stat.S_ISDIR(current.st_mode) + or (current.st_dev, current.st_ino) != created_identity + ): + raise ValueError( + "new profile directory identity changed before authority injection" + ) + yield + final = profile_dir.lstat() + if ( + getattr(final, "st_file_attributes", 0) & 0x00000400 + or not stat.S_ISDIR(final.st_mode) + or (final.st_dev, final.st_ino) != created_identity + ): + raise ValueError( + "new profile directory identity changed during authority injection" + ) + finally: + close_handle(handle) + + +def _read_windows_regular_file_no_follow( + path: Path, + expected_identity: tuple[int, int], +) -> bytes: + """Read a Windows file through a non-reparse handle bound to its identity.""" + import ctypes + import msvcrt + from ctypes import wintypes + + kernel32 = getattr(ctypes, "WinDLL")("kernel32", use_last_error=True) + create_file = kernel32.CreateFileW + create_file.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ] + create_file.restype = wintypes.HANDLE + close_handle = kernel32.CloseHandle + close_handle.argtypes = [wintypes.HANDLE] + close_handle.restype = wintypes.BOOL + + handle = create_file( + str(path), + 0x80000000, # GENERIC_READ + 0x00000001 | 0x00000002, # FILE_SHARE_READ | FILE_SHARE_WRITE + None, + 3, # OPEN_EXISTING + 0x00200000, # FILE_FLAG_OPEN_REPARSE_POINT + None, + ) + if handle == ctypes.c_void_p(-1).value: + raise getattr(ctypes, "WinError")(getattr(ctypes, "get_last_error")()) + try: + fd = getattr(msvcrt, "open_osfhandle")( + handle, os.O_RDONLY | getattr(os, "O_BINARY", 0) + ) + except BaseException: + close_handle(handle) + raise + + # The CRT descriptor owns the Win32 handle after open_osfhandle succeeds. + with os.fdopen(fd, "rb") as stream: + opened = os.fstat(fd) + if ( + getattr(opened, "st_file_attributes", 0) & 0x00000400 + or not stat.S_ISREG(opened.st_mode) + or (opened.st_dev, opened.st_ino) != expected_identity + ): + raise ValueError("cloned config.yaml changed during secure open") + return stream.read() + + +def _write_profile_auth_authority_portable( + profile_dir: Path, + auth_mode: str, + created_identity: tuple[int, int], +) -> None: + """Publish authority on Windows while directory replacement is denied.""" + import yaml + + if not _IS_WINDOWS: + raise RuntimeError( + "secure profile authority injection requires descriptor-relative " + "no-follow operations or a Windows no-delete directory handle" + ) + + staged_path: Path | None = None + staged_fd = -1 + with _windows_profile_directory_guard(profile_dir, created_identity): + config: dict = {} + config_path = profile_dir / "config.yaml" + try: + leaf = config_path.lstat() + except FileNotFoundError: + leaf = None + if leaf is not None: + if ( + getattr(leaf, "st_file_attributes", 0) & 0x00000400 + or not stat.S_ISREG(leaf.st_mode) + ): + raise ValueError( + "cloned config.yaml must be a regular file inside the new profile" + ) + loaded = yaml.safe_load( + _read_windows_regular_file_no_follow( + config_path, (leaf.st_dev, leaf.st_ino) + ).decode("utf-8") + ) or {} + if isinstance(loaded, dict): + config = loaded + + auth_config = config.setdefault("auth", {}) + if not isinstance(auth_config, dict): + auth_config = {} + config["auth"] = auth_config + auth_config["authority"] = auth_mode + payload = yaml.safe_dump( + config, sort_keys=False, default_flow_style=False + ).encode("utf-8") + + create_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + create_flags |= getattr(os, "O_BINARY", 0) + try: + for attempt in range(16): + candidate = profile_dir / ( + f".config.yaml.{os.getpid()}.{time.time_ns()}.{attempt}.tmp" + ) + try: + staged_fd = os.open(str(candidate), create_flags, 0o600) + staged_path = candidate + break + except FileExistsError: + continue + if staged_fd < 0 or staged_path is None: + raise OSError("could not reserve a safe staged config file") + if hasattr(os, "fchmod"): + os.fchmod(staged_fd, 0o600) + view = memoryview(payload) + written = 0 + while written < len(view): + count = os.write(staged_fd, view[written:]) + if count <= 0: + raise OSError("short config.yaml write made no progress") + written += count + os.fsync(staged_fd) + staged_stat = os.fstat(staged_fd) + os.close(staged_fd) + staged_fd = -1 + os.replace(staged_path, config_path) + staged_path = None + published = config_path.lstat() + if (published.st_dev, published.st_ino) != ( + staged_stat.st_dev, + staged_stat.st_ino, + ): + raise ValueError("published config.yaml identity changed") + finally: + if staged_fd >= 0: + os.close(staged_fd) + if staged_path is not None: + try: + staged_path.unlink() + except OSError: + pass + + +def _rollback_new_profile(profile_dir: Path, identity: tuple[int, int]) -> Path: + """Move the created directory out of the profile namespace without deleting. + + Standard filesystem APIs cannot remove a directory conditionally by inode: + another process can replace its final pathname after an identity check but + before ``rmdir``/``rmtree``. A failed transaction is therefore quarantined + under a non-profile name and deliberately retained for operator cleanup. + This is preferable to recursively deleting an attacker-selected replacement. + """ + try: + current = profile_dir.lstat() + except FileNotFoundError: + raise RuntimeError("refusing rollback because the new profile disappeared") + if not stat.S_ISDIR(current.st_mode) or ( + current.st_dev, + current.st_ino, + ) != identity: + raise RuntimeError("refusing to roll back a profile path whose identity changed") + + quarantine: Path | None = None + for attempt in range(16): + candidate = profile_dir.with_name( + f".{profile_dir.name}.rollback-{os.getpid()}-{time.time_ns()}-{attempt}" + ) + try: + _rename_directory_noreplace(profile_dir, candidate) + except FileExistsError: + continue + else: + quarantine = candidate + break + if quarantine is None: + raise OSError("could not reserve a profile rollback quarantine name") + moved = quarantine.lstat() + if not stat.S_ISDIR(moved.st_mode) or ( + moved.st_dev, + moved.st_ino, + ) != identity: + # The source name was substituted inside rename(). Restore that + # unrelated object when possible and leave the transaction inode where + # the concurrent actor placed it; never search-and-delete by pathname. + if not os.path.lexists(profile_dir): + _rename_directory_noreplace(quarantine, profile_dir) + raise RuntimeError("refusing to remove a quarantined profile whose identity changed") + return quarantine + + +def _quarantine_failed_profile( + profile_dir: Path, + identity: tuple[int, int], + creation_error: BaseException, +) -> None: + """Best-effort safe rollback that never hides the originating exception.""" + try: + quarantine = _rollback_new_profile(profile_dir, identity) + creation_error.add_note( + f"incomplete profile retained for safe cleanup at {quarantine}" + ) + except BaseException as rollback_error: + creation_error.add_note(f"profile rollback refused: {rollback_error}") + + +def _rename_directory_noreplace( + staged_dir: Path, + profile_dir: Path, + *, + source_parent_fd: int | None = None, + destination_parent_fd: int | None = None, +) -> None: + """Atomically rename one directory only when the destination is absent.""" + if _IS_WINDOWS: + import ctypes + from ctypes import wintypes + + kernel32 = getattr(ctypes, "WinDLL")("kernel32", use_last_error=True) + move_file = kernel32.MoveFileExW + move_file.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD] + move_file.restype = wintypes.BOOL + if not move_file(str(staged_dir), str(profile_dir), 0): + error = getattr(ctypes, "get_last_error")() + if error in {80, 183}: # ERROR_FILE_EXISTS / ERROR_ALREADY_EXISTS + raise FileExistsError(f"Profile path already exists: {profile_dir}") + raise getattr(ctypes, "WinError")(error) + elif _IS_LINUX: + import ctypes + import errno + + libc = ctypes.CDLL(None, use_errno=True) + renameat2 = getattr(libc, "renameat2", None) + if renameat2 is None: + raise RuntimeError("atomic no-replace profile publication is unavailable") + renameat2.argtypes = [ + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_uint, + ] + renameat2.restype = ctypes.c_int + result = renameat2( + source_parent_fd if source_parent_fd is not None else -100, + os.fsencode(staged_dir), + destination_parent_fd if destination_parent_fd is not None else -100, + os.fsencode(profile_dir), + 1, # RENAME_NOREPLACE + ) + if result != 0: + error = ctypes.get_errno() + if error == errno.EEXIST: + raise FileExistsError(f"Profile path already exists: {profile_dir}") + raise OSError(error, os.strerror(error), str(profile_dir)) + elif _IS_DARWIN: + import ctypes + import errno + + libc = ctypes.CDLL(None, use_errno=True) + if source_parent_fd is not None or destination_parent_fd is not None: + rename_fn = getattr(libc, "renameatx_np", None) + if rename_fn is None: + raise RuntimeError("atomic no-replace profile publication is unavailable") + rename_fn.argtypes = [ + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_uint, + ] + rename_fn.restype = ctypes.c_int + result = rename_fn( + source_parent_fd if source_parent_fd is not None else -2, + os.fsencode(staged_dir), + destination_parent_fd if destination_parent_fd is not None else -2, + os.fsencode(profile_dir), + 0x00000004, + ) + else: + rename_fn = getattr(libc, "renamex_np", None) + if rename_fn is None: + raise RuntimeError("atomic no-replace profile publication is unavailable") + rename_fn.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint] + rename_fn.restype = ctypes.c_int + result = rename_fn( + os.fsencode(staged_dir), os.fsencode(profile_dir), 0x00000004 + ) + if result: + error = ctypes.get_errno() + if error == errno.EEXIST: + raise FileExistsError(f"Profile path already exists: {profile_dir}") + raise OSError(error, os.strerror(error), str(profile_dir)) + else: + raise RuntimeError("atomic no-replace profile publication is unavailable") + + +def _profile_leaf_identity( + profile_dir: Path, + name: str, + *, + directory_fd: int | None = None, +) -> tuple[int, int] | None: + """Return a no-follow regular-leaf identity within a pinned profile.""" + try: + if directory_fd is not None: + leaf = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + else: + leaf = (profile_dir / name).lstat() + except FileNotFoundError: + return None + if ( + getattr(leaf, "st_file_attributes", 0) & 0x00000400 + or not stat.S_ISREG(leaf.st_mode) + ): + raise ValueError(f"cloned {name} must be a regular file inside the new profile") + return leaf.st_dev, leaf.st_ino + + +def _verify_profile_leaf_identity( + profile_dir: Path, + name: str, + expected_identity: tuple[int, int] | None, + *, + directory_fd: int | None = None, +) -> None: + """Fail when a tracked mutable leaf was added, removed, or replaced.""" + current_identity = _profile_leaf_identity( + profile_dir, name, directory_fd=directory_fd + ) + if current_identity != expected_identity: + raise ValueError(f"cloned {name} changed during secure profile creation") + + +def _publish_new_profile( + staged_dir: Path, + profile_dir: Path, + identity: tuple[int, int], + *, + staging_parent_identity: tuple[int, int], + profile_metadata_identity: tuple[int, int] | None = None, + verify_profile_metadata: bool = False, +) -> None: + """Publish the staged object and undo publication if its identity changed.""" + staged = staged_dir.lstat() + if not stat.S_ISDIR(staged.st_mode) or ( + staged.st_dev, + staged.st_ino, + ) != identity: + raise ValueError("staged profile directory identity changed before publication") + + if os.name == "posix" and not _IS_WINDOWS: + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + directory_flags |= getattr(os, "O_CLOEXEC", 0) + source_parent_fd = os.open(staged_dir.parent, directory_flags) + destination_parent_fd = os.open(profile_dir.parent, directory_flags) + staged_directory_fd = -1 + published = False + try: + opened_parent = os.fstat(source_parent_fd) + if ( + opened_parent.st_dev, + opened_parent.st_ino, + ) != staging_parent_identity: + raise ValueError("staged profile parent identity changed before publication") + staged_directory_fd = os.open( + staged_dir.name, + directory_flags, + dir_fd=source_parent_fd, + ) + opened_staged = os.fstat(staged_directory_fd) + if not stat.S_ISDIR(opened_staged.st_mode) or ( + opened_staged.st_dev, + opened_staged.st_ino, + ) != identity: + raise ValueError("staged profile directory identity changed before publication") + if verify_profile_metadata: + _verify_profile_leaf_identity( + staged_dir, + "profile.yaml", + profile_metadata_identity, + directory_fd=staged_directory_fd, + ) + _rename_directory_noreplace( + Path(staged_dir.name), + Path(profile_dir.name), + source_parent_fd=source_parent_fd, + destination_parent_fd=destination_parent_fd, + ) + published = True + if verify_profile_metadata: + _verify_profile_leaf_identity( + profile_dir, + "profile.yaml", + profile_metadata_identity, + directory_fd=staged_directory_fd, + ) + published_fd = os.open( + profile_dir.name, + directory_flags, + dir_fd=destination_parent_fd, + ) + try: + published_stat = os.fstat(published_fd) + if not stat.S_ISDIR(published_stat.st_mode) or ( + published_stat.st_dev, + published_stat.st_ino, + ) != identity: + raise ValueError("published profile directory identity changed") + if verify_profile_metadata: + _verify_profile_leaf_identity( + profile_dir, + "profile.yaml", + profile_metadata_identity, + directory_fd=published_fd, + ) + finally: + os.close(published_fd) + except BaseException: + if published: + try: + _rename_directory_noreplace( + Path(profile_dir.name), + Path(staged_dir.name), + source_parent_fd=destination_parent_fd, + destination_parent_fd=source_parent_fd, + ) + except BaseException as restore_error: + raise RuntimeError( + "profile publication failed and could not be restored safely" + ) from restore_error + raise + finally: + if staged_directory_fd >= 0: + os.close(staged_directory_fd) + os.close(destination_parent_fd) + os.close(source_parent_fd) + return + + if verify_profile_metadata: + _verify_profile_leaf_identity( + staged_dir, "profile.yaml", profile_metadata_identity + ) + _rename_directory_noreplace(staged_dir, profile_dir) + try: + published = profile_dir.lstat() + if ( + getattr(published, "st_file_attributes", 0) & 0x00000400 + or not stat.S_ISDIR(published.st_mode) + or (published.st_dev, published.st_ino) != identity + ): + raise ValueError("published profile directory identity changed") + if verify_profile_metadata: + _verify_profile_leaf_identity( + profile_dir, "profile.yaml", profile_metadata_identity + ) + except BaseException: + try: + _rename_directory_noreplace(profile_dir, staged_dir) + except BaseException as restore_error: + raise RuntimeError( + "profile publication failed and could not be restored safely" + ) from restore_error + raise + + +@contextlib.contextmanager +def _profile_creation_authority( + profile_dir: Path, + created_identity: tuple[int, int], +): + """Keep all profile writes bound to the created directory object.""" + secure_dir_fd = ( + os.name == "posix" + and hasattr(os, "O_DIRECTORY") + and hasattr(os, "O_NOFOLLOW") + and os.open in os.supports_dir_fd + and os.stat in os.supports_dir_fd + and os.rename in os.supports_dir_fd + ) + if secure_dir_fd: + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + directory_flags |= getattr(os, "O_CLOEXEC", 0) + directory_fd = os.open(profile_dir, directory_flags) + try: + opened = os.fstat(directory_fd) + if not stat.S_ISDIR(opened.st_mode) or ( + opened.st_dev, + opened.st_ino, + ) != created_identity: + raise ValueError( + "new profile directory identity changed before profile creation" + ) + fd_root = Path("/proc/self/fd" if _IS_LINUX else "/dev/fd") + if not fd_root.is_dir(): + raise RuntimeError( + "secure profile creation requires an addressable directory descriptor" + ) + yield fd_root / str(directory_fd), directory_fd + finally: + os.close(directory_fd) + return + + if _IS_WINDOWS: + with _windows_profile_directory_guard(profile_dir, created_identity): + yield profile_dir, None + return + + raise RuntimeError( + "secure profile creation requires descriptor-bound paths or a Windows " + "no-delete directory handle" + ) + + +def _regular_profile_leaf_exists(profile_dir: Path, name: str) -> bool: + """Return whether a mutable profile leaf exists and reject path escapes.""" + path = profile_dir / name + try: + leaf = path.lstat() + except FileNotFoundError: + return False + if ( + getattr(leaf, "st_file_attributes", 0) & 0x00000400 + or not stat.S_ISREG(leaf.st_mode) + ): + raise ValueError(f"cloned {name} must be a regular file inside the new profile") + return True + + +def _write_new_profile_leaf( + profile_dir: Path, + name: str, + payload: str, + *, + mode: int = 0o600, +) -> bool: + """Create one regular leaf without following an occupied symlink.""" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) + flags |= getattr(os, "O_BINARY", 0) + path = profile_dir / name + try: + fd = os.open(path, flags, mode) + except FileExistsError: + _regular_profile_leaf_exists(profile_dir, name) + return False + try: + encoded = payload.encode("utf-8") + view = memoryview(encoded) + written = 0 + while written < len(view): + count = os.write(fd, view[written:]) + if count <= 0: + raise OSError(f"short {name} write made no progress") + written += count + if hasattr(os, "fchmod"): + os.fchmod(fd, mode) + os.fsync(fd) + finally: + os.close(fd) + return True + + +def _populate_staged_profile( + profile_dir: Path, + source_dir: Path | None, + clone_all: bool, + *, + destination_dir: Path | None = None, +) -> None: + write_dir = destination_dir or profile_dir + if clone_all and source_dir: + shutil.copytree( + source_dir, + write_dir, + symlinks=True, + dirs_exist_ok=True, + ignore=_clone_all_copytree_ignore(source_dir), + ) + for stale in _CLONE_ALL_STRIP: + (write_dir / stale).unlink(missing_ok=True) + return + + for subdir in _PROFILE_DIRS: + (write_dir / subdir).mkdir(parents=True, exist_ok=True) + if source_dir is None: + return + + for filename in _CLONE_CONFIG_FILES: + src = source_dir / filename + if not src.exists(): + continue + dst = write_dir / filename + shutil.copy2(src, dst) + if filename == ".env": + try: + os.chmod(str(dst), 0o600) + except OSError: + pass + + source_skills = source_dir / "skills" + if source_skills.is_dir(): + shutil.copytree( + source_skills, + write_dir / "skills", + symlinks=True, + dirs_exist_ok=True, + ) + + for relpath in _CLONE_SUBDIR_FILES: + src = source_dir / relpath + if src.exists(): + dst = write_dir / relpath + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + + def create_profile( name: str, clone_from: Optional[str] = None, @@ -996,6 +2116,7 @@ def create_profile( no_alias: bool = False, no_skills: bool = False, description: Optional[str] = None, + auth_mode: str = "shared", ) -> Path: """Create a new profile directory. @@ -1024,6 +2145,8 @@ def create_profile( Path The newly created profile directory. """ + if auth_mode not in {"shared", "profile"}: + raise ValueError("auth_mode must be shared or profile") if no_skills and (clone_from is not None or clone_config or clone_all): raise ValueError( "--no-skills is mutually exclusive with --clone / --clone-from / --clone-all " @@ -1037,9 +2160,11 @@ def create_profile( "Cannot create a profile named 'default' — it is the built-in profile (~/.hermes)." ) - profile_dir = get_profile_dir(canon) - if profile_dir.exists(): - raise FileExistsError(f"Profile '{canon}' already exists at {profile_dir}") + final_profile_dir = get_profile_dir(canon) + if os.path.lexists(final_profile_dir): + raise FileExistsError( + f"Profile '{canon}' already exists at {final_profile_dir}" + ) # Resolve clone source source_dir = None @@ -1057,128 +2182,154 @@ def create_profile( f"Source profile '{clone_from or 'active'}' does not exist at {source_dir}" ) - if clone_all and source_dir: - # Full copy of source profile (exclude sibling ~/.hermes/profiles/) - shutil.copytree( - source_dir, - profile_dir, - symlinks=True, - ignore=_clone_all_copytree_ignore(source_dir), + final_profile_dir.parent.mkdir(parents=True, exist_ok=True) + staging_parent: Path | None = None + profile_dir: Path | None = None + for attempt in range(16): + candidate = final_profile_dir.with_name( + f".{canon}.create-{os.getpid()}-{time.time_ns()}-{attempt}" ) - # Strip runtime files - for stale in _CLONE_ALL_STRIP: - (profile_dir / stale).unlink(missing_ok=True) - else: - # Bootstrap directory structure - profile_dir.mkdir(parents=True, exist_ok=True) - for subdir in _PROFILE_DIRS: - (profile_dir / subdir).mkdir(parents=True, exist_ok=True) - - # Clone config files from source - if source_dir is not None: - for filename in _CLONE_CONFIG_FILES: - src = source_dir / filename - if src.exists(): - dst = profile_dir / filename - shutil.copy2(src, dst) - # Tighten .env to owner-only after copy. shutil.copy2 - # preserves source mode bits, but if the source's .env - # was loose (host umask 0o022 leaving 0o644), tighten - # explicitly so the clone doesn't inherit weak perms. - if filename == ".env": - try: - os.chmod(str(dst), 0o600) - except OSError: - pass - - # Clone installed skills from the source profile. The dashboard's - # "clone from default" flow is expected to preserve both bundled - # and user-installed skills so the new profile immediately has the - # same agent capabilities as the source profile. - source_skills = source_dir / "skills" - if source_skills.is_dir(): - shutil.copytree(source_skills, profile_dir / "skills", symlinks=True, dirs_exist_ok=True) - - # Clone memory and other subdirectory files - for relpath in _CLONE_SUBDIR_FILES: - src = source_dir / relpath - if src.exists(): - dst = profile_dir / relpath - dst.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, dst) - - # Seed an empty .env so the profile has its own credentials file from - # day one. Without it, profile-scoped env writes (dashboard Channels / - # Keys pages, `hermes -p auth add`) had no file until first - # write, and the profile silently inherited API keys from the shell - # environment — users reasonably read that as "the new profile reads - # the root .env". Skipped when --clone/--clone-all already copied one. - env_path = profile_dir / ".env" - if not env_path.exists(): try: - env_path.write_text( + candidate.mkdir(mode=0o700) + staged_child = candidate / "profile" + staged_child.mkdir(mode=0o700) + staging_parent = candidate + profile_dir = staged_child + break + except FileExistsError: + continue + if profile_dir is None or staging_parent is None: + raise OSError("could not reserve a private profile staging directory") + + staging_parent_stat = staging_parent.lstat() + staging_parent_identity = ( + staging_parent_stat.st_dev, + staging_parent_stat.st_ino, + ) + created_stat = profile_dir.lstat() + if not stat.S_ISDIR(created_stat.st_mode): + raise ValueError("new profile path is not a directory") + created_identity = (created_stat.st_dev, created_stat.st_ino) + profile_metadata_identity: tuple[int, int] | None = None + try: + with _profile_creation_authority( + profile_dir, created_identity + ) as (write_dir, creation_directory_fd): + _populate_staged_profile( + profile_dir, + source_dir, + clone_all, + destination_dir=write_dir, + ) + profile_metadata_identity = _profile_leaf_identity( + write_dir, + "profile.yaml", + directory_fd=creation_directory_fd, + ) + + # auth.json OAuth and credential-pool content never moves during + # profile creation. ``profile`` creates a new empty local authority; + # ``shared`` keeps the singleton store in place. + if auth_mode == "profile": + from hermes_cli.auth import _auth_store_lock, _save_auth_store + + _regular_profile_leaf_exists(write_dir, "auth.json") + _regular_profile_leaf_exists(write_dir, "auth.lock") + local_auth = write_dir / "auth.json" + with _auth_store_lock(target_path=local_auth): + _save_auth_store({"providers": {}}, target_path=local_auth) + + # Seed profile-local defaults with exclusive, no-follow creation. + _write_new_profile_leaf( + write_dir, + ".env", "# Per-profile secrets for this Hermes profile.\n" "# API keys and tokens set here override the shell environment.\n" "# Behavioral settings belong in config.yaml, not here.\n", - encoding="utf-8", ) - os.chmod(str(env_path), 0o600) - except OSError: - pass # best-effort — save_env_value creates the file on demand + try: + from hermes_cli.default_soul import DEFAULT_SOUL_MD - # Seed a default SOUL.md so the user has a file to customize immediately. - # Skipped when the profile already has one (from --clone / --clone-all). - soul_path = profile_dir / "SOUL.md" - if not soul_path.exists(): - try: - from hermes_cli.default_soul import DEFAULT_SOUL_MD - soul_path.write_text(DEFAULT_SOUL_MD, encoding="utf-8") - except Exception: - pass # best-effort — don't fail profile creation over this + _write_new_profile_leaf( + write_dir, "SOUL.md", DEFAULT_SOUL_MD, mode=0o644 + ) + except OSError: + pass # best-effort — don't fail profile creation over this - # Write the opt-out marker so seed_profile_skills() and `hermes update`'s - # all-profile sync loop both skip this profile for bundled-skill seeding. - if no_skills: - try: - (profile_dir / NO_BUNDLED_SKILLS_MARKER).write_text( - "This profile opted out of bundled-skill seeding " - "(`hermes profile create --no-skills`).\n" - "Delete this file to re-enable sync on the next `hermes update`.\n", - encoding="utf-8", - ) - except OSError: - pass # best-effort — the feature still works via the empty skills/ dir - - # Cloned configs can be older than the running Hermes (or predate schema - # tracking entirely). Migrate config-only clones immediately so - # desktop/status surfaces don't warn that a just-created profile is - # v0/outdated. Leave --clone-all snapshots byte-for-byte apart from the - # explicit runtime/history stripping above. - if not clone_all: - _migrate_profile_config_if_outdated(profile_dir) - - # Persist description if the caller provided one. Done last so a - # partial-create failure doesn't strand a description file in an - # incomplete profile. - if description and description.strip(): - try: - write_profile_meta( + if no_skills: + try: + _write_new_profile_leaf( + write_dir, + NO_BUNDLED_SKILLS_MARKER, + "This profile opted out of bundled-skill seeding " + "(`hermes profile create --no-skills`).\n" + "Delete this file to re-enable sync on the next `hermes update`.\n", + mode=0o644, + ) + except OSError: + pass + + if not clone_all: + _migrate_profile_config_if_outdated(write_dir) + + if description and description.strip(): + write_profile_meta( + write_dir, + description=description.strip(), + description_auto=False, + creation_directory_fd=creation_directory_fd, + expected_leaf_identity=profile_metadata_identity, + creation_safe=True, + ) + profile_metadata_identity = _profile_leaf_identity( + write_dir, + "profile.yaml", + directory_fd=creation_directory_fd, + ) + + _write_profile_auth_authority( profile_dir, - description=description.strip(), - description_auto=False, + auth_mode, + created_identity, + creation_directory_fd=creation_directory_fd, ) - except Exception: - pass # non-fatal — user can describe later with `hermes profile describe` + _verify_profile_leaf_identity( + write_dir, + "profile.yaml", + profile_metadata_identity, + directory_fd=creation_directory_fd, + ) + current = profile_dir.lstat() + if not stat.S_ISDIR(current.st_mode) or ( + current.st_dev, + current.st_ino, + ) != created_identity: + raise ValueError("new profile directory identity changed during creation") + + _publish_new_profile( + profile_dir, + final_profile_dir, + created_identity, + staging_parent_identity=staging_parent_identity, + profile_metadata_identity=profile_metadata_identity, + verify_profile_metadata=True, + ) + except BaseException as creation_error: + _quarantine_failed_profile(profile_dir, created_identity, creation_error) + raise - # Phase 4: when running inside a container under s6, register the - # new profile's gateway as a runtime s6 service so - # `hermes -p gateway start` can supervise it via - # `s6-svc -u` instead of spawning a bare process. On host (systemd - # / launchd / windows) this is a no-op — the existing per-profile - # unit-generation paths handle gateway lifecycle. - _maybe_register_gateway_service(canon) + try: + staging_parent.rmdir() + except OSError: + pass # private empty transaction parent; safe to retain if cleanup races - return profile_dir + # Register the gateway only after the transaction has committed. On host platforms + # this is a no-op; under s6 it makes the now-complete profile supervisable. + try: + _maybe_register_gateway_service(canon) + except Exception: + pass # publication committed; optional registration cannot undo success + return final_profile_dir def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict]: @@ -1461,7 +2612,34 @@ def _rmtree_with_retry(profile_dir: Path, onexc_handler) -> None: raise last_exc -def delete_profile(name: str, yes: bool = False) -> Path: +def _archive_profile_auth(profile_name: str, auth_path: Path) -> Path: + """Archive profile-local credentials privately while holding their lock.""" + from hermes_cli.auth import _auth_store_lock + + archive_dir = _get_default_hermes_home() / "state-snapshots" / "auth-profile-deletions" + archive_dir.mkdir(parents=True, exist_ok=True) + archive_path = archive_dir / f"{profile_name}-{time.time_ns()}.json" + tmp_path = archive_path.with_name(f".{archive_path.name}.tmp.{os.getpid()}") + with _auth_store_lock(target_path=auth_path): + raw = auth_path.read_bytes() + fd = os.open(str(tmp_path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(fd, "wb") as handle: + handle.write(raw) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_path, archive_path) + archive_path.chmod(0o600) + finally: + tmp_path.unlink(missing_ok=True) + return archive_path + + +def delete_profile( + name: str, + yes: bool = False, + auth_action: str | None = None, +) -> Path: """Delete a profile, its wrapper script, and its gateway service. Stops the gateway if running. Disables systemd/launchd service first @@ -1482,6 +2660,19 @@ def delete_profile(name: str, yes: bool = False) -> Path: if not profile_dir.is_dir(): raise FileNotFoundError(f"Profile '{canon}' does not exist.") + if auth_action not in {None, "archive", "purge"}: + raise ValueError("--auth-action must be either 'archive' or 'purge'") + from hermes_cli.auth_authority import resolve_auth_authority + + authority = resolve_auth_authority(profile_home=profile_dir) + local_auth = ( + authority.effective_mode == "profile" and authority.auth_path.is_file() + ) + if local_auth and auth_action is None: + raise ValueError( + "Profile-local credentials require --auth-action archive or " + "--auth-action purge before deletion" + ) # Show what will be deleted model, provider = _read_config_model(profile_dir) gw_running = _check_gateway_running(profile_dir) @@ -1545,6 +2736,13 @@ def delete_profile(name: str, yes: bool = False) -> Path: # guard — resurrected the deleted tree. _stop_profile_backends(canon, profile_dir) + # Snapshot profile-local credentials only after every known writer is + # quiescent, otherwise a refresh can race the archive and produce a + # self-inconsistent credential record. + if local_auth and auth_action == "archive": + archive_path = _archive_profile_auth(canon, authority.auth_path) + print(f"✓ Archived profile credentials to {archive_path}") + # 3. Remove wrapper script if has_wrapper: if remove_wrapper_script(canon): @@ -2171,10 +3369,12 @@ def rename_profile(old_name: str, new_name: str) -> Path: if new_dir.exists(): raise FileExistsError(f"Profile '{new_canon}' already exists.") - # 1. Stop gateway if running - if _check_gateway_running(old_dir): - _cleanup_gateway_service(old_canon, old_dir) - _stop_gateway_process(old_dir) + # 1. Stop every profile process that can write local auth. Service cleanup + # is unconditional because a supervisor can restart a gateway even when no + # PID artifact is currently visible. + _cleanup_gateway_service(old_canon, old_dir) + _stop_gateway_process(old_dir) + _stop_profile_backends(old_canon, old_dir) # 2. Rename directory old_dir.rename(new_dir) diff --git a/hermes_cli/result_metadata.py b/hermes_cli/result_metadata.py new file mode 100644 index 000000000000..c9822db90d9f --- /dev/null +++ b/hermes_cli/result_metadata.py @@ -0,0 +1,582 @@ +"""Closed-world metadata for non-interactive Hermes query results. + +This module deliberately projects the rich internal conversation result onto a +small, versioned schema. It must never serialize responses, errors, prompts, +session identifiers, provider/model names, tool output, or traceback text. +""" + +from __future__ import annotations + +import errno +import json +import os +import secrets +import stat +from collections.abc import Mapping +from typing import Any + +try: + import fcntl +except ImportError: # pragma: no cover - exercised on native Windows + fcntl = None # type: ignore[assignment] + +_SECURE_DIR_FD_AVAILABLE = all( + call in os.supports_dir_fd for call in (os.open, os.stat, os.unlink, os.link) +) + +SCHEMA_VERSION = "hermes-agent-result-meta-v1" +PUBLIC_ERROR_MESSAGE = "Error: failed to publish result metadata." +MAX_METADATA_BYTES = 1024 +_RESULT_KEYS = frozenset( + { + "schema_version", + "completed", + "failed", + "partial", + "interrupted", + "api_calls", + "failure_class", + } +) +_FAILURE_CLASSES = frozenset( + { + "none", + "interrupted", + "content_policy_blocked", + "provider_api_terminal", + "max_turns_or_incomplete", + "unknown_failure", + } +) + + +class ResultMetadataError(RuntimeError): + """The requested metadata cannot be projected or published safely.""" + + +class ResultMetadataFD: + """Single owner for a validated result-metadata FIFO write endpoint.""" + + __slots__ = ("_fd",) + + def __init__(self, fd: int) -> None: + self._fd = fd + + @property + def closed(self) -> bool: + return self._fd < 0 + + def fileno(self) -> int: + if self.closed: + raise ResultMetadataError("result metadata descriptor is closed") + return self._fd + + def close(self) -> None: + if self.closed: + return + fd = self._fd + self._fd = -1 + try: + os.close(fd) + except OSError as exc: + raise ResultMetadataError("result metadata descriptor close failed") from exc + + +def parse_result_metadata_fd(value: str) -> int: + """Parse argparse input as a canonical decimal descriptor number.""" + + if not isinstance(value, str) or not value.isascii() or not value.isdecimal(): + raise ValueError("result metadata descriptor must be a canonical integer") + if value != str(int(value)): + raise ValueError("result metadata descriptor must be a canonical integer") + fd = int(value) + if fd < 3: + raise ValueError("result metadata descriptor must be at least 3") + return fd + + +def _validate_result_metadata_fd(fd: Any) -> int: + if os.name != "posix" or fcntl is None: + raise ResultMetadataError("result metadata descriptor transport requires POSIX") + if type(fd) is not int or fd < 3: + raise ResultMetadataError("result metadata descriptor must be an integer at least 3") + try: + opened = os.fstat(fd) + flags = fcntl.fcntl(fd, fcntl.F_GETFL) + except (OSError, TypeError, ValueError) as exc: + raise ResultMetadataError("result metadata descriptor is invalid or closed") from exc + if not stat.S_ISFIFO(opened.st_mode): + raise ResultMetadataError("result metadata descriptor must be a FIFO") + access_mode = flags & os.O_ACCMODE + if access_mode != os.O_WRONLY: + raise ResultMetadataError("result metadata descriptor must be a FIFO write endpoint") + if flags & os.O_NONBLOCK: + raise ResultMetadataError("result metadata descriptor must be blocking") + try: + pipe_buf = os.fpathconf(fd, "PC_PIPE_BUF") + except (OSError, TypeError, ValueError) as exc: + raise ResultMetadataError("result metadata FIFO atomic-write bound is unavailable") from exc + if type(pipe_buf) is not int or pipe_buf < MAX_METADATA_BYTES: + raise ResultMetadataError("result metadata FIFO atomic-write bound is too small") + return fd + + +def claim_result_metadata_fd(fd: Any) -> ResultMetadataFD: + """Validate and take ownership of a pre-opened result metadata descriptor. + + Validation does not write to the FIFO. + The accepted descriptor is made non-inheritable before control returns. + """ + + validated_fd = _validate_result_metadata_fd(fd) + try: + os.set_inheritable(validated_fd, False) + except OSError as exc: + raise ResultMetadataError("result metadata descriptor could not be isolated") from exc + return ResultMetadataFD(validated_fd) + + +def _require_secure_filesystem_primitives() -> None: + required_flags = ("O_DIRECTORY", "O_NOFOLLOW") + if os.name != "posix" or any(not hasattr(os, flag) for flag in required_flags): + raise ResultMetadataError( + "secure no-clobber result publication is unavailable on this platform" + ) + if not _SECURE_DIR_FD_AVAILABLE: + raise ResultMetadataError( + "secure directory-relative result publication is unavailable on this platform" + ) + + +def _destination_parts(path: os.PathLike[str] | str) -> tuple[str, list[str]]: + _require_secure_filesystem_primitives() + try: + raw = os.fspath(path) + except TypeError as exc: + raise ResultMetadataError("result metadata destination must be a filesystem path") from exc + if not isinstance(raw, str) or not raw.startswith("/") or raw.endswith("/") or "\x00" in raw: + raise ResultMetadataError("result metadata destination must be an absolute file path") + + lexical_parts = raw.split("/") + if ".." in lexical_parts: + raise ResultMetadataError("result metadata destination must not contain '..'") + components = [part for part in lexical_parts if part not in {"", "."}] + if not components: + raise ResultMetadataError("result metadata destination must name a file") + return raw, components + + +def _open_parent_directory(path: os.PathLike[str] | str) -> tuple[int, str, str]: + raw, components = _destination_parts(path) + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + flags |= getattr(os, "O_CLOEXEC", 0) + current_fd = -1 + try: + current_fd = os.open("/", flags) + for component in components[:-1]: + next_fd = os.open(component, flags, dir_fd=current_fd) + os.close(current_fd) + current_fd = next_fd + if not stat.S_ISDIR(os.fstat(current_fd).st_mode): + raise ResultMetadataError("result metadata parent is not a directory") + return current_fd, components[-1], raw + except ResultMetadataError: + if current_fd >= 0: + os.close(current_fd) + raise + except OSError as exc: + if current_fd >= 0: + os.close(current_fd) + raise ResultMetadataError("result metadata parent chain is unavailable or unsafe") from exc + + +def _leaf_exists(parent_fd: int, leaf: str) -> bool: + try: + os.stat(leaf, dir_fd=parent_fd, follow_symlinks=False) + return True + except FileNotFoundError: + return False + + +def validate_result_metadata_destination(path: os.PathLike[str] | str) -> str: + """Validate a safe, absent destination before model invocation. + + Publication repeats these checks using anchored directory descriptors, + because preflight validation alone cannot close filesystem races. + """ + + parent_fd = -1 + try: + parent_fd, leaf, raw = _open_parent_directory(path) + if _leaf_exists(parent_fd, leaf): + raise ResultMetadataError("result metadata destination already exists") + return raw + except ResultMetadataError: + raise + except OSError as exc: + raise ResultMetadataError("result metadata destination is unavailable or unsafe") from exc + finally: + if parent_fd >= 0: + os.close(parent_fd) + + +def _parent_fd_still_names_destination( + path: os.PathLike[str] | str, + parent_fd: int, +) -> bool: + """Check that the lexical parent still resolves to the anchored directory.""" + current_fd = -1 + try: + current_fd, _leaf, _raw = _open_parent_directory(path) + opened = os.fstat(parent_fd) + current = os.fstat(current_fd) + return (opened.st_dev, opened.st_ino) == (current.st_dev, current.st_ino) + except (OSError, ResultMetadataError, ValueError): + return False + finally: + if current_fd >= 0: + os.close(current_fd) + + +def _api_call_count(result: Mapping[str, Any], max_iterations: int) -> tuple[int, bool]: + if type(max_iterations) is not int or max_iterations < 0: + max_iterations = 90 + upper_bound = max_iterations + 1 # The conversation loop permits one grace call. + value = result.get("api_calls") + if "api_calls" in result and type(value) is int and 0 <= value <= upper_bound: + return value, True + return 0, False + + +def _strict_statuses(result: Mapping[str, Any]) -> tuple[dict[str, bool], bool]: + statuses: dict[str, bool] = {} + valid = True + for key in ("completed", "failed", "partial", "interrupted"): + defaultable = key != "completed" + value = result.get(key, False) + if (key not in result and not defaultable) or type(value) is not bool: + valid = False + value = False + statuses[key] = value + return statuses, valid + + +def _is_max_turn_or_incomplete(result: Mapping[str, Any], statuses: Mapping[str, bool]) -> bool: + if statuses["partial"] or not statuses["completed"]: + return True + exit_reason = result.get("turn_exit_reason") + return isinstance(exit_reason, str) and ( + exit_reason.startswith("max_iterations_reached(") + or exit_reason in {"budget_exhausted", "all_retries_exhausted_no_response"} + ) + + +def _is_trusted_provider_failure_reason(value: Any) -> bool: + """Recognize only values emitted by the structured API error classifier.""" + if not isinstance(value, str): + return False + try: + from agent.error_classifier import FailoverReason + + FailoverReason(value) + return True + except (ImportError, ValueError): + return False + + +def _failure_class_invariant_error( + failure_class: str, + statuses: Mapping[str, bool], +) -> str | None: + completed = statuses["completed"] + failed = statuses["failed"] + partial = statuses["partial"] + interrupted = statuses["interrupted"] + if failure_class == "none" and not ( + completed and not failed and not partial and not interrupted + ): + return "success metadata violates status invariants" + if failure_class == "interrupted" and not interrupted: + return "interrupted metadata violates status invariants" + if failure_class in {"content_policy_blocked", "provider_api_terminal"} and not failed: + return "terminal failure metadata violates status invariants" + if failure_class == "max_turns_or_incomplete" and ( + completed or failed or interrupted + ): + return "incomplete metadata violates status invariants" + return None + + +def build_result_metadata(result: Any, *, max_iterations: int) -> dict[str, Any]: + """Project a trusted conversation result onto the public v1 metadata schema. + + ``api_calls`` is accepted only as a non-boolean integer in the inclusive + range ``0..max_iterations + 1``. Invalid or contradictory internal values + are represented conservatively as ``unknown_failure``. + """ + + if not isinstance(result, Mapping): + result = {} + valid_shape = False + else: + valid_shape = True + + statuses, statuses_valid = _strict_statuses(result) + api_calls, api_calls_valid = _api_call_count(result, max_iterations) + valid = valid_shape and statuses_valid and api_calls_valid + + failure_class = "unknown_failure" + + if valid and sum(int(value) for value in statuses.values()) > 1: + pass + elif valid and statuses["interrupted"]: + failure_class = "interrupted" + elif valid and statuses["failed"] and isinstance(result.get("error"), str) and result[ + "error" + ].startswith("content_policy_blocked:"): + failure_class = "content_policy_blocked" + elif valid and statuses["failed"] and _is_trusted_provider_failure_reason( + result.get("failure_reason") + ): + failure_class = "provider_api_terminal" + elif valid and statuses["failed"]: + pass + elif valid and _is_max_turn_or_incomplete(result, statuses): + failure_class = "max_turns_or_incomplete" + elif valid and statuses["completed"]: + failure_class = "none" + + if _failure_class_invariant_error(failure_class, statuses) is not None: + failure_class = "unknown_failure" + + return { + "schema_version": SCHEMA_VERSION, + "completed": statuses["completed"], + "failed": statuses["failed"], + "partial": statuses["partial"], + "interrupted": statuses["interrupted"], + "api_calls": api_calls, + "failure_class": failure_class, + } + + +def serialize_result_metadata(metadata: Mapping[str, Any]) -> bytes: + """Return deterministic UTF-8 JSON with exactly the v1 public keys.""" + + if set(metadata) != _RESULT_KEYS: + raise ResultMetadataError("metadata does not match the closed public schema") + if metadata.get("schema_version") != SCHEMA_VERSION: + raise ResultMetadataError("unsupported metadata schema version") + if metadata.get("failure_class") not in _FAILURE_CLASSES: + raise ResultMetadataError("unsupported failure class") + for key in ("completed", "failed", "partial", "interrupted"): + if type(metadata.get(key)) is not bool: + raise ResultMetadataError("status fields must be strict booleans") + if type(metadata.get("api_calls")) is not int or metadata["api_calls"] < 0: + raise ResultMetadataError("api_calls must be a non-negative integer") + + statuses = { + key: metadata[key] + for key in ("completed", "failed", "partial", "interrupted") + } + invariant_error = _failure_class_invariant_error(metadata["failure_class"], statuses) + if invariant_error is not None: + raise ResultMetadataError(invariant_error) + + payload = ( + json.dumps( + dict(metadata), + ensure_ascii=True, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + + "\n" + ).encode("utf-8") + if len(payload) > MAX_METADATA_BYTES: + raise ResultMetadataError("result metadata exceeds the fixed size bound") + return payload + + +def write_result_metadata_fd( + owner: ResultMetadataFD, + metadata: Mapping[str, Any], +) -> dict[str, Any]: + """Publish one bounded atomic frame through a validated FIFO writer.""" + + if not isinstance(owner, ResultMetadataFD): + raise ResultMetadataError("result metadata descriptor owner is invalid") + payload = serialize_result_metadata(metadata) + fd = owner.fileno() + try: + written = os.write(fd, payload) + if written != len(payload): + raise ResultMetadataError("short write while publishing result metadata") + except ResultMetadataError: + raise + except (OSError, TypeError, ValueError) as exc: + raise ResultMetadataError("could not write result metadata descriptor") from exc + return dict(metadata) + + +def _same_inode(parent_fd: int, leaf: str, identity: tuple[int, int]) -> bool: + try: + current = os.stat(leaf, dir_fd=parent_fd, follow_symlinks=False) + except FileNotFoundError: + return False + return (current.st_dev, current.st_ino) == identity + + +def _unlink_owned( + parent_fd: int, + leaf: str, + identity: tuple[int, int], + *, + strict: bool, +) -> None: + try: + if not _same_inode(parent_fd, leaf, identity): + if strict: + raise ResultMetadataError("owned result metadata file was replaced") + return + os.unlink(leaf, dir_fd=parent_fd) + except FileNotFoundError: + return + except ResultMetadataError: + raise + except OSError as exc: + if strict: + raise ResultMetadataError("could not remove result metadata staging file") from exc + + +def _retry_unlink_owned(parent_fd: int, leaf: str, identity: tuple[int, int]) -> None: + # Never follow or remove a caller-swapped inode: every retry rechecks the + # private staging file's identity with lstat semantics first. + for _attempt in range(3): + if not _same_inode(parent_fd, leaf, identity): + return + try: + os.unlink(leaf, dir_fd=parent_fd) + return + except FileNotFoundError: + return + except OSError: + continue + + +def _fsync_parent_directory(parent_fd: int) -> None: + try: + os.fsync(parent_fd) + except OSError as exc: + unsupported = { + errno.EBADF, + errno.EINVAL, + getattr(errno, "ENOTSUP", errno.EINVAL), + getattr(errno, "EOPNOTSUPP", errno.EINVAL), + } + if exc.errno not in unsupported: + raise + + +def write_result_metadata( + path: os.PathLike[str] | str, + metadata: Mapping[str, Any], +) -> dict[str, Any]: + """Atomically publish mode-0600 metadata without overwriting a leaf. + + The hard-link publication step provides atomic create-if-absent semantics. + Platforms lacking anchored, no-follow directory operations fail closed. + """ + + payload = serialize_result_metadata(metadata) + parent_fd = temp_fd = -1 + destination_name = "" + temp_name: str | None = None + temp_identity: tuple[int, int] | None = None + destination_identity: tuple[int, int] | None = None + published = False + + try: + parent_fd, destination_name, _raw = _open_parent_directory(path) + if _leaf_exists(parent_fd, destination_name): + raise ResultMetadataError("result metadata destination already exists") + + create_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW + create_flags |= getattr(os, "O_CLOEXEC", 0) + for _attempt in range(16): + candidate = f".{secrets.token_hex(16)}.result-meta.tmp" + try: + temp_fd = os.open(candidate, create_flags, 0o600, dir_fd=parent_fd) + temp_name = candidate + break + except FileExistsError: + continue + if temp_fd < 0 or temp_name is None: + raise ResultMetadataError("could not reserve result metadata staging file") + + os.fchmod(temp_fd, 0o600) + opened_stat = os.fstat(temp_fd) + temp_identity = (opened_stat.st_dev, opened_stat.st_ino) + if not stat.S_ISREG(opened_stat.st_mode): + raise ResultMetadataError("result metadata staging file is not regular") + + view = memoryview(payload) + written = 0 + while written < len(view): + count = os.write(temp_fd, view[written:]) + if type(count) is not int or count <= 0: + raise ResultMetadataError("short result metadata write made no progress") + written += count + os.fsync(temp_fd) + + # Link the already-open inode rather than its directory entry. This + # prevents a caller with write access to the parent from swapping the + # private temp name to a symlink between fsync and publication. + fd_source = f"/proc/self/fd/{temp_fd}" + try: + source_stat = os.stat(fd_source) + except OSError as exc: + raise ResultMetadataError( + "secure open-inode publication is unavailable on this platform" + ) from exc + if (source_stat.st_dev, source_stat.st_ino) != temp_identity: + raise ResultMetadataError("result metadata staging identity changed") + os.link( + fd_source, + destination_name, + dst_dir_fd=parent_fd, + follow_symlinks=True, + ) + published = True + destination_identity = temp_identity + if not _same_inode(parent_fd, destination_name, temp_identity): + raise ResultMetadataError("published result metadata identity changed") + if not _parent_fd_still_names_destination(path, parent_fd): + raise ResultMetadataError("destination parent changed during publication") + + _unlink_owned(parent_fd, temp_name, temp_identity, strict=True) + temp_name = None + os.close(temp_fd) + temp_fd = -1 + _fsync_parent_directory(parent_fd) + published = False # Durable success: finally must not roll back the leaf. + return dict(metadata) + except ResultMetadataError: + raise + except (OSError, TypeError, ValueError) as exc: + raise ResultMetadataError("result metadata publication failed") from exc + finally: + if temp_fd >= 0: + try: + os.close(temp_fd) + except OSError: + pass + if parent_fd >= 0: + if published and destination_identity is not None: + _retry_unlink_owned(parent_fd, destination_name, destination_identity) + if temp_name is not None and temp_identity is not None: + _retry_unlink_owned(parent_fd, temp_name, temp_identity) + try: + os.close(parent_fd) + except OSError: + pass diff --git a/hermes_cli/subcommands/auth.py b/hermes_cli/subcommands/auth.py index e81fcea8c100..e0ff76aed255 100644 --- a/hermes_cli/subcommands/auth.py +++ b/hermes_cli/subcommands/auth.py @@ -65,11 +65,43 @@ def build_auth_parser(subparsers, *, cmd_auth: Callable) -> None: auth_status = auth_subparsers.add_parser( "status", help="Show auth status for a provider" ) - auth_status.add_argument("provider", help="Provider id") + auth_status.add_argument( + "provider", + nargs="?", + help="Optional provider id; omit to inspect auth-store authority", + ) + auth_status.add_argument( + "--all-profiles", + action="store_true", + help="Show redacted auth authority for the default and all named profiles", + ) auth_logout = auth_subparsers.add_parser( "logout", help="Log out a provider and clear stored auth state" ) auth_logout.add_argument("provider", help="Provider id") + auth_migrate = auth_subparsers.add_parser( + "migrate-shared", + help="Plan or apply migration of legacy profile stores to shared auth", + ) + selection = auth_migrate.add_mutually_exclusive_group() + selection.add_argument("--all-profiles", action="store_true") + selection.add_argument("--profile", help="Named profile to migrate") + mode = auth_migrate.add_mutually_exclusive_group(required=True) + mode.add_argument("--dry-run", action="store_true") + mode.add_argument("--apply", action="store_true") + mode.add_argument("--recover", action="store_true") + mode.add_argument("--rollback", action="store_true") + auth_migrate.add_argument("--plan-id") + auth_migrate.add_argument("--plan-digest") + auth_migrate.add_argument( + "--conflict-policy", + choices=["abort", "prefer-shared", "prefer-profile"], + default="abort", + ) + auth_recover = auth_subparsers.add_parser( + "migrate-recover", help="Roll back an incomplete shared-auth migration" + ) + auth_recover.add_argument("--plan-id", required=True) auth_spotify = auth_subparsers.add_parser( "spotify", help="Authenticate Hermes with Spotify via PKCE" ) diff --git a/hermes_cli/subcommands/backup.py b/hermes_cli/subcommands/backup.py index 745d2193303c..993152e98c45 100644 --- a/hermes_cli/subcommands/backup.py +++ b/hermes_cli/subcommands/backup.py @@ -35,4 +35,14 @@ def build_backup_parser(subparsers, *, cmd_backup: Callable) -> None: backup_parser.add_argument( "-l", "--label", help="Label for the snapshot (only used with --quick)" ) + backup_parser.add_argument( + "--auth-mode", + choices=["exclude", "include-encrypted"], + default="exclude", + help="Exclude auth by default or include one encrypted authority store", + ) + backup_parser.add_argument( + "--auth-passphrase-file", + help="File containing the passphrase for --auth-mode include-encrypted", + ) backup_parser.set_defaults(func=cmd_backup) diff --git a/hermes_cli/subcommands/doctor.py b/hermes_cli/subcommands/doctor.py index 5be37c645581..163292429e17 100644 --- a/hermes_cli/subcommands/doctor.py +++ b/hermes_cli/subcommands/doctor.py @@ -22,6 +22,11 @@ def build_doctor_parser(subparsers, *, cmd_doctor: Callable) -> None: doctor_parser.add_argument( "--fix", action="store_true", help="Attempt to fix issues automatically" ) + doctor_parser.add_argument( + "--all-profiles", + action="store_true", + help="Inspect redacted authentication authority for every profile", + ) doctor_parser.add_argument( "--ack", metavar="ADVISORY_ID", diff --git a/hermes_cli/subcommands/import_cmd.py b/hermes_cli/subcommands/import_cmd.py index 36ed375d8d22..e82dbba070d7 100644 --- a/hermes_cli/subcommands/import_cmd.py +++ b/hermes_cli/subcommands/import_cmd.py @@ -28,4 +28,14 @@ def build_import_cmd_parser(subparsers, *, cmd_import: Callable) -> None: action="store_true", help="Overwrite existing files without confirmation", ) + import_parser.add_argument( + "--auth-action", + choices=["skip", "restore-shared", "restore-profile"], + default="skip", + help="Explicit destination for encrypted auth in the archive", + ) + import_parser.add_argument( + "--auth-passphrase-file", + help="File containing the passphrase for encrypted auth restore", + ) import_parser.set_defaults(func=cmd_import) diff --git a/hermes_cli/subcommands/profile.py b/hermes_cli/subcommands/profile.py index d812fadf9715..a2ff54910111 100644 --- a/hermes_cli/subcommands/profile.py +++ b/hermes_cli/subcommands/profile.py @@ -62,12 +62,24 @@ def build_profile_parser(subparsers, *, cmd_profile: Callable) -> None: "Used by the kanban decomposer to route tasks based on role instead " "of profile name alone. Skip and add later via `hermes profile describe`.", ) + profile_create.add_argument( + "--auth-mode", + choices=["shared", "profile"], + default="shared", + help="Auth authority for the new profile; profile mode explicitly copies source auth", + ) profile_delete = profile_subparsers.add_parser("delete", help="Delete a profile") profile_delete.add_argument("profile_name", help="Profile to delete") profile_delete.add_argument( "-y", "--yes", action="store_true", help="Skip confirmation prompt" ) + profile_delete.add_argument( + "--auth-action", + choices=["archive", "purge"], + default=None, + help="Required for profile-local credentials: archive privately or purge", + ) profile_describe = profile_subparsers.add_parser( "describe", diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 767882ba9d1a..c33dd07b86af 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -993,6 +993,10 @@ def _timezone_options() -> List[str]: "network": "agent", "checkpoints": "agent", "approvals": "security", + # `auth.authority` is the only schema-surfaced auth setting. Keep the + # shared/profile authority selector with the other security controls + # instead of creating a one-field tab. + "auth": "security", "human_delay": "display", "dashboard": "display", "code_execution": "agent", diff --git a/nix/checks.nix b/nix/checks.nix index 9e69d4c0f832..afd31e4ba516 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -8,6 +8,7 @@ let hermes-agent = self'.packages.default; hermesVenv = hermes-agent.hermesVenv; + authAuthorityPython = pkgs.python3.withPackages (ps: [ ps.pyyaml ]); configMergeScript = pkgs.callPackage ./configMergeScript.nix { }; @@ -32,6 +33,25 @@ def leaf_paths(d, prefix=""): json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) ' > $out ''; + + authAuthoritySeed = pkgs.writeText "hermes-auth-authority-seed.json" + ''{"version":1,"providers":{"nous":{"refresh_token":"seed-token"}}}''; + + forceOverwriteEval = builtins.tryEval (builtins.deepSeq + ((inputs.nixpkgs.lib.nixosSystem { + system = pkgs.system; + modules = [ + inputs.self.nixosModules.default + ({ ... }: { + system.stateVersion = "24.11"; + services.hermes-agent = { + enable = true; + authFileForceOverwrite = true; + }; + }) + ]; + }).config.system.build.toplevel.drvPath) + true); in { packages.configKeys = configKeys; @@ -75,6 +95,60 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) echo "ok" > $out/result ''; } // lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + # Exercise the activation helper and YAML contract through the same script + # invoked by the NixOS module: shared/profile routing, private modes, + # and preserve-on-repeat semantics are all build-time contracts. + auth-authority-activation = pkgs.runCommand "hermes-auth-authority-activation" { } '' + set -euo pipefail + ROOT=$(mktemp -d) + PROFILE="$ROOT/profiles/worker" + mkdir -p "$PROFILE" + + printf 'auth:\n authority: shared\n' > "$PROFILE/config.yaml" + SHARED_RESULT=$(PYTHONPATH=${../scripts} ${authAuthorityPython}/bin/python3 ${../scripts/nix_auth_authority.py} \ + "$PROFILE" ${authAuthoritySeed}) + echo "$SHARED_RESULT" | grep -q '"status": "created"' + test -f "$ROOT/auth.json" + test ! -e "$PROFILE/auth.json" + test "$(stat -c %a "$ROOT/auth.json")" = 600 + test "$(stat -c %a "$ROOT/auth.lock")" = 600 + + ${pkgs.python3}/bin/python3 - "$ROOT/auth.json" <<'PY' +import json, pathlib, sys +path = pathlib.Path(sys.argv[1]) +value = json.loads(path.read_text()) +value["providers"]["nous"]["refresh_token"] = "rotated-token" +path.write_text(json.dumps(value)) +path.chmod(0o600) +PY + REPEAT_RESULT=$(PYTHONPATH=${../scripts} ${authAuthorityPython}/bin/python3 ${../scripts/nix_auth_authority.py} \ + "$PROFILE" ${authAuthoritySeed}) + echo "$REPEAT_RESULT" | grep -q '"status": "preserved"' + grep -q 'rotated-token' "$ROOT/auth.json" + + LOCAL="$ROOT/profiles/local" + mkdir -p "$LOCAL" + printf 'auth:\n authority: profile\n' > "$LOCAL/config.yaml" + PYTHONPATH=${../scripts} ${authAuthorityPython}/bin/python3 ${../scripts/nix_auth_authority.py} \ + "$LOCAL" ${authAuthoritySeed} >/dev/null + test -f "$LOCAL/auth.json" + test "$(stat -c %a "$LOCAL/auth.json")" = 600 + test "$(stat -c %a "$LOCAL/auth.lock")" = 600 + + mkdir -p $out + echo ok > $out/result + ''; + + # The deprecated overwrite switch must fail module evaluation rather + # than becoming an inert or accidentally live activation control. + auth-force-overwrite-rejected = + if forceOverwriteEval.success then + throw "services.hermes-agent.authFileForceOverwrite=true unexpectedly evaluated" + else pkgs.runCommand "hermes-auth-force-overwrite-rejected" { } '' + mkdir -p $out + echo ok > $out/result + ''; + # Verify binaries exist and are executable package-contents = pkgs.runCommand "hermes-package-contents" { } '' set -e diff --git a/nix/nixosModules.nix b/nix/nixosModules.nix index df74427c48f9..2b2268bfbdd7 100644 --- a/nix/nixosModules.nix +++ b/nix/nixosModules.nix @@ -28,6 +28,7 @@ let cfg = config.services.hermes-agent; + authAuthorityPython = pkgs.python3.withPackages (ps: [ ps.pyyaml ]); effectivePackage = if cfg.extraPythonPackages == [ ] && cfg.extraDependencyGroups == [ ] then cfg.package @@ -49,10 +50,16 @@ # settings.terminal.cwd overrides the workingDirectory default. # Container mode uses the in-container mount path. effectiveWorkDir = if cfg.container.enable then containerWorkDir else cfg.workingDirectory; + authSettings = { auth.authority = cfg.authAuthority; }; configJson = builtins.toJSON ( - lib.recursiveUpdate { terminal.cwd = effectiveWorkDir; } cfg.settings + lib.recursiveUpdate + (lib.recursiveUpdate { terminal.cwd = effectiveWorkDir; } cfg.settings) + authSettings ); generatedConfigFile = pkgs.writeText "hermes-config.yaml" configJson; + generatedAuthConfigFile = pkgs.writeText "hermes-auth-authority.yaml" ( + builtins.toJSON authSettings + ); configFile = if cfg.configFile != null then cfg.configFile else generatedConfigFile; configMergeScript = pkgs.callPackage ./configMergeScript.nix { }; @@ -62,7 +69,6 @@ # CLI/TUI without hitting EACCES; otherwise group-read-only (0640). Secrets # (.env) stay 0640 regardless — see below. configYamlMode = if cfg.addToSystemPackages then "0660" else "0640"; - # Generate .env from non-secret environment attrset envFileContent = lib.concatStringsSep "\n" ( lib.mapAttrsToList (k: v: "${k}=${v}") cfg.environment @@ -318,9 +324,22 @@ authFileForceOverwrite = mkOption { type = types.bool; default = false; - description = "Always overwrite auth.json from authFile on activation."; + description = '' + Deprecated unsafe compatibility option. Setting this to true is + rejected; live auth stores are never overwritten during activation. + ''; }; + authAuthority = mkOption { + type = types.enum [ "shared" "profile" ]; + default = "shared"; + description = '' + Authoritative auth-store class. The NixOS service uses the default + profile, so shared and profile both target stateDir/.hermes/auth.json. + ''; + }; + + # ── Documents ──────────────────────────────────────────────────────── documents = mkOption { type = types.attrsOf (types.either types.str types.path); @@ -676,10 +695,16 @@ { assertions = let names = map lib.getName cfg.extraPlugins; - in [{ - assertion = (lib.length names) == (lib.length (lib.unique names)); - message = "services.hermes-agent.extraPlugins: duplicate plugin names detected: ${toString names}. If using fetchFromGitHub, set name = \"plugin-name\" to disambiguate."; - }]; + in [ + { + assertion = (lib.length names) == (lib.length (lib.unique names)); + message = "services.hermes-agent.extraPlugins: duplicate plugin names detected: ${toString names}. If using fetchFromGitHub, set name = \"plugin-name\" to disambiguate."; + } + { + assertion = !cfg.authFileForceOverwrite; + message = "services.hermes-agent.authFileForceOverwrite=true is no longer supported because activation must not overwrite a live credential store. Preserve the current store and use `hermes auth migrate-shared` or an explicit encrypted backup restore instead."; + } + ]; } # ── Warnings ────────────────────────────────────────────────────── @@ -752,6 +777,9 @@ # hermes-group users can save settings via the CLI/TUI, else 0640). ${if cfg.configFile != null then '' install -o ${cfg.user} -g ${cfg.group} -m ${configYamlMode} -D ${configFile} ${cfg.stateDir}/.hermes/config.yaml + ${configMergeScript} ${generatedAuthConfigFile} ${cfg.stateDir}/.hermes/config.yaml + chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/config.yaml + chmod ${configYamlMode} ${cfg.stateDir}/.hermes/config.yaml '' else '' ${configMergeScript} ${generatedConfigFile} ${cfg.stateDir}/.hermes/config.yaml chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/config.yaml @@ -818,13 +846,11 @@ # Seed auth file if provided ${lib.optionalString (cfg.authFile != null) '' - ${if cfg.authFileForceOverwrite then '' - install -o ${cfg.user} -g ${cfg.group} -m 0600 ${cfg.authFile} ${cfg.stateDir}/.hermes/auth.json - '' else '' - if [ ! -f ${cfg.stateDir}/.hermes/auth.json ]; then - install -o ${cfg.user} -g ${cfg.group} -m 0600 ${cfg.authFile} ${cfg.stateDir}/.hermes/auth.json - fi - ''} + PYTHONPATH=${../scripts} ${authAuthorityPython}/bin/python3 ${../scripts/nix_auth_authority.py} \ + ${lib.escapeShellArg "${cfg.stateDir}/.hermes"} \ + ${lib.escapeShellArg cfg.authFile} \ + --uid "$(${pkgs.coreutils}/bin/id -u ${cfg.user})" \ + --gid "$(${pkgs.coreutils}/bin/id -g ${cfg.group})" ''} # Seed .env from Nix-declared environment + environmentFiles. diff --git a/plugins/platforms/photon/auth.py b/plugins/platforms/photon/auth.py index b8b356a16b8c..4c83cde05b1a 100644 --- a/plugins/platforms/photon/auth.py +++ b/plugins/platforms/photon/auth.py @@ -40,9 +40,7 @@ import logging import os import re -import stat import time -import uuid from base64 import b64encode from dataclasses import dataclass from pathlib import Path @@ -88,69 +86,48 @@ class PhotonDashboardAuthError(RuntimeError): # auth.json helpers — share the file with the rest of hermes-agent. def _auth_json_path() -> Path: - """Resolve ``~/.hermes/auth.json`` honouring the active Hermes profile.""" - try: - from hermes_constants import get_hermes_home - return Path(get_hermes_home()) / "auth.json" - except Exception: - return Path(os.path.expanduser("~/.hermes")) / "auth.json" + """Resolve the configured canonical auth authority.""" + from hermes_cli.auth_authority import get_auth_store_path + + return get_auth_store_path() def _load_auth() -> Dict[str, Any]: - path = _auth_json_path() - if not path.exists(): - return {} - try: - with path.open("r", encoding="utf-8") as fh: - return json.load(fh) or {} - except (OSError, json.JSONDecodeError) as e: - logger.warning("photon: could not read %s: %s", path, e) - return {} + from hermes_cli.auth import _load_auth_store + + return _load_auth_store(_auth_json_path()) + +def _save_auth( + data: Dict[str, Any], + *, + pool_keys: tuple[str, ...] = ("photon", "photon_project", "photon_user"), + provider_keys: tuple[str, ...] = ("photon",), +) -> None: + """Merge only Photon-owned state under the canonical store lock. + + Callers commonly pass a whole-store snapshot loaded before doing network + work. Re-merging unrelated entries from that snapshot would overwrite + credentials rotated by another process while Photon was in flight. + """ + from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store -def _save_auth(data: Dict[str, Any]) -> None: path = _auth_json_path() - path.parent.mkdir(parents=True, exist_ok=True) - # Per-process random temp suffix avoids collisions between concurrent - # writers and stale leftovers from a crashed prior write. - tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}") - # Create with 0o600 atomically via os.open(O_EXCL) + fdopen: the old - # open() → write → chmod() sequence left a window where the bearer - # token sat world-readable at process umask (typically 0o644), and the - # predictable temp name could be pre-planted (symlink attack). Mirrors - # hermes_cli/auth.py:_save_auth_store (#19673, #21148). - fd = os.open( - str(tmp), - os.O_WRONLY | os.O_CREAT | os.O_EXCL, - stat.S_IRUSR | stat.S_IWUSR, - ) - try: - fh = os.fdopen(fd, "w", encoding="utf-8") - except BaseException: - # os.fdopen() failed before taking ownership of the raw descriptor, - # so nothing else will ever close it — do it here, then drop the - # just-created temp file. - try: - os.close(fd) - except OSError: - pass - try: - tmp.unlink() - except OSError: - pass - raise - try: - with fh: - json.dump(data, fh, indent=2, sort_keys=True) - fh.flush() - os.fsync(fh.fileno()) - tmp.replace(path) - except BaseException: - try: - tmp.unlink() - except OSError: - pass - raise + with _auth_store_lock(target_path=path): + current = _load_auth_store(path) + providers = data.get("providers") + if isinstance(providers, dict): + current_providers = current.setdefault("providers", {}) + for key in provider_keys: + if key in providers: + current_providers[key] = providers[key] + pool = data.get("credential_pool") + if isinstance(pool, dict): + current_pool = current.setdefault("credential_pool", {}) + for key in pool_keys: + if key in pool: + current_pool[key] = pool[key] + _save_auth_store(current, target_path=path) def load_photon_token() -> Optional[str]: @@ -170,14 +147,11 @@ def load_photon_token() -> Optional[str]: def store_photon_token(token: str) -> None: """Persist a dashboard bearer token under ``credential_pool.photon``.""" - from hermes_cli.auth import _auth_store_lock - - with _auth_store_lock(): - auth = _load_auth() - auth.setdefault("credential_pool", {})["photon"] = [ - {"access_token": token, "issued_at": int(time.time())} - ] - _save_auth(auth) + auth = _load_auth() + auth.setdefault("credential_pool", {})["photon"] = [ + {"access_token": token, "issued_at": int(time.time())} + ] + _save_auth(auth, pool_keys=("photon",), provider_keys=()) def clear_photon_token() -> None: @@ -188,14 +162,20 @@ def clear_photon_token() -> None: auth = _load_auth() pool = auth.get("credential_pool", {}) photon = pool.get("photon", []) - if isinstance(photon, list) and photon: + pool_changed = isinstance(photon, list) and bool(photon) + if pool_changed: pool["photon"] = [] - _save_auth(auth) # Also clear the legacy shape if present. providers = auth.get("providers", {}) - if "photon" in providers: + provider_changed = "photon" in providers + if provider_changed: providers["photon"] = {} - _save_auth(auth) + if pool_changed or provider_changed: + _save_auth( + auth, + pool_keys=("photon",) if pool_changed else (), + provider_keys=("photon",) if provider_changed else (), + ) def check_photon_token_valid(token: str) -> bool: @@ -284,21 +264,18 @@ def store_project_credentials( ``auth.json`` so management commands work even when ``.env`` hasn't been loaded into the current process. """ - from hermes_cli.auth import _auth_store_lock - - with _auth_store_lock(): - auth = _load_auth() - record: Dict[str, Any] = { - "spectrum_project_id": spectrum_project_id, - "project_secret": project_secret, - "issued_at": int(time.time()), - } - if dashboard_project_id: - record["dashboard_project_id"] = dashboard_project_id - if name: - record["name"] = name - auth.setdefault("credential_pool", {})["photon_project"] = [record] - _save_auth(auth) + auth = _load_auth() + record: Dict[str, Any] = { + "spectrum_project_id": spectrum_project_id, + "project_secret": project_secret, + "issued_at": int(time.time()), + } + if dashboard_project_id: + record["dashboard_project_id"] = dashboard_project_id + if name: + record["name"] = name + auth.setdefault("credential_pool", {})["photon_project"] = [record] + _save_auth(auth, pool_keys=("photon_project",), provider_keys=()) _persist_runtime_env(spectrum_project_id, project_secret) @@ -312,21 +289,18 @@ def store_user_numbers( """Persist non-secret Photon user numbers for offline ``status`` output.""" if not phone_number and not assigned_phone_number: return - from hermes_cli.auth import _auth_store_lock - - with _auth_store_lock(): - auth = _load_auth() - record: Dict[str, Any] = {"issued_at": int(time.time())} - if phone_number: - record["phone_number"] = phone_number - if assigned_phone_number: - record["assigned_phone_number"] = assigned_phone_number - if user_id: - record["user_id"] = user_id - if dashboard_project_id: - record["dashboard_project_id"] = dashboard_project_id - auth.setdefault("credential_pool", {})["photon_user"] = [record] - _save_auth(auth) + auth = _load_auth() + record: Dict[str, Any] = {"issued_at": int(time.time())} + if phone_number: + record["phone_number"] = phone_number + if assigned_phone_number: + record["assigned_phone_number"] = assigned_phone_number + if user_id: + record["user_id"] = user_id + if dashboard_project_id: + record["dashboard_project_id"] = dashboard_project_id + auth.setdefault("credential_pool", {})["photon_user"] = [record] + _save_auth(auth, pool_keys=("photon_user",), provider_keys=()) def _persist_runtime_env(spectrum_project_id: str, project_secret: str) -> None: diff --git a/plugins/platforms/photon/sidecar/package-lock.json b/plugins/platforms/photon/sidecar/package-lock.json index d9f3f9ff49f3..a98ff218bd72 100644 --- a/plugins/platforms/photon/sidecar/package-lock.json +++ b/plugins/platforms/photon/sidecar/package-lock.json @@ -72,9 +72,9 @@ } }, "node_modules/@opentelemetry/api-logs": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz", - "integrity": "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", + "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -111,16 +111,16 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-http": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.218.0.tgz", - "integrity": "sha512-Qx+4rpVHzgg89dawcWRHyt+XRXeLnhFz/qBtvggmjkcgPUdr+NAB0/u/eIPA8yAeJV0J80Vz43JZCh/XFvZFGw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.219.0.tgz", + "integrity": "sha512-mhl2HL6GmZI8b8PwPfqMws/5ovJfbRTxwc9Y5agVVHiQ+e5SL1btsFr/kJDgt7YCexDtsUn5HAreHQO9szFS0A==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-exporter-base": "0.218.0", - "@opentelemetry/otlp-transformer": "0.218.0", - "@opentelemetry/sdk-logs": "0.218.0" + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/sdk-logs": "0.219.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -129,32 +129,17 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/core": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", - "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/exporter-trace-otlp-http": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.218.0.tgz", - "integrity": "sha512-8dqezsmPhtKitIK/eTipZhYl9EX2/gNQ5zUMhaz3uxEURwfkNf8IPvo6yNfrzbxdtpAOybS/+h7wmIWYqFSpiw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.219.0.tgz", + "integrity": "sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-exporter-base": "0.218.0", - "@opentelemetry/otlp-transformer": "0.218.0", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/sdk-trace-base": "2.7.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -163,62 +148,14 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/core": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", - "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/resources": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", - "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", - "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.218.0.tgz", - "integrity": "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.219.0.tgz", + "integrity": "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-transformer": "0.218.0" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-transformer": "0.219.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -227,33 +164,18 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/core": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", - "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.218.0.tgz", - "integrity": "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.219.0.tgz", + "integrity": "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/sdk-logs": "0.218.0", - "@opentelemetry/sdk-metrics": "2.7.1", - "@opentelemetry/sdk-trace-base": "2.7.1" + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", + "@opentelemetry/sdk-metrics": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -262,54 +184,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/core": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", - "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", - "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", - "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@opentelemetry/resources": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", @@ -327,14 +201,14 @@ } }, "node_modules/@opentelemetry/sdk-logs": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz", - "integrity": "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", + "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -344,45 +218,14 @@ "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/core": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", - "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", - "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", - "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.8.0.tgz", + "integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -391,37 +234,6 @@ "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", - "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", - "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@opentelemetry/sdk-trace-base": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", diff --git a/plugins/platforms/photon/sidecar/package.json b/plugins/platforms/photon/sidecar/package.json index 0d0cff38d70e..442a49bdd969 100644 --- a/plugins/platforms/photon/sidecar/package.json +++ b/plugins/platforms/photon/sidecar/package.json @@ -17,9 +17,16 @@ }, "overrides": { "protobufjs": "8.7.1", - "@opentelemetry/otlp-transformer": "0.218.0", - "@opentelemetry/otlp-exporter-base": "0.218.0", - "@opentelemetry/exporter-trace-otlp-http": "0.218.0", - "@opentelemetry/exporter-logs-otlp-http": "0.218.0" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/context-async-hooks": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0", + "@opentelemetry/sdk-metrics": "2.8.0", + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/sdk-logs": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/exporter-trace-otlp-http": "0.219.0", + "@opentelemetry/exporter-logs-otlp-http": "0.219.0" } } diff --git a/plugins/web/xai/provider.py b/plugins/web/xai/provider.py index 77d80a439815..84a4aa2c6f75 100644 --- a/plugins/web/xai/provider.py +++ b/plugins/web/xai/provider.py @@ -287,14 +287,19 @@ def search(self, query: str, limit: int = 5) -> Dict[str, Any]: refresh_exc, ) body = "" - try: - body = exc.response.text[:300] if exc.response is not None else "" - except Exception: - body = "" + if status not in {401, 403}: + try: + body = exc.response.text[:300] if exc.response is not None else "" + except Exception: + body = "" logger.warning("xAI web search HTTP %d: %s", status, body) return { "success": False, - "error": f"xAI web search returned HTTP {status}: {body}".rstrip(), + "error": ( + f"xAI web search returned HTTP {status}" + if status in {401, 403} + else f"xAI web search returned HTTP {status}: {body}".rstrip() + ), } except httpx.RequestError as exc: logger.warning("xAI web search request error: %s", exc) diff --git a/scripts/auth_authority_config.py b/scripts/auth_authority_config.py new file mode 100644 index 000000000000..1d63bb591e42 --- /dev/null +++ b/scripts/auth_authority_config.py @@ -0,0 +1,48 @@ +"""Canonical fail-closed auth.authority loader for bootstrap scripts.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +_VALID_AUTHORITIES = {"shared", "profile"} + + +class AuthorityConfigError(RuntimeError, ValueError): + """Raised when auth.authority cannot be resolved safely.""" + + +def load_configured_authority(config_path: Path) -> str | None: + """Load auth.authority with the same YAML mapping contract as Hermes core.""" + if not config_path.is_file(): + return None + try: + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError) as exc: + raise AuthorityConfigError( + f"invalid auth authority config at {config_path}: {exc}" + ) from exc + + if raw is None: + return None + if not isinstance(raw, dict): + raise AuthorityConfigError( + f"invalid auth authority config at {config_path}: root must be a mapping" + ) + auth = raw.get("auth") + if auth is None: + return None + if not isinstance(auth, dict): + raise AuthorityConfigError( + f"invalid auth authority config at {config_path}: auth must be a mapping" + ) + mode = auth.get("authority") + if mode is None: + return None + if not isinstance(mode, str) or mode.strip().lower() not in _VALID_AUTHORITIES: + raise AuthorityConfigError( + f"Invalid auth.authority in auth authority config at {config_path}: " + "auth.authority must be 'shared' or 'profile'" + ) + return mode.strip().lower() diff --git a/scripts/auth_store_consumer_inventory.json b/scripts/auth_store_consumer_inventory.json new file mode 100644 index 000000000000..405989dc4b6c --- /dev/null +++ b/scripts/auth_store_consumer_inventory.json @@ -0,0 +1,19 @@ +{ + "version": 2, + "consumers": { + "agent/credential_pool.py": {"category": "non_io_security_guard", "reason": "non_io_guard_no_store_access"}, + "hermes_cli/auth.py": {"category": "canonical_authority_owner", "reason": "canonical_auth_authority"}, + "hermes_cli/auth_authority.py": {"category": "canonical_authority_owner", "reason": "canonical_auth_authority"}, + "hermes_cli/auth_migration.py": {"category": "whole_store_migration_adapter", "reason": "canonical_locked_migration"}, + "hermes_cli/backup.py": {"category": "whole_store_backup_restore_adapter", "reason": "canonical_locked_backup_restore"}, + "hermes_cli/profiles.py": {"category": "profile_clone_export_adapter", "reason": "explicit_profile_clone_export"}, + "nix/checks.nix": {"category": "repository_integration_harness", "reason": "build_time_authority_contract_check"}, + "scripts/check_auth_store_consumers.py": {"category": "non_io_security_guard", "reason": "non_io_guard_no_store_access"}, + "scripts/docker_auth_authority.py": {"category": "whole_store_boot_adapter", "reason": "canonical_locked_bootstrap"}, + "scripts/nix_auth_authority.py": {"category": "whole_store_deployment_adapter", "reason": "canonical_locked_deployment_seed"}, + "scripts/tool_search_livetest.py": {"category": "explicit_credential_copy_harness", "reason": "isolated_opt_in_credential_copy"}, + "plugins/google_meet/cli.py": {"category": "provider_native_store", "reason": "provider_native_non_hermes_store"}, + "docker/stage2-hook.sh": {"category": "whole_store_boot_adapter", "reason": "canonical_locked_bootstrap"}, + "nix/nixosModules.nix": {"category": "whole_store_deployment_adapter", "reason": "canonical_locked_deployment_seed"} + } +} diff --git a/scripts/check_auth_store_consumers.py b/scripts/check_auth_store_consumers.py new file mode 100644 index 000000000000..642d85f000d1 --- /dev/null +++ b/scripts/check_auth_store_consumers.py @@ -0,0 +1,1951 @@ +#!/usr/bin/env python3 +"""Reject unclassified production construction of a Hermes auth.json path.""" + +from __future__ import annotations + +import argparse +import ast +import itertools +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Optional, TypeVar + +_TEXT_SUFFIXES = {".js", ".mjs", ".cjs", ".ts", ".tsx", ".sh", ".nix"} +_EXCLUDED_PARTS = { + ".git", + ".venv", + "venv", + "node_modules", + "dist", + "build", + "tests", + "test", +} +_TEST_NAME_RE = re.compile(r"(?:^test_|_test\.py$|\.test\.[^.]+$|\.spec\.[^.]+$)") +_AUTH_BASENAME = "auth.json" +_FLOW_OVERFLOW = "" +_MAX_FLOW_ALTERNATIVES = 128 +_MAX_FORMAT_ATTEMPTS = 4096 +_MAX_DIRECT_CALL_WORK = 256 +_MAX_DIRECT_CALL_DEPTH = 21 +_MAX_STRUCTURED_ALTERNATIVES = 16 +_MAX_TEXT_FRAGMENT_CHAIN = 64 +_PATH_IO_METHODS = {"open", "read_bytes", "read_text", "write_bytes", "write_text"} +_PATH_CONSTRUCTORS = {"Path", "PurePath", "PurePosixPath", "PureWindowsPath"} +_FUNCTION_SYMBOL_PREFIX = "user_function:" +_CLASS_SYMBOL_PREFIX = "user_class:" +_INSTANCE_SYMBOL_PREFIX = "user_instance:" +_BOUND_METHOD_SYMBOL_PREFIX = "bound_method:" +_CALL_RESULT_SYMBOL_PREFIX = "call_result:" +_DIRECT_CALL_NON_IO_FINDINGS = { + "constructed_path", + "join", + "joinpath", + "path_division", +} + +# Reviewed exception contracts from the issue-380 consumer inventory. Reasons +# are machine values on purpose: free-form prose would let an inventory edit +# silence the rejector without selecting an approved behavior contract. +APPROVED_CLASSIFICATIONS = { + "canonical_authority_owner": frozenset({"canonical_auth_authority"}), + "whole_store_migration_adapter": frozenset({"canonical_locked_migration"}), + "whole_store_backup_restore_adapter": frozenset( + {"canonical_locked_backup_restore"} + ), + "profile_clone_export_adapter": frozenset({"explicit_profile_clone_export"}), + "whole_store_boot_adapter": frozenset({"canonical_locked_bootstrap"}), + "whole_store_deployment_adapter": frozenset( + {"canonical_locked_deployment_seed"} + ), + "explicit_credential_copy_harness": frozenset( + {"isolated_opt_in_credential_copy"} + ), + "provider_native_store": frozenset({"provider_native_non_hermes_store"}), + "repository_integration_harness": frozenset( + {"build_time_authority_contract_check"} + ), + "non_io_security_guard": frozenset({"non_io_guard_no_store_access"}), +} + + +@dataclass(frozen=True) +class Finding: + path: str + line: int + kind: str + + +@dataclass(frozen=True) +class InventoryEntry: + category: str + reason: str + + +def _is_test_or_generated(relative: Path) -> bool: + return any(part in _EXCLUDED_PARTS for part in relative.parts) or bool( + _TEST_NAME_RE.search(relative.name) + ) + + +def _is_auth_store_reference(value: str) -> bool: + # Fail closed when bounded flow analysis cannot retain every alternative. + if _FLOW_OVERFLOW in value: + return True + normalized = value.replace("\\", "/").rstrip("/") + return normalized == _AUTH_BASENAME or normalized.endswith(f"/{_AUTH_BASENAME}") + + +def _is_concrete_auth_store_reference(value: str) -> bool: + """Match a known auth-store path without treating string overflow as I/O.""" + return _FLOW_OVERFLOW not in value and _is_auth_store_reference(value) + + +_BUILTIN_OPEN = "builtin_open" +_BUILTINS_MODULE = "builtins_module" +_PATH_CONSTRUCTOR = "path_constructor" +_CONSTRUCTED_PATH_VALUE = "constructed_path_value" +_PATHLIB_MODULE = "pathlib_module" +_DYNAMIC_PART = "" + + +@dataclass(frozen=True) +class _FlowValue: + strings: frozenset[str] = frozenset() + symbols: frozenset[str] = frozenset() + sequences: tuple[tuple["_FlowValue", ...], ...] = () + mappings: tuple[tuple[tuple[str, "_FlowValue"], ...], ...] = () + + def merged(self, other: "_FlowValue") -> "_FlowValue": + if self == other: + return self + sequences, sequence_overflow = ( + _merge_structures(self.sequences, other.sequences) + if self.sequences or other.sequences + else ((), False) + ) + mappings, mapping_overflow = ( + _merge_structures(self.mappings, other.mappings) + if self.mappings or other.mappings + else ((), False) + ) + strings = self.strings | other.strings + if sequence_overflow or mapping_overflow: + strings = strings | {_FLOW_OVERFLOW} + return _FlowValue( + strings=_bounded_strings(strings), + symbols=self.symbols | other.symbols, + sequences=sequences, + mappings=mappings, + ) + + +_UNKNOWN_VALUE = _FlowValue() +_T = TypeVar("_T") + + +def _merge_structures( + left: tuple[_T, ...], right: tuple[_T, ...] +) -> tuple[tuple[_T, ...], bool]: + retained: list[_T] = [] + seen: set[_T] = set() + for value in (*left, *right): + if value in seen: + continue + seen.add(value) + retained.append(value) + if len(retained) > _MAX_STRUCTURED_ALTERNATIVES: + return tuple(retained[:_MAX_STRUCTURED_ALTERNATIVES]), True + return tuple(retained), False + + +def _bounded_strings(values: Iterable[str]) -> frozenset[str]: + retained: set[str] = set() + for value in values: + retained.add(value) + if len(retained) > _MAX_FLOW_ALTERNATIVES: + return frozenset([*sorted(retained)[:_MAX_FLOW_ALTERNATIVES], _FLOW_OVERFLOW]) + return frozenset(retained) + + +class _FlowScope: + def __init__( + self, + parent: "_FlowScope | None" = None, + *, + local_names: set[str] | None = None, + global_names: set[str] | None = None, + nonlocal_names: set[str] | None = None, + is_class_namespace: bool = False, + class_symbol: str | None = None, + named_expression_scope: "_FlowScope | None" = None, + ) -> None: + self.parent = parent + self.global_names = global_names or set() + self.nonlocal_names = nonlocal_names or set() + self.is_class_namespace = is_class_namespace + self.class_symbol = class_symbol + self.named_expression_scope = named_expression_scope + self.bindings = { + name: _UNKNOWN_VALUE + for name in (local_names or set()) - self.global_names - self.nonlocal_names + } + self.versions = {name: 0 for name in self.bindings} + + def _root(self) -> "_FlowScope": + scope = self + while scope.parent is not None: + scope = scope.parent + return scope + + def _nonlocal_target(self, name: str) -> "_FlowScope": + scope = self.parent + while scope is not None and scope.parent is not None: + if name in scope.bindings: + return scope + scope = scope.parent + return self.parent or self + + def _target(self, name: str) -> "_FlowScope": + if name in self.global_names: + return self._root() + if name in self.nonlocal_names: + return self._nonlocal_target(name) + return self + + def assign(self, name: str, value: _FlowValue) -> None: + target = self._target(name) + target.bindings[name] = value + target.versions[name] = target.versions.get(name, 0) + 1 + + def assign_named_expression(self, name: str, value: _FlowValue) -> None: + (self.named_expression_scope or self).assign(name, value) + + def resolve(self, name: str) -> _FlowValue: + if name in self.global_names: + return self._root()._resolve_local_or_builtin(name) + if name in self.nonlocal_names: + return self._nonlocal_target(name)._resolve_local_or_parent(name) + return self._resolve_local_or_parent(name) + + def _resolve_local_or_parent(self, name: str) -> _FlowValue: + if name in self.bindings: + return self.bindings[name] + if self.parent is not None: + return self.parent._resolve_local_or_parent(name) + return self._builtin_value(name) + + def _resolve_local_or_builtin(self, name: str) -> _FlowValue: + if name in self.bindings: + return self.bindings[name] + return self._builtin_value(name) + + @staticmethod + def _builtin_value(name: str) -> _FlowValue: + if name == "open": + return _FlowValue(symbols=frozenset({_BUILTIN_OPEN})) + if name in _PATH_CONSTRUCTORS: + return _FlowValue(symbols=frozenset({_PATH_CONSTRUCTOR})) + return _UNKNOWN_VALUE + + +@dataclass +class _DeferredFunction: + owner: _FlowScope + node: ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda + child: _FlowScope + symbol: str + method_kind: str = "function" + branch_chain: list[_FlowScope] | None = None + branch_bindings: list[dict[str, _FlowValue]] | None = None + branch_versions: list[dict[str, int]] | None = None + + +def _match_pattern_names(pattern: ast.pattern) -> set[str]: + names: set[str] = set() + for item in ast.walk(pattern): + if isinstance(item, (ast.MatchAs, ast.MatchStar)) and item.name is not None: + names.add(item.name) + elif isinstance(item, ast.MatchMapping) and item.rest is not None: + names.add(item.rest) + return names + + +def _is_irrefutable_match_pattern(pattern: ast.pattern) -> bool: + if isinstance(pattern, ast.MatchAs): + return pattern.pattern is None or _is_irrefutable_match_pattern(pattern.pattern) + if isinstance(pattern, ast.MatchOr): + return any(_is_irrefutable_match_pattern(item) for item in pattern.patterns) + return False + + +class _ScopeDeclarations(ast.NodeVisitor): + def __init__(self) -> None: + self.local_names: set[str] = set() + self.global_names: set[str] = set() + self.nonlocal_names: set[str] = set() + + def _bind_target(self, target: ast.AST) -> None: + if isinstance(target, ast.Name): + self.local_names.add(target.id) + elif isinstance(target, (ast.Tuple, ast.List)): + for element in target.elts: + self._bind_target(element) + + def visit_Assign(self, node: ast.Assign) -> None: + for target in node.targets: + self._bind_target(target) + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: + self._bind_target(node.target) + + def visit_AugAssign(self, node: ast.AugAssign) -> None: + self._bind_target(node.target) + + def visit_NamedExpr(self, node: ast.NamedExpr) -> None: + self._bind_target(node.target) + self.visit(node.value) + + def visit_For(self, node: ast.For) -> None: + self._bind_target(node.target) + for statement in [*node.body, *node.orelse]: + self.visit(statement) + + def visit_AsyncFor(self, node: ast.AsyncFor) -> None: + self._bind_target(node.target) + for statement in [*node.body, *node.orelse]: + self.visit(statement) + + def visit_With(self, node: ast.With) -> None: + for item in node.items: + if item.optional_vars is not None: + self._bind_target(item.optional_vars) + for statement in node.body: + self.visit(statement) + + def visit_AsyncWith(self, node: ast.AsyncWith) -> None: + for item in node.items: + if item.optional_vars is not None: + self._bind_target(item.optional_vars) + for statement in node.body: + self.visit(statement) + + def visit_Match(self, node: ast.Match) -> None: + self.visit(node.subject) + for case in node.cases: + self.local_names.update(_match_pattern_names(case.pattern)) + if case.guard is not None: + self.visit(case.guard) + for statement in case.body: + self.visit(statement) + + def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None: + if node.name is not None: + self.local_names.add(node.name) + if node.type is not None: + self.visit(node.type) + for statement in node.body: + self.visit(statement) + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + self.local_names.add(alias.asname or alias.name.split(".")[0]) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + for alias in node.names: + self.local_names.add(alias.asname or alias.name) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self.local_names.add(node.name) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self.local_names.add(node.name) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self.local_names.add(node.name) + + def visit_Lambda(self, node: ast.Lambda) -> None: + return + + def visit_Global(self, node: ast.Global) -> None: + self.global_names.update(node.names) + + def visit_Nonlocal(self, node: ast.Nonlocal) -> None: + self.nonlocal_names.update(node.names) + + +def _scope_declarations(statements: list[ast.stmt]) -> _ScopeDeclarations: + declarations = _ScopeDeclarations() + for statement in statements: + declarations.visit(statement) + return declarations + + +def _combine_strings( + left: frozenset[str], right: frozenset[str], separator: str = "" +) -> frozenset[str]: + left_values = left or frozenset({_DYNAMIC_PART}) + right_values = right or frozenset({_DYNAMIC_PART}) + return _bounded_strings( + f"{first}{separator}{second}" + for first in sorted(left_values) + for second in sorted(right_values) + ) + + +def _join_string_parts(parts: list[frozenset[str]], separator: str) -> frozenset[str]: + if not parts: + return frozenset() + combined = parts[0] or frozenset({_DYNAMIC_PART}) + for part in parts[1:]: + combined = _combine_strings(combined, part, separator) + return combined + + +class _PythonFlowAnalyzer: + def __init__(self, relative: str) -> None: + self.relative = relative + self.findings: list[Finding] = [] + self.seen_lines: set[int] = set() + self.deferred_functions: list[_DeferredFunction] = [] + self.function_templates: dict[str, _DeferredFunction] = {} + self.lambda_templates: dict[int, _DeferredFunction] = {} + self.class_methods: dict[tuple[str, str], tuple[str, str]] = {} + self.class_bases: dict[str, tuple[str, ...]] = {} + self.deferred_call_results: dict[str, tuple[ast.Call, _FlowScope]] = {} + self.active_function_calls: set[str] = set() + self.function_call_cache: dict[tuple[object, ...], _FlowValue] = {} + self.direct_function_work = 0 + self.sequence_expansion_work = 0 + self.direct_function_work_budgets: list[int] = [] + self.return_value_stack: list[list[_FlowValue]] = [] + self.direct_function_depth = 0 + + def analyze(self, tree: ast.Module) -> list[Finding]: + scope = _FlowScope() + self._analyze_block(tree.body, scope) + self._flush_functions(scope) + self._flush_lambdas(scope) + return self.findings + + def _record(self, node: ast.AST, kind: str) -> None: + line = getattr(node, "lineno", None) + if line is None or line in self.seen_lines: + return + self.findings.append(Finding(self.relative, line, kind)) + self.seen_lines.add(line) + + def _resolve_flow_value(self, value: _FlowValue) -> _FlowValue: + root_evaluation = not self.direct_function_work_budgets + if root_evaluation: + self.direct_function_work_budgets.append(0) + result = _FlowValue( + strings=value.strings, + symbols=frozenset( + symbol + for symbol in value.symbols + if not symbol.startswith(_CALL_RESULT_SYMBOL_PREFIX) + ), + sequences=value.sequences, + mappings=value.mappings, + ) + pending = [ + symbol + for symbol in value.symbols + if symbol.startswith(_CALL_RESULT_SYMBOL_PREFIX) + ] + seen: set[str] = set() + while pending: + symbol = pending.pop() + if symbol in seen: + continue + seen.add(symbol) + call = self.deferred_call_results.get(symbol) + if call is None: + continue + returned = self._evaluate_user_call(*call) + pending.extend( + item + for item in returned.symbols + if item.startswith(_CALL_RESULT_SYMBOL_PREFIX) + ) + result = result.merged( + _FlowValue( + strings=returned.strings, + symbols=frozenset( + item + for item in returned.symbols + if not item.startswith(_CALL_RESULT_SYMBOL_PREFIX) + ), + sequences=returned.sequences, + mappings=returned.mappings, + ) + ) + if root_evaluation: + self.direct_function_work_budgets.pop() + return result + + def _resolved_expression_value( + self, node: ast.AST, scope: _FlowScope + ) -> _FlowValue: + return self._resolve_flow_value(self._expression_value(node, scope)) + + def _class_method_candidates( + self, class_symbol: str, name: str, seen: frozenset[str] = frozenset() + ) -> tuple[tuple[str, str], ...]: + if class_symbol in seen: + return () + direct = self.class_methods.get((class_symbol, name)) + if direct is not None: + return (direct,) + next_seen = seen | {class_symbol} + for base_symbol in self.class_bases.get(class_symbol, ()): + inherited = self._class_method_candidates(base_symbol, name, next_seen) + if inherited: + return inherited + return () + + def _user_callable_symbols(self, value: _FlowValue) -> frozenset[str]: + symbols = { + symbol + for symbol in value.symbols + if symbol.startswith((_FUNCTION_SYMBOL_PREFIX, _BOUND_METHOD_SYMBOL_PREFIX)) + } + for owner_symbol in value.symbols: + if not owner_symbol.startswith(_INSTANCE_SYMBOL_PREFIX): + continue + class_symbol = owner_symbol.removeprefix(_INSTANCE_SYMBOL_PREFIX) + for function_symbol, method_kind in self._class_method_candidates( + class_symbol, "__call__" + ): + symbols.add( + function_symbol + if method_kind == "staticmethod" + else f"{_BOUND_METHOD_SYMBOL_PREFIX}{function_symbol}" + ) + return frozenset(symbols) + + def _sequence_value( + self, node: ast.Tuple | ast.List, scope: _FlowScope + ) -> _FlowValue: + alternatives: list[list[_FlowValue]] = [[]] + overflowed = False + for item in node.elts: + if isinstance(item, ast.Starred): + value = self._resolved_expression_value(item.value, scope) + options = value.sequences or ((_UNKNOWN_VALUE,),) + overflowed = overflowed or _FLOW_OVERFLOW in value.strings + if len(options) == 1: + option = options[0] + for prefix in alternatives: + prefix.extend(option) + self.sequence_expansion_work += len(option) + continue + + expanded: list[list[_FlowValue]] = [] + seen: set[tuple[_FlowValue, ...]] = set() + product_overflow = False + for prefix in alternatives: + for option in options: + candidate = (*prefix, *option) + self.sequence_expansion_work += len(candidate) + if candidate in seen: + continue + seen.add(candidate) + if len(expanded) >= _MAX_STRUCTURED_ALTERNATIVES: + product_overflow = True + break + expanded.append(list(candidate)) + if product_overflow: + break + alternatives = expanded + overflowed = overflowed or product_overflow + else: + value = self._expression_value(item, scope) + for prefix in alternatives: + prefix.append(value) + self.sequence_expansion_work += 1 + return _FlowValue( + strings=frozenset({_FLOW_OVERFLOW}) if overflowed else frozenset(), + sequences=tuple(tuple(option) for option in alternatives), + ) + + def _expression_value(self, node: ast.AST, scope: _FlowScope) -> _FlowValue: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return _FlowValue(strings=frozenset({node.value})) + if isinstance(node, ast.Lambda): + deferred = self._prepare_lambda(node, scope) + return _FlowValue(symbols=frozenset({deferred.symbol})) + if isinstance(node, ast.Starred): + return self._expression_value(node.value, scope) + if isinstance(node, (ast.Tuple, ast.List)): + return self._sequence_value(node, scope) + if isinstance(node, ast.Dict) and all( + isinstance(key, ast.Constant) and isinstance(key.value, str) + for key in node.keys + ): + return _FlowValue( + mappings=( + tuple( + (key.value, self._expression_value(value, scope)) + for key, value in zip(node.keys, node.values) + if isinstance(key, ast.Constant) + and isinstance(key.value, str) + ), + ) + ) + if isinstance(node, ast.Name): + return scope.resolve(node.id) + if isinstance(node, ast.NamedExpr): + return self._expression_value(node.value, scope) + if isinstance(node, ast.IfExp): + return self._expression_value(node.body, scope).merged( + self._expression_value(node.orelse, scope) + ) + if isinstance(node, ast.Attribute): + owner = self._expression_value(node.value, scope) + if node.attr == "open" and _BUILTINS_MODULE in owner.symbols: + return _FlowValue(symbols=frozenset({_BUILTIN_OPEN})) + if node.attr in _PATH_CONSTRUCTORS and _PATHLIB_MODULE in owner.symbols: + return _FlowValue(symbols=frozenset({_PATH_CONSTRUCTOR})) + symbols: set[str] = set() + for owner_symbol in owner.symbols: + if owner_symbol.startswith(_CLASS_SYMBOL_PREFIX): + for function_symbol, method_kind in self._class_method_candidates( + owner_symbol, node.attr + ): + symbols.add( + f"{_BOUND_METHOD_SYMBOL_PREFIX}{function_symbol}" + if method_kind == "classmethod" + else function_symbol + ) + elif owner_symbol.startswith(_INSTANCE_SYMBOL_PREFIX): + class_symbol = owner_symbol.removeprefix(_INSTANCE_SYMBOL_PREFIX) + for function_symbol, method_kind in self._class_method_candidates( + class_symbol, node.attr + ): + symbols.add( + function_symbol + if method_kind == "staticmethod" + else f"{_BOUND_METHOD_SYMBOL_PREFIX}{function_symbol}" + ) + return _FlowValue(symbols=frozenset(symbols)) + if isinstance(node, ast.JoinedStr): + parts = [ + self._resolved_expression_value( + value.value if isinstance(value, ast.FormattedValue) else value, + scope, + ).strings + for value in node.values + ] + return _FlowValue(strings=_join_string_parts(parts, "")) + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Div)): + left = self._resolved_expression_value(node.left, scope) + right = self._resolved_expression_value(node.right, scope) + return _FlowValue( + strings=_combine_strings( + left.strings, + right.strings, + "/" if isinstance(node.op, ast.Div) else "", + ), + symbols=frozenset({_CONSTRUCTED_PATH_VALUE}) + if _CONSTRUCTED_PATH_VALUE in left.symbols | right.symbols + else frozenset(), + ) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Mod): + templates = self._resolved_expression_value(node.left, scope).strings + mappings = self._resolved_expression_value(node.right, scope).mappings + if mappings: + mapping_products: list[ + tuple[list[str], Iterable[tuple[str, ...]]] + ] = [] + for mapping in mappings: + keys = [key for key, _ in mapping] + option_sets = [ + self._resolve_flow_value(value).strings + or frozenset({_DYNAMIC_PART}) + for _, value in mapping + ] + mapping_products.append( + ( + keys, + itertools.product( + *(sorted(options) for options in option_sets) + ), + ) + ) + operands: Iterable[object] = ( + dict(zip(keys, values)) + for keys, products in mapping_products + for values in products + ) + elif isinstance(node.right, ast.Tuple): + option_sets = [ + self._resolved_expression_value(item, scope).strings + or frozenset({_DYNAMIC_PART}) + for item in node.right.elts + ] + operands = ( + arguments if len(arguments) != 1 else arguments[0] + for arguments in itertools.product( + *(sorted(options) for options in option_sets) + ) + ) + else: + options = ( + self._resolved_expression_value(node.right, scope).strings + or frozenset({_DYNAMIC_PART}) + ) + operands = iter(sorted(options)) + values: set[str] = set() + for attempt, operand in enumerate(operands, start=1): + if attempt > _MAX_FORMAT_ATTEMPTS: + values.add(_FLOW_OVERFLOW) + break + for template in sorted(templates): + try: + values.add(template % operand) + except (TypeError, ValueError): + continue + if len(values) > _MAX_FLOW_ALTERNATIVES: + return _FlowValue(strings=_bounded_strings(values)) + return _FlowValue(strings=_bounded_strings(values)) + if not isinstance(node, ast.Call): + return _UNKNOWN_VALUE + + callable_value = self._expression_value(node.func, scope) + if self._user_callable_symbols(callable_value): + symbol = f"{_CALL_RESULT_SYMBOL_PREFIX}{id(node)}" + self.deferred_call_results[symbol] = (node, scope) + return _FlowValue(symbols=frozenset({symbol})) + class_symbols = sorted( + symbol + for symbol in callable_value.symbols + if symbol.startswith(_CLASS_SYMBOL_PREFIX) + ) + if class_symbols: + return _FlowValue( + symbols=frozenset( + f"{_INSTANCE_SYMBOL_PREFIX}{symbol}" for symbol in class_symbols + ) + ) + if _PATH_CONSTRUCTOR in callable_value.symbols and node.args: + path_values = { + value + for positional, _ in self._call_signatures(node, scope) + for value in _join_string_parts( + [argument.strings for argument in positional], "/" + ) + } + if any( + isinstance(argument, ast.Starred) + and _FLOW_OVERFLOW + in self._resolved_expression_value(argument.value, scope).strings + for argument in node.args + ): + path_values.add(_FLOW_OVERFLOW) + return _FlowValue( + strings=_bounded_strings(path_values), + symbols=frozenset({_CONSTRUCTED_PATH_VALUE}), + ) + if not isinstance(node.func, ast.Attribute): + return _UNKNOWN_VALUE + + method = node.func.attr + owner = self._resolved_expression_value(node.func.value, scope) + owner_strings = owner.strings + path_symbols = ( + frozenset({_CONSTRUCTED_PATH_VALUE}) + if _CONSTRUCTED_PATH_VALUE in owner.symbols + else frozenset() + ) + if method == "with_suffix" and len(node.args) == 1: + suffixes = self._resolved_expression_value(node.args[0], scope).strings + suffix_values: list[str] = [] + for base in sorted(owner_strings): + for suffix in sorted(suffixes): + try: + suffix_values.append(str(Path(base).with_suffix(suffix))) + except ValueError: + continue + return _FlowValue( + strings=_bounded_strings(suffix_values), symbols=path_symbols + ) + if method == "joinpath": + return _FlowValue( + strings=_join_string_parts( + [owner_strings] + + [ + self._resolved_expression_value(arg, scope).strings + for arg in node.args + ], + "/", + ), + symbols=path_symbols, + ) + if ( + method == "join" + and len(node.args) == 1 + and isinstance(node.args[0], (ast.Tuple, ast.List)) + ): + separators = owner_strings or frozenset({_DYNAMIC_PART}) + parts = [ + self._resolved_expression_value(element, scope).strings + for element in node.args[0].elts + ] + joined_values = ( + value + for separator in sorted(separators) + for value in _join_string_parts(parts, separator) + ) + return _FlowValue(strings=_bounded_strings(joined_values)) + if method == "join" and node.args: + return _FlowValue( + strings=_join_string_parts( + [ + self._resolved_expression_value(arg, scope).strings + for arg in node.args + ], + "/", + ) + ) + if method == "format" and owner_strings: + positional_options = [ + self._resolved_expression_value(arg, scope).strings + or frozenset({_DYNAMIC_PART}) + for arg in node.args + ] + keyword_items = [ + ( + item.arg, + self._resolved_expression_value(item.value, scope).strings + or frozenset({_DYNAMIC_PART}), + ) + for item in node.keywords + if item.arg is not None + ] + values: set[str] = set() + attempts = 0 + overflowed = False + for template in sorted(owner_strings): + positional_product = itertools.product( + *(sorted(options) for options in positional_options) + ) + for positional in positional_product: + keyword_product = itertools.product( + *(sorted(options) for _, options in keyword_items) + ) + for keyword_values in keyword_product: + attempts += 1 + if attempts > _MAX_FORMAT_ATTEMPTS: + overflowed = True + break + keyword = { + name: value + for (name, _), value in zip( + keyword_items, keyword_values + ) + } + try: + values.add(template.format(*positional, **keyword)) + except (IndexError, KeyError, ValueError): + continue + if len(values) > _MAX_FLOW_ALTERNATIVES: + overflowed = True + break + if overflowed: + break + if overflowed: + break + if overflowed: + values = set(sorted(values)[:_MAX_FLOW_ALTERNATIVES]) + values.add(_FLOW_OVERFLOW) + return _FlowValue(strings=frozenset(values)) + return _UNKNOWN_VALUE + + def _call_signatures( + self, node: ast.Call, scope: _FlowScope + ) -> list[tuple[tuple[_FlowValue, ...], tuple[tuple[str, _FlowValue], ...]]]: + signatures: list[tuple[list[_FlowValue], dict[str, _FlowValue]]] = [ + ([], {}) + ] + overflowed = False + for argument in node.args: + if isinstance(argument, ast.Starred): + value = self._resolved_expression_value(argument.value, scope) + options = value.sequences + if not options: + options = ((_UNKNOWN_VALUE,),) + options = tuple( + tuple(self._resolve_flow_value(item) for item in option) + for option in options + ) + overflowed = overflowed or ( + len(signatures) * len(options) > _MAX_FLOW_ALTERNATIVES + ) + expanded = [] + for positional, keywords in signatures: + for option in options: + if len(expanded) >= _MAX_FLOW_ALTERNATIVES: + overflowed = True + break + expanded.append(([*positional, *option], dict(keywords))) + if len(expanded) >= _MAX_FLOW_ALTERNATIVES: + break + else: + value = self._resolved_expression_value(argument, scope) + expanded = [ + ([*positional, value], dict(keywords)) + for positional, keywords in signatures + ] + overflowed = overflowed or len(expanded) > _MAX_FLOW_ALTERNATIVES + signatures = expanded[:_MAX_FLOW_ALTERNATIVES] + + for keyword in node.keywords: + if keyword.arg is not None: + value = self._resolved_expression_value(keyword.value, scope) + for _, keywords in signatures: + keywords[keyword.arg] = value + continue + options = self._resolved_expression_value(keyword.value, scope).mappings + if not options: + options = ((('', _UNKNOWN_VALUE),),) + options = tuple( + tuple((name, self._resolve_flow_value(value)) for name, value in option) + for option in options + ) + expanded = [] + for positional, keywords in signatures: + for option in options: + merged = dict(keywords) + merged.update(dict(option)) + merged.pop("", None) + expanded.append((list(positional), merged)) + overflowed = overflowed or len(expanded) > _MAX_FLOW_ALTERNATIVES + signatures = expanded[:_MAX_FLOW_ALTERNATIVES] + + if overflowed and signatures: + positional, keywords = signatures[0] + signatures[0] = ( + [*positional, _FlowValue(strings=frozenset({_FLOW_OVERFLOW}))], + keywords, + ) + return [ + (tuple(positional), tuple(sorted(keywords.items()))) + for positional, keywords in signatures + ] + + @staticmethod + def _clone_function_scope(template: _FlowScope) -> _FlowScope: + cloned = _FlowScope( + template.parent, + local_names=set(template.bindings), + global_names=set(template.global_names), + nonlocal_names=set(template.nonlocal_names), + named_expression_scope=template.named_expression_scope, + ) + cloned.bindings = dict(template.bindings) + cloned.versions = dict(template.versions) + return cloned + + def _bind_call_arguments( + self, + call: ast.Call, + deferred: _DeferredFunction, + caller: _FlowScope, + child: _FlowScope, + *, + bound_method: bool = False, + signatures: tuple[ + tuple[tuple[_FlowValue, ...], tuple[tuple[str, _FlowValue], ...]], ... + ] + | None = None, + ) -> None: + positional = [ + *deferred.node.args.posonlyargs, + *deferred.node.args.args, + ] + parameter_names = { + item.arg for item in [*positional, *deferred.node.args.kwonlyargs] + } + bound: dict[str, _FlowValue] = {} + vararg_values: list[tuple[_FlowValue, ...]] = [] + kwarg_values: list[tuple[tuple[str, _FlowValue], ...]] = [] + call_signatures = signatures or tuple(self._call_signatures(call, caller)) + for call_positional, call_keywords in call_signatures: + if bound_method: + call_positional = (_UNKNOWN_VALUE, *call_positional) + for argument, value in zip(positional, call_positional): + bound[argument.arg] = bound.get(argument.arg, _UNKNOWN_VALUE).merged( + value + ) + if deferred.node.args.vararg is not None: + vararg_values.append(tuple(call_positional[len(positional) :])) + extra_keywords: list[tuple[str, _FlowValue]] = [] + for name, value in call_keywords: + if name in parameter_names: + bound[name] = bound.get(name, _UNKNOWN_VALUE).merged(value) + else: + extra_keywords.append((name, value)) + if deferred.node.args.kwarg is not None: + kwarg_values.append(tuple(sorted(extra_keywords))) + for name, value in bound.items(): + child.assign(name, value) + if deferred.node.args.vararg is not None: + child.assign( + deferred.node.args.vararg.arg, + _FlowValue(sequences=tuple(vararg_values)), + ) + if deferred.node.args.kwarg is not None: + child.assign( + deferred.node.args.kwarg.arg, + _FlowValue(mappings=tuple(kwarg_values)), + ) + + def _evaluate_user_call(self, node: ast.Call, scope: _FlowScope) -> _FlowValue: + root_evaluation = not self.direct_function_work_budgets + if root_evaluation: + self.direct_function_work_budgets.append(0) + result = _UNKNOWN_VALUE + callable_value = self._expression_value(node.func, scope) + candidates = [ + ( + raw_symbol.removeprefix(_BOUND_METHOD_SYMBOL_PREFIX), + raw_symbol.startswith(_BOUND_METHOD_SYMBOL_PREFIX), + ) + for raw_symbol in sorted(self._user_callable_symbols(callable_value)) + if raw_symbol.removeprefix(_BOUND_METHOD_SYMBOL_PREFIX) + not in self.active_function_calls + ] + if not candidates: + if root_evaluation: + self.direct_function_work_budgets.pop() + return result + signature_guards = {symbol for symbol, _ in candidates} + self.active_function_calls.update(signature_guards) + try: + signatures = tuple(self._call_signatures(node, scope)) + finally: + self.active_function_calls.difference_update(signature_guards) + for symbol, bound_method in candidates: + deferred = self.function_templates.get(symbol) + if deferred is None: + continue + environment = tuple( + tuple(sorted(item.bindings.items())) + for item in self._scope_chain(deferred.owner) + ) + cache_key = (symbol, bound_method, signatures, environment) + cached = self.function_call_cache.get(cache_key) + if cached is not None: + result = result.merged(cached) + continue + if ( + self.direct_function_depth >= _MAX_DIRECT_CALL_DEPTH + or self.direct_function_work_budgets[-1] >= _MAX_DIRECT_CALL_WORK + ): + self._record(node, "analysis_overflow") + result = result.merged( + _FlowValue(strings=frozenset({_FLOW_OVERFLOW})) + ) + continue + self.direct_function_work += 1 + self.direct_function_work_budgets[-1] += 1 + child = self._clone_function_scope(deferred.child) + self._bind_call_arguments( + node, + deferred, + scope, + child, + bound_method=bound_method, + signatures=signatures, + ) + returns: list[_FlowValue] = [] + self.return_value_stack.append(returns) + self.active_function_calls.add(symbol) + self.direct_function_depth += 1 + try: + self._analyze_function_body(deferred.node, child, deferred.owner) + finally: + self.direct_function_depth -= 1 + self.active_function_calls.remove(symbol) + self.return_value_stack.pop() + returned = _UNKNOWN_VALUE + for value in returns: + returned = returned.merged(value) + self.function_call_cache[cache_key] = returned + result = result.merged(returned) + if root_evaluation: + self.direct_function_work_budgets.pop() + return result + + def _analyze_direct_function_calls( + self, node: ast.Call, scope: _FlowScope + ) -> None: + callable_value = self._expression_value(node.func, scope) + if not any( + symbol.removeprefix(_BOUND_METHOD_SYMBOL_PREFIX) + in self.function_templates + for symbol in self._user_callable_symbols(callable_value) + ): + return + if not any( + _is_auth_store_reference(value) + for positional, keywords in self._call_signatures(node, scope) + for argument in [*positional, *(value for _, value in keywords)] + for value in argument.strings + ): + return + self._evaluate_user_call(node, scope) + + def _finding_kind(self, node: ast.AST, scope: _FlowScope) -> Optional[str]: + if isinstance(node, ast.Call): + callable_value = self._expression_value(node.func, scope) + if _BUILTIN_OPEN in callable_value.symbols: + open_paths = [ + positional[0] + if positional + else dict(keywords).get("file", _UNKNOWN_VALUE) + for positional, keywords in self._call_signatures(node, scope) + ] + if any( + _is_auth_store_reference(value) + for open_path in open_paths + for value in open_path.strings + ): + return "open" + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr in _PATH_IO_METHODS + and any( + _is_auth_store_reference(value) + for value in self._resolved_expression_value( + node.func.value, scope + ).strings + ) + ): + return node.func.attr + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr + in { + "join", + "joinpath", + } + and any( + _is_auth_store_reference(value) + for value in self._resolved_expression_value(node, scope).strings + ) + ): + return node.func.attr + if ( + isinstance(node, ast.BinOp) + and isinstance(node.op, ast.Div) + and any( + _is_auth_store_reference(value) + for value in self._resolved_expression_value(node, scope).strings + ) + ): + return "path_division" + user_callable = False + if isinstance(node, ast.Call): + callable_value = self._expression_value(node.func, scope) + user_callable = bool(self._user_callable_symbols(callable_value)) + if user_callable and (node.args or node.keywords): + arguments = [ + value + for positional, keywords in self._call_signatures(node, scope) + for value in [*positional, *(item for _, item in keywords)] + ] + if arguments and all(value == _UNKNOWN_VALUE for value in arguments): + return None + if isinstance(node, (ast.Call, ast.JoinedStr, ast.BinOp)): + resolved = self._resolved_expression_value(node, scope) + if user_callable and _CONSTRUCTED_PATH_VALUE not in resolved.symbols: + return None + values = resolved.strings + reference_match = ( + _is_auth_store_reference + if isinstance(node, ast.Call) + else _is_concrete_auth_store_reference + ) + if any(reference_match(value) for value in values): + return "constructed_path" + return None + + def _scan_expression(self, node: ast.AST, scope: _FlowScope) -> None: + if isinstance(node, ast.NamedExpr): + self._scan_expression(node.value, scope) + value = self._expression_value(node.value, scope) + if isinstance(node.target, ast.Name): + scope.assign_named_expression(node.target.id, value) + else: + self._assign_target(node.target, value, scope) + return + if isinstance(node, ast.Lambda): + self._analyze_lambda(node, scope) + return + if isinstance( + node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp) + ): + self._analyze_comprehension(node, scope) + return + if isinstance(node, ast.Call): + self._analyze_direct_function_calls(node, scope) + kind = self._finding_kind(node, scope) + if kind is not None and not ( + self.direct_function_depth + and kind in _DIRECT_CALL_NON_IO_FINDINGS + ): + self._record(node, kind) + for child in ast.iter_child_nodes(node): + if isinstance(child, ast.expr): + self._scan_expression(child, scope) + + @staticmethod + def _target_names(target: ast.AST) -> set[str]: + if isinstance(target, ast.Name): + return {target.id} + if isinstance(target, (ast.Tuple, ast.List)): + return set().union( + *(_PythonFlowAnalyzer._target_names(item) for item in target.elts) + ) + return set() + + def _analyze_comprehension( + self, + node: ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp, + scope: _FlowScope, + ) -> None: + generators = node.generators + if not generators: + return + self._scan_expression(generators[0].iter, scope) + local_names = set().union( + *(self._target_names(generator.target) for generator in generators) + ) + child = _FlowScope( + scope, + local_names=local_names, + named_expression_scope=scope.named_expression_scope or scope, + ) + for index, generator in enumerate(generators): + iteration_scope = scope if index == 0 else child + if index: + self._scan_expression(generator.iter, iteration_scope) + self._assign_target( + generator.target, + self._iterated_value(generator.iter, iteration_scope), + child, + ) + for condition in generator.ifs: + self._scan_expression(condition, child) + if isinstance(node, ast.DictComp): + self._scan_expression(node.key, child) + self._scan_expression(node.value, child) + else: + self._scan_expression(node.elt, child) + + def _prepare_lambda( + self, node: ast.Lambda, scope: _FlowScope + ) -> _DeferredFunction: + existing = self.lambda_templates.get(id(node)) + if existing is not None: + return existing + defaults = [*node.args.defaults] + [ + item for item in node.args.kw_defaults if item is not None + ] + for default in defaults: + self._scan_expression(default, scope) + parameters = [ + *node.args.posonlyargs, + *node.args.args, + *node.args.kwonlyargs, + ] + local_names = {item.arg for item in parameters} + if node.args.vararg is not None: + local_names.add(node.args.vararg.arg) + if node.args.kwarg is not None: + local_names.add(node.args.kwarg.arg) + declarations = _ScopeDeclarations() + declarations.visit(node.body) + local_names.update(declarations.local_names) + body_parent = scope + while body_parent.is_class_namespace and body_parent.parent is not None: + body_parent = body_parent.parent + child = _FlowScope(body_parent, local_names=local_names) + positional = [*node.args.posonlyargs, *node.args.args] + if node.args.defaults: + for argument, default in zip( + positional[-len(node.args.defaults) :], node.args.defaults + ): + child.assign(argument.arg, self._expression_value(default, scope)) + for argument, default in zip(node.args.kwonlyargs, node.args.kw_defaults): + if default is not None: + child.assign(argument.arg, self._expression_value(default, scope)) + symbol = f"{_FUNCTION_SYMBOL_PREFIX}{id(node)}" + deferred = _DeferredFunction(body_parent, node, child, symbol) + self.lambda_templates[id(node)] = deferred + self.function_templates[symbol] = deferred + self.deferred_functions.append(deferred) + return deferred + + def _analyze_lambda(self, node: ast.Lambda, scope: _FlowScope) -> None: + self._prepare_lambda(node, scope) + + def _flush_lambdas(self, owner: _FlowScope) -> None: + return + + def _assign_target( + self, target: ast.AST, value: _FlowValue, scope: _FlowScope + ) -> None: + if isinstance(target, ast.Name): + scope.assign(target.id, value) + elif isinstance(target, (ast.Tuple, ast.List)): + for element in target.elts: + self._assign_target(element, _UNKNOWN_VALUE, scope) + + def _assign_expression_target( + self, target: ast.AST, expression: ast.AST, scope: _FlowScope + ) -> None: + if ( + isinstance(target, (ast.Tuple, ast.List)) + and isinstance(expression, (ast.Tuple, ast.List)) + and len(target.elts) == len(expression.elts) + ): + for target_item, value_item in zip(target.elts, expression.elts): + self._assign_expression_target(target_item, value_item, scope) + return + self._assign_target(target, self._expression_value(expression, scope), scope) + + def _iterated_value(self, expression: ast.AST, scope: _FlowScope) -> _FlowValue: + if not isinstance(expression, (ast.Tuple, ast.List, ast.Set)): + return _UNKNOWN_VALUE + value = _UNKNOWN_VALUE + for element in expression.elts: + value = value.merged(self._expression_value(element, scope)) + return value + + def _scope_chain(self, scope: _FlowScope) -> list[_FlowScope]: + chain = [] + current: Optional[_FlowScope] = scope + while current is not None: + chain.append(current) + current = current.parent + return list(reversed(chain)) + + def _analyze_branches( + self, + scope: _FlowScope, + branches: list[list[ast.stmt]], + initial_bindings: list[dict[str, _FlowValue]] | None = None, + ) -> None: + chain = self._scope_chain(scope) + original = self._capture_bindings(chain) + outcomes: list[list[dict[str, _FlowValue]]] = [] + branch_bindings = initial_bindings or [{} for _ in branches] + constrained: list[_DeferredFunction] = [] + for branch, initial in zip(branches, branch_bindings): + self._restore_bindings(chain, original) + for name, value in initial.items(): + scope.assign(name, value) + known_functions = {id(item) for item in self.deferred_functions} + self._analyze_block(branch, scope) + outcome = self._capture_bindings(chain) + outcomes.append(outcome) + for item in self.deferred_functions: + if id(item) in known_functions or item.owner not in chain: + continue + item.branch_chain = list(chain) + item.branch_bindings = [dict(bindings) for bindings in outcome] + constrained.append(item) + self._restore_bindings(chain, self._merge_binding_snapshots(outcomes)) + baseline = self._capture_versions(chain) + for item in constrained: + item.branch_versions = [dict(versions) for versions in baseline] + + @staticmethod + def _capture_bindings(chain: list[_FlowScope]) -> list[dict[str, _FlowValue]]: + return [dict(item.bindings) for item in chain] + + @staticmethod + def _capture_versions(chain: list[_FlowScope]) -> list[dict[str, int]]: + return [dict(item.versions) for item in chain] + + @staticmethod + def _restore_bindings( + chain: list[_FlowScope], snapshot: list[dict[str, _FlowValue]] + ) -> None: + for item, bindings in zip(chain, snapshot): + item.bindings = dict(bindings) + + @staticmethod + def _merge_binding_snapshots( + outcomes: list[list[dict[str, _FlowValue]]], + ) -> list[dict[str, _FlowValue]]: + merged_snapshot: list[dict[str, _FlowValue]] = [] + for index in range(len(outcomes[0])): + first = outcomes[0][index] + if all(outcome[index] == first for outcome in outcomes[1:]): + merged_snapshot.append(dict(first)) + continue + names = set().union(*(outcome[index].keys() for outcome in outcomes)) + merged: dict[str, _FlowValue] = {} + for name in names: + values = [ + outcome[index].get(name, _UNKNOWN_VALUE) for outcome in outcomes + ] + value = values[0] + if any(candidate != value for candidate in values[1:]): + for candidate in values[1:]: + value = value.merged(candidate) + merged[name] = value + merged_snapshot.append(merged) + return merged_snapshot + + def _analyze_try( + self, node: ast.Try | ast.TryStar, scope: _FlowScope + ) -> None: + chain = self._scope_chain(scope) + original = self._capture_bindings(chain) + + try_prefixes = [original] + for statement in node.body: + self._analyze_block([statement], scope) + try_prefixes.append(self._capture_bindings(chain)) + body_endpoint = self._capture_bindings(chain) + self._analyze_block(node.orelse, scope) + outcomes = [self._capture_bindings(chain)] + + handler_entry = self._merge_binding_snapshots(try_prefixes) + for handler in node.handlers: + self._restore_bindings(chain, handler_entry) + if handler.type is not None: + self._scan_expression(handler.type, scope) + if handler.name is not None: + scope.assign(handler.name, _UNKNOWN_VALUE) + self._analyze_block(handler.body, scope) + outcomes.append(self._capture_bindings(chain)) + + self._restore_bindings(chain, self._merge_binding_snapshots(outcomes)) + self._analyze_block(node.finalbody, scope) + + def _analyze_match(self, node: ast.Match, scope: _FlowScope) -> None: + self._scan_expression(node.subject, scope) + branches: list[list[ast.stmt]] = [] + initial_bindings: list[dict[str, _FlowValue]] = [] + for case in node.cases: + for item in ast.walk(case.pattern): + if isinstance(item, ast.expr): + self._scan_expression(item, scope) + branch = list(case.body) + if case.guard is not None: + branch.insert(0, ast.Expr(value=case.guard)) + branches.append(branch) + initial_bindings.append( + {name: _UNKNOWN_VALUE for name in _match_pattern_names(case.pattern)} + ) + if not any( + case.guard is None and _is_irrefutable_match_pattern(case.pattern) + for case in node.cases + ): + branches.append([]) + initial_bindings.append({}) + self._analyze_branches(scope, branches, initial_bindings) + + def _function_scope( + self, node: ast.FunctionDef | ast.AsyncFunctionDef, parent: _FlowScope + ) -> _FlowScope: + declarations = _scope_declarations(node.body) + parameters = [ + *node.args.posonlyargs, + *node.args.args, + *node.args.kwonlyargs, + ] + local_names = declarations.local_names | {item.arg for item in parameters} + if node.args.vararg is not None: + local_names.add(node.args.vararg.arg) + if node.args.kwarg is not None: + local_names.add(node.args.kwarg.arg) + child = _FlowScope( + parent, + local_names=local_names, + global_names=declarations.global_names, + nonlocal_names=declarations.nonlocal_names, + ) + positional = [*node.args.posonlyargs, *node.args.args] + if node.args.defaults: + default_parameters = positional[-len(node.args.defaults) :] + for argument, default in zip(default_parameters, node.args.defaults): + child.assign(argument.arg, self._expression_value(default, parent)) + for argument, default in zip(node.args.kwonlyargs, node.args.kw_defaults): + if default is not None: + child.assign(argument.arg, self._expression_value(default, parent)) + return child + + def _prepare_function( + self, node: ast.FunctionDef | ast.AsyncFunctionDef, scope: _FlowScope + ) -> _DeferredFunction: + expressions = [*node.decorator_list, *node.args.defaults] + [ + item for item in node.args.kw_defaults if item is not None + ] + for expression in expressions: + self._scan_expression(expression, scope) + body_parent = scope + while body_parent.is_class_namespace and body_parent.parent is not None: + body_parent = body_parent.parent + child = self._function_scope(node, body_parent) + symbol = f"{_FUNCTION_SYMBOL_PREFIX}{id(node)}" + decorator_names = { + decorator.id + for decorator in node.decorator_list + if isinstance(decorator, ast.Name) + } + method_kind = ( + "staticmethod" + if "staticmethod" in decorator_names + else "classmethod" + if "classmethod" in decorator_names + else "instance" + if scope.is_class_namespace + else "function" + ) + deferred = _DeferredFunction( + body_parent, node, child, symbol, method_kind=method_kind + ) + self.function_templates[symbol] = deferred + if scope.class_symbol is not None: + self.class_methods[(scope.class_symbol, node.name)] = (symbol, method_kind) + scope.assign(node.name, _FlowValue(symbols=frozenset({symbol}))) + return deferred + + def _analyze_function_body( + self, + node: ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda, + child: _FlowScope, + scope: _FlowScope, + ) -> None: + chain = self._scope_chain(scope) + saved_bindings = [dict(item.bindings) for item in chain] + saved_versions = [dict(item.versions) for item in chain] + if isinstance(node, ast.Lambda): + self._scan_expression(node.body, child) + if self.return_value_stack: + self.return_value_stack[-1].append( + self._expression_value(node.body, child) + ) + else: + self._analyze_block(node.body, child) + self._flush_functions(child) + self._flush_lambdas(child) + for item, bindings, versions in zip( + chain, saved_bindings, saved_versions + ): + item.bindings = bindings + item.versions = versions + + def _analyze_deferred_function( + self, deferred: _DeferredFunction, child: _FlowScope + ) -> None: + if ( + deferred.branch_chain is None + or deferred.branch_bindings is None + or deferred.branch_versions is None + ): + self._analyze_function_body( + deferred.node, child, deferred.owner + ) + return + + chain = deferred.branch_chain + saved_bindings = self._capture_bindings(chain) + saved_versions = self._capture_versions(chain) + for scope, branch_bindings, baseline_versions in zip( + chain, deferred.branch_bindings, deferred.branch_versions + ): + current_bindings = dict(scope.bindings) + current_versions = dict(scope.versions) + names = set(branch_bindings) | set(current_bindings) + scope.bindings = { + name: ( + current_bindings.get(name, _UNKNOWN_VALUE) + if current_versions.get(name, 0) + > baseline_versions.get(name, 0) + else branch_bindings.get(name, _UNKNOWN_VALUE) + ) + for name in names + } + self._analyze_function_body(deferred.node, child, deferred.owner) + for scope, bindings, versions in zip( + chain, saved_bindings, saved_versions + ): + scope.bindings = bindings + scope.versions = versions + + def _flush_functions(self, owner: _FlowScope) -> None: + pending = [item for item in self.deferred_functions if item.owner is owner] + self.deferred_functions = [ + item for item in self.deferred_functions if item.owner is not owner + ] + for item in pending: + self._analyze_deferred_function(item, item.child) + + def _analyze_statement(self, node: ast.stmt, scope: _FlowScope) -> None: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + self.deferred_functions.append(self._prepare_function(node, scope)) + return + if isinstance(node, ast.ClassDef): + for expression in [*node.decorator_list, *node.bases]: + self._scan_expression(expression, scope) + class_symbol = f"{_CLASS_SYMBOL_PREFIX}{id(node)}" + self.class_bases[class_symbol] = tuple( + symbol + for base in node.bases + for symbol in sorted(self._expression_value(base, scope).symbols) + if symbol.startswith(_CLASS_SYMBOL_PREFIX) + ) + scope.assign(node.name, _FlowValue(symbols=frozenset({class_symbol}))) + declarations = _scope_declarations(node.body) + child = _FlowScope( + scope, + global_names=declarations.global_names, + nonlocal_names=declarations.nonlocal_names, + is_class_namespace=True, + class_symbol=class_symbol, + ) + self._analyze_block(node.body, child) + self._flush_functions(child) + self._flush_lambdas(child) + return + if isinstance(node, ast.Return): + value = _UNKNOWN_VALUE + if node.value is not None: + self._scan_expression(node.value, scope) + value = self._expression_value(node.value, scope) + if self.return_value_stack: + self.return_value_stack[-1].append(value) + return + if isinstance(node, ast.Import): + for alias in node.names: + name = alias.asname or alias.name.split(".")[0] + if alias.name == "builtins": + value = _FlowValue(symbols=frozenset({_BUILTINS_MODULE})) + elif alias.name == "pathlib": + value = _FlowValue(symbols=frozenset({_PATHLIB_MODULE})) + else: + value = _UNKNOWN_VALUE + scope.assign(name, value) + return + if isinstance(node, ast.ImportFrom): + for alias in node.names: + name = alias.asname or alias.name + if node.module == "builtins" and alias.name == "open": + value = _FlowValue(symbols=frozenset({_BUILTIN_OPEN})) + elif node.module == "pathlib" and alias.name in _PATH_CONSTRUCTORS: + value = _FlowValue(symbols=frozenset({_PATH_CONSTRUCTOR})) + else: + value = _UNKNOWN_VALUE + scope.assign(name, value) + return + if isinstance(node, ast.Assign): + self._scan_expression(node.value, scope) + value = self._expression_value(node.value, scope) + for target in node.targets: + if isinstance(target, (ast.Tuple, ast.List)): + self._assign_expression_target(target, node.value, scope) + else: + self._assign_target(target, value, scope) + return + if isinstance(node, ast.AnnAssign): + value = _UNKNOWN_VALUE + if node.value is not None: + self._scan_expression(node.value, scope) + value = self._expression_value(node.value, scope) + self._assign_target(node.target, value, scope) + return + if isinstance(node, (ast.AugAssign, ast.NamedExpr)): + self._scan_expression(node.value, scope) + self._assign_target(node.target, _UNKNOWN_VALUE, scope) + return + if isinstance(node, ast.If): + self._scan_expression(node.test, scope) + self._analyze_branches(scope, [node.body, node.orelse or []]) + return + if isinstance(node, (ast.For, ast.AsyncFor)): + self._scan_expression(node.iter, scope) + self._assign_target(node.target, self._iterated_value(node.iter, scope), scope) + self._analyze_branches(scope, [node.body, []]) + self._analyze_block(node.orelse, scope) + return + if isinstance(node, ast.While): + self._scan_expression(node.test, scope) + self._analyze_branches(scope, [node.body, []]) + self._analyze_block(node.orelse, scope) + return + if isinstance(node, ast.Match): + self._analyze_match(node, scope) + return + if isinstance(node, (ast.With, ast.AsyncWith)): + for item in node.items: + self._scan_expression(item.context_expr, scope) + if item.optional_vars is not None: + self._assign_target(item.optional_vars, _UNKNOWN_VALUE, scope) + self._analyze_block(node.body, scope) + return + if isinstance(node, (ast.Try, ast.TryStar)): + self._analyze_try(node, scope) + return + if isinstance( + node, (ast.Global, ast.Nonlocal, ast.Pass, ast.Break, ast.Continue) + ): + return + for child in ast.iter_child_nodes(node): + if isinstance(child, ast.expr): + self._scan_expression(child, scope) + elif isinstance(child, ast.stmt): + self._analyze_statement(child, scope) + + def _analyze_block(self, statements: list[ast.stmt], scope: _FlowScope) -> None: + for statement in statements: + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + self.deferred_functions.append( + self._prepare_function(statement, scope) + ) + else: + self._analyze_statement(statement, scope) + + +def _python_findings(path: Path, relative: str) -> list[Finding]: + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=relative) + except (OSError, UnicodeError, SyntaxError): + return [] + return _PythonFlowAnalyzer(relative).analyze(tree) + + +_QUOTED_FRAGMENT_RE = re.compile( + r"'([^'\\]*(?:\\.[^'\\]*)*)'|" + r'"([^"\\]*(?:\\.[^"\\]*)*)"|' + r"`([^`\\]*(?:\\.[^`\\]*)*)`" +) + + +def _strip_text_comments(source: str, suffix: str) -> str: + output = list(source) + quote: Optional[str] = None + escaped = False + line_comment = False + block_comment = False + index = 0 + while index < len(source): + char = source[index] + following = source[index + 1] if index + 1 < len(source) else "" + if line_comment: + if char == "\n": + line_comment = False + else: + output[index] = " " + index += 1 + continue + if block_comment: + if char == "*" and following == "/": + output[index] = output[index + 1] = " " + block_comment = False + index += 2 + else: + if char != "\n": + output[index] = " " + index += 1 + continue + if quote is not None: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == quote: + quote = None + index += 1 + continue + if char in {"'", '"', "`"}: + quote = char + index += 1 + continue + if char == "/" and following == "*": + output[index] = output[index + 1] = " " + block_comment = True + index += 2 + continue + if char == "/" and following == "/" and suffix != ".sh": + output[index] = output[index + 1] = " " + line_comment = True + index += 2 + continue + if char == "#" and suffix in {".sh", ".nix"}: + output[index] = " " + line_comment = True + index += 1 + continue + index += 1 + return "".join(output) + + +def _fragment_value(match: re.Match[str]) -> str: + return next(group for group in match.groups() if group is not None) + + +def _split_auth_store_reference_lines(source: str, suffix: str) -> set[int]: + lines: set[int] = set() + fragments = list(_QUOTED_FRAGMENT_RE.finditer(source)) + fragment_lines: list[int] = [] + line = 1 + cursor = 0 + for fragment in fragments: + line += source.count("\n", cursor, fragment.start()) + fragment_lines.append(line) + cursor = fragment.start() + for start, first in enumerate(fragments): + combined = _fragment_value(first) + previous_end = first.end() + following_fragments = itertools.islice( + fragments, + start + 1, + start + 2 + _MAX_TEXT_FRAGMENT_CHAIN, + ) + for offset, following in enumerate(following_fragments, start=1): + separator = source[previous_end : following.start()] + if suffix == ".sh": + concatenates = separator == "" or bool( + re.fullmatch(r"\\\r?\n[ \t]*", separator) + ) + else: + concatenates = bool(re.fullmatch(r"\s*\+\s*", separator)) + if not concatenates: + break + if offset > _MAX_TEXT_FRAGMENT_CHAIN: + # A longer static chain is pathological; flag it rather than + # permitting chain length to become a scanner bypass. + lines.add(fragment_lines[start]) + break + combined += _fragment_value(following) + if _is_auth_store_reference(combined): + lines.add(fragment_lines[start]) + break + previous_end = following.end() + return lines + + +def _text_findings(path: Path, relative: str) -> list[Finding]: + try: + source = path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + return [] + source = _strip_text_comments(source, path.suffix.lower()) + split_reference_lines = _split_auth_store_reference_lines( + source, path.suffix.lower() + ) + return [ + Finding(relative, line_number, "text_reference") + for line_number, line in enumerate(source.splitlines(), start=1) + if _AUTH_BASENAME in line or line_number in split_reference_lines + ] + + +def scan_repository(root: Path) -> list[Finding]: + root = root.resolve() + if not root.is_dir(): + raise ValueError(f"scan root is not a directory: {root}") + findings: list[Finding] = [] + for path in root.rglob("*"): + if not path.is_file(): + continue + relative_path = path.relative_to(root) + if _is_test_or_generated(relative_path): + continue + relative = relative_path.as_posix() + if path.suffix == ".py": + findings.extend(_python_findings(path, relative)) + elif path.suffix in _TEXT_SUFFIXES: + findings.extend(_text_findings(path, relative)) + return sorted(findings, key=lambda item: (item.path, item.line, item.kind)) + + +def load_inventory(path: Path) -> dict[str, InventoryEntry]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict) or set(payload) != {"version", "consumers"}: + raise ValueError( + "auth-store consumer inventory must define exactly version and consumers" + ) + if payload.get("version") != 2 or not isinstance(payload.get("consumers"), dict): + raise ValueError("auth-store consumer inventory must use schema version 2") + + inventory: dict[str, InventoryEntry] = {} + for raw_path, raw_entry in payload["consumers"].items(): + if not isinstance(raw_path, str) or not raw_path: + raise ValueError("auth-store consumer paths must be non-empty strings") + candidate = Path(raw_path) + if candidate.is_absolute() or ".." in candidate.parts: + raise ValueError( + f"auth-store consumer path must be repository-relative: {raw_path!r}" + ) + if not isinstance(raw_entry, dict) or set(raw_entry) != {"category", "reason"}: + raise ValueError( + f"auth-store consumer {raw_path!r} must define exactly category and reason" + ) + category = raw_entry.get("category") + reason = raw_entry.get("reason") + if not isinstance(category, str) or category not in APPROVED_CLASSIFICATIONS: + raise ValueError( + f"auth-store consumer {raw_path!r} has unapproved category {category!r}" + ) + if not isinstance(reason, str) or reason not in APPROVED_CLASSIFICATIONS[category]: + raise ValueError( + f"auth-store consumer {raw_path!r} has unapproved reason {reason!r} " + f"for category {category!r}" + ) + inventory[raw_path] = InventoryEntry(category=category, reason=reason) + return inventory + + +def audit(root: Path, inventory_path: Path) -> tuple[list[Finding], list[str]]: + findings = scan_repository(root) + inventory = load_inventory(inventory_path) + detected_paths = {finding.path for finding in findings} + unclassified = [finding for finding in findings if finding.path not in inventory] + stale = sorted(path for path in inventory if path not in detected_paths) + return unclassified, stale + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--inventory", type=Path) + args = parser.parse_args(argv) + inventory = args.inventory or args.root / "scripts" / "auth_store_consumer_inventory.json" + try: + unclassified, stale = audit(args.root, inventory) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"auth-store consumer audit failed: {exc}", file=sys.stderr) + return 2 + if unclassified: + print("Unclassified production auth.json consumers:", file=sys.stderr) + for finding in unclassified: + print( + f" {finding.path}:{finding.line}: {finding.kind}", file=sys.stderr + ) + print( + "Route access through the canonical authority API or add a reviewed " + "adapter category to scripts/auth_store_consumer_inventory.json.", + file=sys.stderr, + ) + if stale: + print("Stale auth.json consumer inventory entries:", file=sys.stderr) + for path in stale: + print(f" {path}", file=sys.stderr) + return 1 if unclassified or stale else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/docker_auth_authority.py b/scripts/docker_auth_authority.py new file mode 100644 index 000000000000..9bf6cd5b66af --- /dev/null +++ b/scripts/docker_auth_authority.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Resolve the auth-store path during early container boot. + +This mirrors hermes_cli.auth_authority without importing the application and +uses the canonical strict YAML authority loader shared with Nix activation. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import sys +import tempfile +from typing import Any, Callable + +import fcntl + +try: + from scripts.auth_authority_config import load_configured_authority +except ModuleNotFoundError: # Direct execution puts scripts/ on sys.path. + from auth_authority_config import load_configured_authority + + +def _root_and_profile(home: Path) -> tuple[Path, str]: + if home.parent.name == "profiles" and home.parent.parent.name == ".hermes": + return home.parent.parent, home.name + return home, "default" + + +def resolve_auth_authority(hermes_home: str) -> dict[str, Any]: + home = Path(hermes_home).expanduser().resolve(strict=False) + root, profile_id = _root_and_profile(home) + requested = load_configured_authority(home / "config.yaml") + legacy = False + if requested is None: + if profile_id != "default" and (home / "auth.json").is_file(): + authority = "profile" + legacy = True + else: + authority = "shared" + else: + authority = requested + + if authority == "shared": + path = root / "auth.json" + elif authority == "profile": + path = home / "auth.json" + else: + raise ValueError(f"Invalid auth.authority {authority!r}; expected shared or profile") + + bridged = os.environ.get("HERMES_INTERNAL_AUTHORITY_PATH", "").strip() + if bridged: + bridge_path = Path(bridged).expanduser() + if not bridge_path.is_absolute(): + raise ValueError("HERMES_INTERNAL_AUTHORITY_PATH must be absolute") + bridge_path = bridge_path.resolve(strict=False) + try: + bridge_path.relative_to(root.resolve(strict=False)) + except ValueError as exc: + raise ValueError( + "HERMES_INTERNAL_AUTHORITY_PATH must remain inside the Hermes root" + ) from exc + if bridge_path != path.resolve(strict=False): + raise ValueError( + "HERMES_INTERNAL_AUTHORITY_PATH does not match the configured auth authority" + ) + + return { + "authority": authority, + "requested_authority": requested, + "auth_path": str(path), + "lock_path": str(path.with_suffix(".lock")), + "profile_id": profile_id, + "legacy_compatibility": legacy, + } + + +def _atomic_json_write(auth_path: Path, value: dict[str, Any]) -> None: + fd, tmp_name = tempfile.mkstemp(prefix=".auth-update-", dir=auth_path.parent) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(value, handle, separators=(",", ":")) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_name, auth_path) + os.chmod(auth_path, 0o600) + directory_fd = os.open(auth_path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + try: + os.unlink(tmp_name) + except FileNotFoundError: + pass + + +def update_auth_store( + hermes_home: str | Path, + updater: Callable[[dict[str, Any]], tuple[str, dict[str, Any] | None]], + *, + expected_auth_path: str | Path | None = None, +) -> str: + """Lock, reread, and optionally replace the selected canonical auth store.""" + resolved = resolve_auth_authority(str(hermes_home)) + auth_path = Path(resolved["auth_path"]) + lock_path = Path(resolved["lock_path"]) + if expected_auth_path is not None and auth_path.resolve(strict=False) != Path( + expected_auth_path + ).resolve(strict=False): + raise RuntimeError("auth authority changed before update") + auth_path.parent.mkdir(parents=True, exist_ok=True) + lock_flags = os.O_CREAT | os.O_RDWR + if hasattr(os, "O_NOFOLLOW"): + lock_flags |= os.O_NOFOLLOW + lock_fd = os.open(lock_path, lock_flags, 0o600) + try: + os.fchmod(lock_fd, 0o600) + fcntl.flock(lock_fd, fcntl.LOCK_EX) + locked = resolve_auth_authority(str(hermes_home)) + if Path(locked["auth_path"]).resolve(strict=False) != auth_path.resolve( + strict=False + ): + raise RuntimeError("auth authority changed while waiting for the lock") + if auth_path.is_symlink(): + raise RuntimeError("refusing symlinked auth destination") + if not auth_path.is_file(): + return "no_auth_file" + try: + store = json.loads(auth_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return "auth_unreadable" + if not isinstance(store, dict): + return "auth_unreadable" + status, updated = updater(store) + if updated is not None: + _atomic_json_write(auth_path, updated) + return status + finally: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + os.close(lock_fd) + + +def seed_auth_store(hermes_home: str, raw: str) -> str: + """Create the selected store once while holding the canonical auth lock.""" + if not raw: + return "no_seed" + parsed = json.loads(raw) + if not isinstance(parsed, dict): + raise ValueError("auth bootstrap must be a JSON object") + resolved = resolve_auth_authority(hermes_home) + auth_path = Path(resolved["auth_path"]) + lock_path = Path(resolved["lock_path"]) + auth_path.parent.mkdir(parents=True, exist_ok=True) + lock_flags = os.O_CREAT | os.O_RDWR + if hasattr(os, "O_NOFOLLOW"): + lock_flags |= os.O_NOFOLLOW + lock_fd = os.open(lock_path, lock_flags, 0o600) + try: + os.fchmod(lock_fd, 0o600) + fcntl.flock(lock_fd, fcntl.LOCK_EX) + locked = resolve_auth_authority(hermes_home) + if Path(locked["auth_path"]).resolve(strict=False) != auth_path.resolve( + strict=False + ): + raise ValueError("auth authority changed while waiting for the lock") + if auth_path.is_symlink(): + raise ValueError("refusing symlinked auth bootstrap destination") + if auth_path.exists(): + return "exists" + fd, tmp_name = tempfile.mkstemp( + prefix=".auth-bootstrap-", dir=auth_path.parent + ) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(parsed, handle, separators=(",", ":")) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_name, auth_path) + os.chmod(auth_path, 0o600) + except Exception: + try: + os.unlink(tmp_name) + except OSError: + pass + raise + return "seeded" + finally: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + os.close(lock_fd) + + +def main() -> int: + home = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("HERMES_HOME", "") + if not home: + print("HERMES_HOME is required", file=sys.stderr) + return 2 + try: + result = resolve_auth_authority(home) + except (OSError, ValueError) as exc: + print(str(exc), file=sys.stderr) + return 2 + field = sys.argv[2] if len(sys.argv) > 2 else None + if field == "seed": + try: + print( + seed_auth_store( + home, os.environ.get("HERMES_AUTH_JSON_BOOTSTRAP", "") + ) + ) + except (OSError, ValueError) as exc: + print(str(exc), file=sys.stderr) + return 2 + return 0 + if field: + if field not in result: + print(f"Unknown result field: {field}", file=sys.stderr) + return 2 + print(result[field]) + else: + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/docker_rebootstrap_nous_session.py b/scripts/docker_rebootstrap_nous_session.py index 0c4125d64e2d..03acb8cb1f8d 100644 --- a/scripts/docker_rebootstrap_nous_session.py +++ b/scripts/docker_rebootstrap_nous_session.py @@ -38,6 +38,7 @@ import json import os +from pathlib import Path import sys from datetime import datetime, timezone from typing import Any, Optional @@ -156,53 +157,58 @@ def reseed_if_terminal(auth_path: str, seed_raw: str) -> str: if seed_nous is None: return "bad_seed" - if not os.path.exists(auth_path): - # Blank volume — this is the normal first-boot case, not a re-seed. - return "no_auth_file" - try: - with open(auth_path, "r", encoding="utf-8") as fh: - store = json.load(fh) - except (OSError, ValueError): - # Corrupt/unreadable auth.json: do NOT overwrite blindly. A separate - # concern; leave it for the operator / other recovery paths. - return "auth_unreadable" + from docker_auth_authority import update_auth_store + except ModuleNotFoundError: # imported as scripts.* by unit tests/dev tooling + from scripts.docker_auth_authority import update_auth_store + + def update(store: dict[str, object]): + providers = store.get("providers") + if not isinstance(providers, dict): + providers = {} + store["providers"] = providers + + local_nous = providers.get("nous") + terminal = _nous_entry_is_terminal(local_nous) + newer_seed = _seed_is_newer(local_nous, seed_nous) + if not terminal and not newer_seed: + return "not_terminal", None + + providers["nous"] = seed_nous + return ("reseeded" if terminal else "reseeded_newer"), store + + return update_auth_store( + Path(auth_path).parent, + update, + expected_auth_path=auth_path, + ) - if not isinstance(store, dict): - return "auth_unreadable" - providers = store.get("providers") - if not isinstance(providers, dict): - providers = {} - store["providers"] = providers - - local_nous = providers.get("nous") - terminal = _nous_entry_is_terminal(local_nous) - newer_seed = _seed_is_newer(local_nous, seed_nous) - if not terminal and not newer_seed: - # Healthy and at least as new as the seed, or incomparable. Never roll a - # session back merely because an old rebootstrap env remains configured. - return "not_terminal" - - # Surgical replacement: swap ONLY providers.nous, preserve everything else. - providers["nous"] = seed_nous - - tmp_path = f"{auth_path}.rebootstrap.tmp" - with open(tmp_path, "w", encoding="utf-8") as fh: - json.dump(store, fh) - os.replace(tmp_path, auth_path) +def reseed_profile_if_terminal(profile_home: Path, seed_raw: str) -> str: + """Resolve and update auth through Docker's canonical authority helper.""" try: - os.chmod(auth_path, 0o600) - except OSError: - pass - return "reseeded" if terminal else "reseeded_newer" + from docker_auth_authority import resolve_auth_authority + except ModuleNotFoundError: # imported as scripts.* by unit tests/dev tooling + from scripts.docker_auth_authority import resolve_auth_authority + + authority = resolve_auth_authority(str(profile_home)) + return reseed_if_terminal(str(authority["auth_path"]), seed_raw) def main() -> int: auth_path = sys.argv[1] if len(sys.argv) > 1 else "" if not auth_path: home = os.environ.get("HERMES_HOME", "") - auth_path = os.path.join(home, "auth.json") if home else "auth.json" + if home: + try: + from docker_auth_authority import resolve_auth_authority + + auth_path = resolve_auth_authority(home)["auth_path"] + except Exception as exc: + print(f"[rebootstrap] authority resolution failed (ignored): {exc}", file=sys.stderr) + return 0 + else: + auth_path = "auth.json" seed_raw = os.environ.get(REBOOTSTRAP_ENV, "") try: diff --git a/scripts/nix_auth_authority.py b/scripts/nix_auth_authority.py new file mode 100644 index 000000000000..aa19bbea3fac --- /dev/null +++ b/scripts/nix_auth_authority.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Authority-aware, lock-safe auth.json seeding for NixOS activation.""" + +from __future__ import annotations + +import argparse +import fcntl +import json +import os +import shutil +import stat +import tempfile +from pathlib import Path +from typing import Any + +try: + from scripts.auth_authority_config import load_configured_authority +except ModuleNotFoundError: # Direct execution puts scripts/ on sys.path. + from auth_authority_config import load_configured_authority + + +def _shared_root(home: Path) -> Path: + resolved = home.expanduser().resolve(strict=False) + if resolved.parent.name == "profiles": + return resolved.parent.parent + return resolved + + +def _configured_authority(home: Path) -> str | None: + return load_configured_authority(home / "config.yaml") + + +def resolve_auth_authority(home: Path) -> dict[str, Any]: + home = home.expanduser().resolve(strict=False) + root = _shared_root(home) + configured = _configured_authority(home) + requested = configured or "shared" + if requested not in {"shared", "profile"}: + raise ValueError( + "Invalid auth.authority in config.yaml; expected 'shared' or 'profile'" + ) + profile_path = home / "auth.json" + legacy = ( + configured is None + and home != root + and profile_path.is_file() + ) + effective = "profile" if requested == "profile" or legacy else "shared" + auth_path = profile_path if effective == "profile" else root / "auth.json" + return { + "requested_authority": requested, + "authority": effective, + "auth_path": auth_path, + "lock_path": auth_path.with_suffix(".lock"), + "legacy_compatibility": legacy, + } + + +def _verify_private_file( + path: Path, + *, + label: str, + uid: int | None, + gid: int | None, +) -> None: + try: + metadata = path.stat(follow_symlinks=False) + except OSError as exc: + raise RuntimeError(f"cannot verify {label}: {exc}") from exc + if not stat.S_ISREG(metadata.st_mode): + raise RuntimeError(f"{label} must be a regular file") + if stat.S_IMODE(metadata.st_mode) != 0o600: + raise RuntimeError(f"{label} must have mode 0600") + if uid is not None and metadata.st_uid != uid: + raise RuntimeError(f"{label} is not owned by runtime uid {uid}") + if gid is not None and metadata.st_gid != gid: + raise RuntimeError(f"{label} is not owned by runtime gid {gid}") + + +def seed_auth( + home: Path, + source: Path, + *, + uid: int | None = None, + gid: int | None = None, +) -> dict[str, Any]: + """Seed an empty authority while serializing existence checks and writes.""" + source = source.expanduser() + if source.is_symlink(): + raise RuntimeError("auth seed source must not be a symlink") + source = source.resolve(strict=True) + if not source.is_file(): + raise RuntimeError("auth seed source must be a regular file") + authority = resolve_auth_authority(home) + destination = authority["auth_path"] + lock_path = authority["lock_path"] + destination.parent.mkdir(parents=True, exist_ok=True, mode=0o750) + if uid is not None or gid is not None: + os.chown(destination.parent, -1 if uid is None else uid, -1 if gid is None else gid) + + lock_flags = os.O_RDWR | os.O_CREAT + if hasattr(os, "O_NOFOLLOW"): + lock_flags |= os.O_NOFOLLOW + lock_fd = os.open(lock_path, lock_flags, 0o600) + try: + os.fchmod(lock_fd, stat.S_IRUSR | stat.S_IWUSR) + if uid is not None or gid is not None: + os.fchown(lock_fd, -1 if uid is None else uid, -1 if gid is None else gid) + fcntl.flock(lock_fd, fcntl.LOCK_EX) + if destination.is_symlink(): + raise RuntimeError("refusing symlinked auth seed destination") + if destination.exists() and not destination.is_file(): + raise RuntimeError("auth seed destination must be a regular file") + existed = destination.is_file() + if existed: + status = "preserved" + else: + try: + seed_value = json.loads(source.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise RuntimeError(f"auth seed is not valid JSON: {exc}") from exc + if not isinstance(seed_value, dict): + raise RuntimeError("auth seed must be a JSON object") + seed_raw = (json.dumps(seed_value, separators=(",", ":")) + "\n").encode() + fd, tmp_name = tempfile.mkstemp( + prefix=f"{destination.name}.tmp.", dir=destination.parent + ) + tmp_path = Path(tmp_name) + try: + os.fchmod(fd, stat.S_IRUSR | stat.S_IWUSR) + if uid is not None or gid is not None: + os.fchown(fd, -1 if uid is None else uid, -1 if gid is None else gid) + with os.fdopen(fd, "wb") as output: + output.write(seed_raw) + output.flush() + os.fsync(output.fileno()) + os.replace(tmp_path, destination) + directory_fd = os.open(destination.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + tmp_path.unlink(missing_ok=True) + status = "created" + _verify_private_file( + destination, label="auth seed destination", uid=uid, gid=gid + ) + lock_stat = os.fstat(lock_fd) + if not stat.S_ISREG(lock_stat.st_mode): + raise RuntimeError("auth seed lock must be a regular file") + if stat.S_IMODE(lock_stat.st_mode) != 0o600: + raise RuntimeError("auth seed lock must have mode 0600") + if uid is not None and lock_stat.st_uid != uid: + raise RuntimeError(f"auth seed lock is not owned by runtime uid {uid}") + if gid is not None and lock_stat.st_gid != gid: + raise RuntimeError(f"auth seed lock is not owned by runtime gid {gid}") + finally: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + os.close(lock_fd) + + return { + "status": status, + "authority": authority["authority"], + "legacy_compatibility": authority["legacy_compatibility"], + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("home", type=Path) + parser.add_argument("source", type=Path) + + parser.add_argument("--uid", type=int) + parser.add_argument("--gid", type=int) + args = parser.parse_args() + print( + json.dumps( + seed_auth( + args.home, + args.source, + uid=args.uid, + gid=args.gid, + ), + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tool_search_livetest.py b/scripts/tool_search_livetest.py index 1df22ff8c7f4..2903e8cff166 100644 --- a/scripts/tool_search_livetest.py +++ b/scripts/tool_search_livetest.py @@ -32,14 +32,19 @@ from pathlib import Path from typing import Any, Dict, List, Tuple -# Force-isolate the test environment BEFORE any hermes imports. -ORIGINAL_HOME = os.environ.get("HERMES_HOME") -ORIGINAL_AUTH = Path.home() / ".hermes" / "auth.json" - _THIS_DIR = Path(__file__).resolve().parent _WORKTREE_ROOT = _THIS_DIR.parent sys.path.insert(0, str(_WORKTREE_ROOT)) +# Resolve the real credential authority before replacing HERMES_HOME with the +# harness sandbox. Repository imports are available because the worktree root +# is inserted first; silently falling back to a guessed path would violate the +# single-authority contract. +ORIGINAL_HOME = os.environ.get("HERMES_HOME") +from hermes_cli.auth_authority import resolve_auth_authority + +ORIGINAL_AUTH = resolve_auth_authority().auth_path + # --------------------------------------------------------------------------- # Fake MCP tools — realistic shape, varied difficulty for retrieval # --------------------------------------------------------------------------- @@ -251,23 +256,27 @@ def setup_isolated_home(enabled: bool, listing: str = "off", listing_max_tokens: int = 4000, model: str = "anthropic/claude-haiku-4.5") -> Path: - """Create a fresh ~/.hermes/ for one test, copying minimal credentials. + """Create a fresh profile-local Hermes home for one live test. - Also reads OPENROUTER_API_KEY from the user's real ``~/.hermes/.env`` so - the agent can authenticate against OpenRouter inside the isolated home. + Credential copying is privileged and disabled unless the operator sets + ``HERMES_TOOL_SEARCH_LIVETEST_ALLOW_CREDENTIAL_COPY=1`` explicitly. """ home_dir = Path(tempfile.mkdtemp(prefix="hermes_ts_live_")) hermes_home = home_dir / ".hermes" hermes_home.mkdir(parents=True) - if ORIGINAL_AUTH.exists(): - shutil.copy(ORIGINAL_AUTH, hermes_home / "auth.json") - - # Copy .env so OPENROUTER_API_KEY (or others) are visible to the agent - # running inside the isolated home. + allow_credentials = ( + os.environ.get("HERMES_TOOL_SEARCH_LIVETEST_ALLOW_CREDENTIAL_COPY") == "1" + ) real_env_file = Path.home() / ".hermes" / ".env" - if real_env_file.exists(): - shutil.copy(real_env_file, hermes_home / ".env") + if allow_credentials and ORIGINAL_AUTH.is_file(): + destination = hermes_home / "auth.json" + shutil.copyfile(ORIGINAL_AUTH, destination) + destination.chmod(0o600) + if allow_credentials and real_env_file.is_file(): + destination = hermes_home / ".env" + shutil.copyfile(real_env_file, destination) + destination.chmod(0o600) # Also load the real user env into this process so the provider # resolver can authenticate. We go through the canonical loader # (python-dotenv under the hood) rather than parsing the file by @@ -278,6 +287,7 @@ def setup_isolated_home(enabled: bool, listing: str = "off", load_hermes_dotenv(hermes_home=str(Path.home() / ".hermes")) cfg = { + "auth": {"authority": "profile"}, "model": { "provider": "openrouter", "model": model, diff --git a/tests/agent/test_credential_pool_oauth_writethrough.py b/tests/agent/test_credential_pool_oauth_writethrough.py index 579459280203..40aa3ef858e7 100644 --- a/tests/agent/test_credential_pool_oauth_writethrough.py +++ b/tests/agent/test_credential_pool_oauth_writethrough.py @@ -70,10 +70,257 @@ def profile_and_root(tmp_path, monkeypatch): return profile_path, root_path +@pytest.mark.parametrize( + "provider", + ["openai-codex", "xai-oauth"], +) +def test_pool_refresh_writes_through_to_root_when_profile_reads_root( + profile_and_root, provider +): + """A profile reading root's grant must push rotated tokens back to root.""" + profile_path, root_path = profile_and_root + # Profile has NO own provider block (reads root via fallback). + _write_store(profile_path, {"version": 1, "providers": {}}) + _write_store( + root_path, + { + "version": 1, + "providers": { + provider: { + "tokens": { + "access_token": "old-access", + "refresh_token": "old-refresh", + } + } + }, + }, + ) + + pool = CredentialPool(provider, []) + pool._sync_device_code_entry_to_auth_store( + _entry(provider, id="e1", access_token="new-access", refresh_token="new-refresh") + ) + + # Profile got the rotated chain (existing behavior). + profile = _read_store(profile_path) + assert ( + profile["providers"][provider]["tokens"]["refresh_token"] == "new-refresh" + ) + + # AND the global root no longer holds the revoked refresh token (#48415). + root = _read_store(root_path) + assert root["providers"][provider]["tokens"]["access_token"] == "new-access" + assert root["providers"][provider]["tokens"]["refresh_token"] == "new-refresh" + + +@pytest.mark.parametrize( + ("provider", "refresh_name"), + [ + ("openai-codex", "refresh_codex_oauth_pure"), + ("xai-oauth", "refresh_xai_oauth_pure"), + ], +) +def test_full_pool_refresh_locks_and_updates_authoritative_root( + profile_and_root, monkeypatch, provider, refresh_name +): + """The full single-use refresh transaction must own and update root.""" + profile_path, root_path = profile_and_root + _write_store(profile_path, {"version": 1, "providers": {}}) + _write_store( + root_path, + { + "version": 1, + "providers": { + provider: { + "tokens": { + "access_token": "old-access", + "refresh_token": "old-refresh", + } + } + }, + }, + ) + + root_before = root_path.read_bytes() + root_lock_held = {"during_post": False} + + def fake_refresh(*_args, **_kwargs): + root_lock_held["during_post"] = ( + getattr(A._auth_lock_holder_for(root_path), "depth", 0) > 0 + ) + return { + "access_token": "rotated-access", + "refresh_token": "rotated-refresh", + "last_refresh": "2020-01-02T00:00:00Z", + } + + monkeypatch.setattr(A, refresh_name, fake_refresh) + entry = _entry( + provider, + id=f"{provider}-full-refresh", + access_token="old-access", + refresh_token="old-refresh", + ) + pool = CredentialPool(provider, [entry]) + + refreshed = pool._refresh_entry(entry, force=True) + + assert refreshed is not None + assert refreshed.refresh_token == "rotated-refresh" + assert root_lock_held["during_post"] is True + assert root_path.read_bytes() != root_before + root = _read_store(root_path) + assert ( + root["providers"][provider]["tokens"]["refresh_token"] + == "rotated-refresh" + ) + + +@pytest.mark.parametrize( + ("provider", "refresh_name", "error_code"), + [ + ("openai-codex", "refresh_codex_oauth_pure", "codex_refresh_failed"), + ("xai-oauth", "refresh_xai_oauth_pure", "xai_refresh_failed"), + ], +) +def test_terminal_pool_refresh_quarantines_authoritative_root( + profile_and_root, monkeypatch, provider, refresh_name, error_code +): + """A terminal fallback refresh must revoke root, not only the profile.""" + profile_path, root_path = profile_and_root + _write_store(profile_path, {"version": 1, "providers": {}}) + _write_store( + root_path, + { + "version": 1, + "providers": { + provider: { + "tokens": { + "access_token": "revoked-access", + "refresh_token": "revoked-refresh", + } + } + }, + }, + ) + + root_before = root_path.read_bytes() + root_lock_held = {"during_save": False} + real_save = A._save_auth_store + + def tracking_save(auth_store, target_path=None, *, updated_at=None): + save_path = target_path if target_path is not None else A._auth_file_path() + if save_path.resolve(strict=False) == root_path.resolve(strict=False): + root_lock_held["during_save"] = ( + getattr(A._auth_lock_holder_for(root_path), "depth", 0) > 0 + ) + return real_save(auth_store, target_path, updated_at=updated_at) + + monkeypatch.setattr(A, "_save_auth_store", tracking_save) + + def terminal_refresh(*_args, **_kwargs): + raise A.AuthError( + "Refresh token was revoked", + provider=provider, + code=error_code, + relogin_required=True, + ) + + monkeypatch.setattr(A, refresh_name, terminal_refresh) + entry = _entry( + provider, + id=f"{provider}-terminal", + access_token="revoked-access", + refresh_token="revoked-refresh", + ) + pool = CredentialPool(provider, [entry]) + + assert pool._refresh_entry(entry, force=True) is None + + assert root_lock_held["during_save"] is True + assert root_path.read_bytes() != root_before + root_state = _read_store(root_path)["providers"][provider] + assert "access_token" not in root_state["tokens"] + assert "refresh_token" not in root_state["tokens"] + assert root_state["last_auth_error"]["code"] == error_code + + +@pytest.mark.parametrize( + "provider", + ["openai-codex", "xai-oauth"], +) +def test_pool_refresh_does_not_touch_root_when_profile_shadows( + profile_and_root, provider +): + """A profile that genuinely shadows root must NOT clobber the root grant.""" + profile_path, root_path = profile_and_root + # Profile has its OWN provider block: it shadows root legitimately. + _write_store( + profile_path, + { + "version": 1, + "providers": { + provider: { + "tokens": { + "access_token": "profile-old", + "refresh_token": "profile-old-refresh", + } + } + }, + }, + ) + _write_store( + root_path, + { + "version": 1, + "providers": { + provider: { + "tokens": { + "access_token": "root-untouched", + "refresh_token": "root-untouched-refresh", + } + } + }, + }, + ) + + pool = CredentialPool(provider, []) + pool._sync_device_code_entry_to_auth_store( + _entry( + provider, + id="e2", + access_token="profile-new", + refresh_token="profile-new-refresh", + ) + ) + profile = _read_store(profile_path) + assert ( + profile["providers"][provider]["tokens"]["refresh_token"] + == "profile-new-refresh" + ) + + # Root keeps its own grant — write-through must not run when the profile + # owns the block. + root = _read_store(root_path) + assert ( + root["providers"][provider]["tokens"]["refresh_token"] + == "root-untouched-refresh" + ) +def test_write_through_helper_is_noop_in_classic_mode(monkeypatch, tmp_path): + """When profile == root (classic mode), the helper must be a no-op. + ``_global_auth_file_path`` returns None in classic mode; the profile save + already wrote to root, so a second write would be redundant (and the + helper has nothing to target). + """ + monkeypatch.setattr(A, "_global_auth_file_path", lambda: None) + # Must not raise and must not attempt any write. + CP._write_through_provider_state_to_global_root( + "openai-codex", {"tokens": {"access_token": "a", "refresh_token": "r"}} + ) def test_global_write_through_preserves_concurrent_root_update( @@ -173,12 +420,13 @@ def test_codex_pool_refresh_holds_auth_store_lock_across_post(monkeypatch, tmp_p Codex refresh tokens are single-use. If two Hermes processes both read the same on-disk token and both POST it, the loser gets ``refresh_token_reused``. Serializing the sync -> refresh POST -> write-back sequence through the - shared ``_auth_store_lock`` closes that window: a second process blocks on - the flock and, once inside, adopts the rotated token instead of re-POSTing. + authority-bound auth-store lock set closes that window: a second process + blocks on the flock and, once inside, adopts the rotated token instead of + re-POSTing. This asserts the invariant directly — that ``refresh_codex_oauth_pure`` is - only ever called while the auth-store lock is held — rather than snapshotting - any token value. + only ever called while the active auth-store lock is held — rather than + snapshotting any token value. """ provider = "openai-codex" profile_path = tmp_path / "auth.json" @@ -187,28 +435,12 @@ def test_codex_pool_refresh_holds_auth_store_lock_across_post(monkeypatch, tmp_p monkeypatch.setenv("HOME", str(tmp_path / "not-the-root")) lock_held: dict = {"during_post": None} - real_lock = A._auth_store_lock - - depth = {"n": 0} - - import contextlib - - @contextlib.contextmanager - def tracking_lock(*args, **kwargs): - depth["n"] += 1 - try: - with real_lock(*args, **kwargs): - yield - finally: - depth["n"] -= 1 - - monkeypatch.setattr(A, "_auth_store_lock", tracking_lock) - # credential_pool imported _auth_store_lock by name; patch that binding too. - monkeypatch.setattr(CP, "_auth_store_lock", tracking_lock) def fake_refresh(access_token, refresh_token, **kwargs): # The POST to the token endpoint must happen with the lock held. - lock_held["during_post"] = depth["n"] > 0 + lock_held["during_post"] = ( + getattr(A._auth_lock_holder_for(profile_path), "depth", 0) > 0 + ) return { "access_token": "rotated-access", "refresh_token": "rotated-refresh", diff --git a/tests/gateway/test_auth_migration_precondition.py b/tests/gateway/test_auth_migration_precondition.py new file mode 100644 index 000000000000..0ad593f81c27 --- /dev/null +++ b/tests/gateway/test_auth_migration_precondition.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +@pytest.fixture() +def gateway_auth_home(tmp_path: Path, monkeypatch): + root = tmp_path / ".hermes" + profile = root / "profiles" / "gateway" + profile.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(profile)) + return root + + +def _journal(root: Path, phase: str) -> None: + path = ( + root + / "state-snapshots" + / "auth-migrations" + / "journals" + / "plan-gateway.json" + ) + path.parent.mkdir(parents=True) + path.write_text(json.dumps({"plan_id": "plan-gateway", "phase": phase})) + + +@pytest.mark.parametrize( + "phase", + ["planned", "locked", "backed_up", "target_written", "profiles_configured", "manual_required"], +) +def test_gateway_startup_precondition_rejects_incomplete_migration( + gateway_auth_home: Path, phase: str +) -> None: + from gateway.run import _auth_migration_startup_ready + + _journal(gateway_auth_home, phase) + + assert _auth_migration_startup_ready() is False + + +@pytest.mark.parametrize("phase", ["committed", "rolled_back", "aborted"]) +def test_gateway_startup_precondition_accepts_terminal_migration( + gateway_auth_home: Path, phase: str +) -> None: + from gateway.run import _auth_migration_startup_ready + + _journal(gateway_auth_home, phase) + + assert _auth_migration_startup_ready() is True + + +@pytest.mark.asyncio +async def test_start_gateway_stops_before_runtime_lock_for_incomplete_migration( + gateway_auth_home: Path, monkeypatch +) -> None: + import gateway.run as gateway_run + + _journal(gateway_auth_home, "target_written") + monkeypatch.setattr( + "gateway.code_skew.record_boot_fingerprint", lambda: None + ) + monkeypatch.setattr( + "gateway.status.get_running_pid", + lambda: (_ for _ in ()).throw( + AssertionError("runtime lock discovery must not run") + ), + ) + + assert await gateway_run.start_gateway() is False diff --git a/tests/hermes_cli/test_auth_authority.py b/tests/hermes_cli/test_auth_authority.py new file mode 100644 index 000000000000..f211ff26072c --- /dev/null +++ b/tests/hermes_cli/test_auth_authority.py @@ -0,0 +1,320 @@ +"""Behavior contracts for the canonical authentication authority resolver.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +@pytest.fixture() +def profile_layout(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Path]: + root = tmp_path / ".hermes" + profile = root / "profiles" / "coder" + profile.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(profile)) + return {"root": root, "profile": profile} + + +def _write_config(profile: Path, auth: dict) -> None: + (profile / "config.yaml").write_text( + "auth:\n" + + "\n".join(f" {key}: {value}" for key, value in auth.items()) + + "\n", + encoding="utf-8", + ) + + +def test_absent_config_defaults_to_shared_authority(profile_layout): + from hermes_cli.auth_authority import resolve_auth_authority + + authority = resolve_auth_authority() + + assert authority.requested_mode == "shared" + assert authority.effective_mode == "shared" + assert authority.auth_path == profile_layout["root"] / "auth.json" + assert authority.lock_path == profile_layout["root"] / "auth.lock" + assert authority.profile_id == "coder" + assert authority.legacy_compatibility is False + + +def test_absent_config_preserves_existing_profile_store(profile_layout): + from hermes_cli.auth_authority import resolve_auth_authority + + (profile_layout["profile"] / "auth.json").write_text( + json.dumps({"providers": {"nous": {"access_token": "legacy-token"}}}), + encoding="utf-8", + ) + + authority = resolve_auth_authority() + + assert authority.requested_mode == "shared" + assert authority.effective_mode == "profile" + assert authority.auth_path == profile_layout["profile"] / "auth.json" + assert authority.legacy_compatibility is True + + +def test_explicit_shared_mode_wins_and_reports_conflicting_profile_store( + profile_layout, +): + from hermes_cli.auth_authority import resolve_auth_authority + + (profile_layout["profile"] / "auth.json").write_text("{}", encoding="utf-8") + _write_config(profile_layout["profile"], {"authority": "shared"}) + + authority = resolve_auth_authority() + + assert authority.effective_mode == "shared" + assert authority.auth_path == profile_layout["root"] / "auth.json" + assert authority.conflicting_store == profile_layout["profile"] / "auth.json" + + +def test_profile_mode_uses_profile_store_and_authority_lock(profile_layout): + from hermes_cli.auth_authority import resolve_auth_authority + + _write_config(profile_layout["profile"], {"authority": "profile"}) + + authority = resolve_auth_authority() + + assert authority.effective_mode == "profile" + assert authority.auth_path == profile_layout["profile"] / "auth.json" + assert authority.lock_path == profile_layout["profile"] / "auth.lock" + + +@pytest.mark.parametrize( + ("auth", "match"), + [ + ({"authority": "unknown"}, "auth.authority"), + ({"authority": "custom"}, "auth.authority"), + ], +) +def test_invalid_authority_config_fails_closed(profile_layout, auth, match): + from hermes_cli.auth_authority import ( + AuthAuthorityConfigError, + resolve_auth_authority, + ) + + _write_config(profile_layout["profile"], auth) + + with pytest.raises(AuthAuthorityConfigError, match=match): + resolve_auth_authority() + + +def test_auth_store_entrypoint_uses_canonical_authority(profile_layout): + from hermes_cli.auth import _auth_file_path, _auth_lock_path + + _write_config(profile_layout["profile"], {"authority": "shared"}) + + assert _auth_file_path() == profile_layout["root"] / "auth.json" + assert _auth_lock_path() == profile_layout["root"] / "auth.lock" + + +def test_authority_status_is_redacted(profile_layout): + from hermes_cli.auth_authority import auth_authority_status + + root = profile_layout["root"] + token = "do-not-print-this-token" + (root / "auth.json").write_text( + json.dumps({"providers": {"nous": {"access_token": token}}}) + ) + + rendered = json.dumps(auth_authority_status(), sort_keys=True) + assert token not in rendered + assert '"effective_mode": "shared"' in rendered + assert '"path": "~/.hermes/auth.json"' in rendered + assert str(root) not in rendered + + +def test_error_location_label_does_not_expose_operator_path(profile_layout): + from hermes_cli.auth_authority import describe_auth_store + + label = describe_auth_store() + + assert label == "shared auth store (~/.hermes/auth.json)" + assert str(profile_layout["root"]) not in label + + +def test_profile_location_label_is_normalized(profile_layout): + from hermes_cli.auth_authority import describe_auth_store + + _write_config(profile_layout["profile"], {"authority": "profile"}) + + assert describe_auth_store() == ( + "profile-local auth store (~/.hermes/profiles/coder/auth.json)" + ) + + +@pytest.mark.parametrize("provider_id", ["openai-codex", "xai-oauth"]) +@pytest.mark.parametrize( + ("authority_mode", "expected_store"), + [ + ("shared", "shared auth store (~/.hermes/auth.json)"), + ( + "profile", + "profile-local auth store (~/.hermes/profiles/coder/auth.json)", + ), + ( + "legacy", + "legacy profile-local auth store (~/.hermes/profiles/coder/auth.json)", + ), + ], +) +def test_oauth_login_success_reports_effective_redacted_auth_authority( + profile_layout, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + provider_id: str, + authority_mode: str, + expected_store: str, +) -> None: + from types import SimpleNamespace + + from hermes_cli import auth as auth_module + + if authority_mode == "legacy": + (profile_layout["profile"] / "auth.json").write_text("{}", encoding="utf-8") + else: + _write_config(profile_layout["profile"], {"authority": authority_mode}) + + monkeypatch.setattr( + auth_module, + "_update_config_for_provider", + lambda *args, **kwargs: "~/.hermes/profiles/coder/config.yaml", + ) + if provider_id == "openai-codex": + monkeypatch.setattr( + auth_module, + "_codex_device_code_login", + lambda: { + "tokens": {"access_token": "test", "refresh_token": "refresh"}, + "base_url": auth_module.DEFAULT_CODEX_BASE_URL, + }, + ) + monkeypatch.setattr(auth_module, "_save_codex_tokens", lambda *args: None) + auth_module._login_openai_codex( + SimpleNamespace(), + auth_module.PROVIDER_REGISTRY[provider_id], + force_new_login=True, + ) + else: + monkeypatch.setattr( + auth_module, + "_xai_oauth_device_code_login", + lambda **kwargs: { + "tokens": {"access_token": "test", "refresh_token": "refresh"}, + "base_url": auth_module.DEFAULT_XAI_OAUTH_BASE_URL, + }, + ) + monkeypatch.setattr( + auth_module, "_save_xai_oauth_tokens", lambda *args, **kwargs: None + ) + monkeypatch.setattr( + auth_module, "unsuppress_credential_source", lambda *args: None + ) + auth_module._login_xai_oauth( + SimpleNamespace(no_browser=True, timeout=1), + auth_module.PROVIDER_REGISTRY[provider_id], + force_new_login=True, + ) + + output = capsys.readouterr().out + assert f"Auth state: {expected_store}" in output + assert str(profile_layout["root"]) not in output + + +def test_auth_status_without_provider_reports_authority(profile_layout, capsys): + from types import SimpleNamespace + + from hermes_cli.auth_commands import auth_status_command + + auth_status_command(SimpleNamespace(provider=None)) + output = capsys.readouterr().out + assert "Authentication authority" in output + assert "mode: shared" in output + assert "do-not-print-this-token" not in output + + +def test_auth_status_all_profiles_reports_redacted_topology(profile_layout, capsys): + from types import SimpleNamespace + + from hermes_cli.auth_commands import auth_status_command + + isolated = profile_layout["root"] / "profiles" / "isolated" + isolated.mkdir(parents=True) + _write_config(isolated, {"authority": "profile"}) + secret = "all-profiles-must-not-print-this-token" + (isolated / "auth.json").write_text( + json.dumps({"providers": {"nous": {"access_token": secret}}}) + ) + + auth_status_command(SimpleNamespace(provider=None, all_profiles=True)) + output = capsys.readouterr().out + + assert "default: mode=shared" in output + assert "coder: mode=shared" in output + assert "isolated: mode=profile" in output + assert secret not in output + + +def test_incomplete_restore_blocks_on_unstatable_journal_candidate(profile_layout): + from hermes_cli.auth_authority import incomplete_auth_restore + + journals = ( + profile_layout["root"] + / "state-snapshots" + / "auth-restores" + / "journals" + ) + journals.mkdir(parents=True) + (journals / "valid.json").write_text( + json.dumps({"operation_id": "valid-operation", "phase": "auth_written"}), + encoding="utf-8", + ) + (journals / "dangling.json").symlink_to(journals / "missing-target") + + incomplete = incomplete_auth_restore(profile_layout["root"]) + assert incomplete is not None + assert incomplete["phase"] == "unreadable" + + +@pytest.mark.parametrize("journal_kind", ["auth-migrations", "auth-restores"]) +def test_malformed_pending_journal_blocks_authority_resolution( + profile_layout, journal_kind +): + from hermes_cli.auth_authority import ( + AuthAuthorityConfigError, + resolve_auth_authority, + ) + + journals = ( + profile_layout["root"] + / "state-snapshots" + / journal_kind + / "journals" + ) + journals.mkdir(parents=True) + (journals / "pending.json").write_text("{", encoding="utf-8") + + with pytest.raises(AuthAuthorityConfigError, match="incomplete (migration|restore)"): + resolve_auth_authority() + + +@pytest.mark.parametrize("mode", ["shared", "profile"]) +def test_authority_rejects_auth_store_symlinks(profile_layout, mode): + from hermes_cli.auth_authority import ( + AuthAuthorityConfigError, + resolve_auth_authority, + ) + + _write_config(profile_layout["profile"], {"authority": mode}) + outside = profile_layout["root"].parent / f"outside-{mode}.json" + outside.write_text("{}", encoding="utf-8") + target_home = ( + profile_layout["root"] if mode == "shared" else profile_layout["profile"] + ) + (target_home / "auth.json").symlink_to(outside) + + with pytest.raises(AuthAuthorityConfigError, match="must not be a symlink"): + resolve_auth_authority() diff --git a/tests/hermes_cli/test_auth_authority_concurrency.py b/tests/hermes_cli/test_auth_authority_concurrency.py new file mode 100644 index 000000000000..07ccd2fd3330 --- /dev/null +++ b/tests/hermes_cli/test_auth_authority_concurrency.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +import json +import multiprocessing +import os +from contextlib import contextmanager +from pathlib import Path +import threading + +import pytest + + +def _write_provider(profile_home: str, provider: str, ready, start) -> None: + os.environ["HERMES_HOME"] = profile_home + from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store + + ready.put(provider) + start.wait(10) + for sequence in range(20): + with _auth_store_lock(): + store = _load_auth_store() + store.setdefault("providers", {})[provider] = {"sequence": sequence} + _save_auth_store(store) + + +def _increment_provider( + profile_home: str, provider: str, iterations: int, ready, start +) -> None: + os.environ["HERMES_HOME"] = profile_home + from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store + + ready.put(profile_home) + start.wait(10) + for _ in range(iterations): + with _auth_store_lock(): + store = _load_auth_store() + state = store.setdefault("providers", {}).setdefault(provider, {}) + state["rotations"] = int(state.get("rotations", 0)) + 1 + _save_auth_store(store) + + +def _crash_while_locked(profile_home: str, acquired) -> None: + os.environ["HERMES_HOME"] = profile_home + from hermes_cli.auth import _auth_store_lock + from hermes_cli.auth_authority import get_auth_store_path + + with _auth_store_lock(target_path=get_auth_store_path()): + acquired.set() + os._exit(23) + + +def test_shared_authority_serializes_writers_from_distinct_profiles(tmp_path: Path) -> None: + root = tmp_path / "hermes" + profiles = [root / "profiles" / name for name in ("alpha", "beta")] + for profile in profiles: + profile.mkdir(parents=True) + (profile / "config.yaml").write_text( + "auth:\n authority: shared\n", encoding="utf-8" + ) + + context = multiprocessing.get_context("spawn") + ready = context.Queue() + start = context.Event() + workers = [ + context.Process( + target=_write_provider, + args=(str(profile), provider, ready, start), + ) + for profile, provider in zip(profiles, ("alpha", "beta"), strict=True) + ] + for worker in workers: + worker.start() + assert {ready.get(timeout=10), ready.get(timeout=10)} == {"alpha", "beta"} + start.set() + for worker in workers: + worker.join(20) + assert worker.exitcode == 0 + + store = json.loads((root / "auth.json").read_text(encoding="utf-8")) + assert store["providers"]["alpha"]["sequence"] == 19 + assert store["providers"]["beta"]["sequence"] == 19 + assert (root / "auth.json").stat().st_mode & 0o777 == 0o600 + + +def test_shared_authority_lock_is_released_when_writer_crashes(tmp_path: Path) -> None: + root = tmp_path / "hermes" + profile = root / "profiles" / "crash" + profile.mkdir(parents=True) + (profile / "config.yaml").write_text( + "auth:\n authority: shared\n", encoding="utf-8" + ) + + context = multiprocessing.get_context("spawn") + acquired = context.Event() + crashed = context.Process( + target=_crash_while_locked, args=(str(profile), acquired) + ) + crashed.start() + assert acquired.wait(10) + crashed.join(10) + assert crashed.exitcode == 23 + + os.environ["HERMES_HOME"] = str(profile) + from hermes_cli.auth import _auth_store_lock, _save_auth_store + from hermes_cli.auth_authority import get_auth_store_path + + path = get_auth_store_path() + with _auth_store_lock(target_path=path, timeout_seconds=2): + _save_auth_store({"providers": {"after-crash": {}}}, target_path=path) + + assert "after-crash" in json.loads(path.read_text())["providers"] + + +def test_shared_authority_serializes_same_provider_rotation(tmp_path: Path) -> None: + root = tmp_path / "hermes" + profiles = [root / "profiles" / name for name in ("alpha", "beta")] + for profile in profiles: + profile.mkdir(parents=True) + (profile / "config.yaml").write_text( + "auth:\n authority: shared\n", encoding="utf-8" + ) + + context = multiprocessing.get_context("spawn") + ready = context.Queue() + start = context.Event() + workers = [ + context.Process( + target=_increment_provider, + args=(str(profile), "nous", 20, ready, start), + ) + for profile in profiles + ] + for worker in workers: + worker.start() + assert {ready.get(timeout=10), ready.get(timeout=10)} == { + str(profile) for profile in profiles + } + start.set() + for worker in workers: + worker.join(20) + assert worker.exitcode == 0 + + store = json.loads((root / "auth.json").read_text(encoding="utf-8")) + assert store["providers"]["nous"]["rotations"] == 40 + assert (root / "auth.lock").is_file() + assert not any((profile / "auth.lock").exists() for profile in profiles) + + +def test_explicit_profile_authorities_remain_isolated_under_same_workload( + tmp_path: Path, +) -> None: + root = tmp_path / "hermes" + profiles = [root / "profiles" / name for name in ("alpha", "beta")] + for profile in profiles: + profile.mkdir(parents=True) + (profile / "config.yaml").write_text( + "auth:\n authority: profile\n", encoding="utf-8" + ) + + context = multiprocessing.get_context("spawn") + ready = context.Queue() + start = context.Event() + workers = [ + context.Process( + target=_increment_provider, + args=(str(profile), "nous", 20, ready, start), + ) + for profile in profiles + ] + for worker in workers: + worker.start() + assert {ready.get(timeout=10), ready.get(timeout=10)} == { + str(profile) for profile in profiles + } + start.set() + for worker in workers: + worker.join(20) + assert worker.exitcode == 0 + + for profile in profiles: + store = json.loads((profile / "auth.json").read_text(encoding="utf-8")) + assert store["providers"]["nous"]["rotations"] == 20 + assert (profile / "auth.json").stat().st_mode & 0o777 == 0o600 + assert (profile / "auth.lock").is_file() + assert not (root / "auth.json").exists() + assert not (root / "auth.lock").exists() + + +@pytest.mark.parametrize("failure_point", ["before-temp", "after-temp"]) +def test_atomic_save_failure_preserves_store_and_backup( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure_point: str, +) -> None: + import hermes_cli.auth as auth_mod + + home = tmp_path / "hermes" + home.mkdir() + (home / "config.yaml").write_text( + "auth:\n authority: shared\n", encoding="utf-8" + ) + auth_file = home / "auth.json" + original = b'{"version":1,"providers":{"nous":{"generation":"old"}}}\n' + auth_file.write_bytes(original) + auth_file.chmod(0o600) + backup = home / "auth.json.backup" + backup.write_bytes(original) + backup.chmod(0o600) + monkeypatch.setenv("HERMES_HOME", str(home)) + + if failure_point == "before-temp": + real_open = auth_mod.os.open + + def fail_temp_open(path, flags, mode=0o777): + if ".tmp." in os.fspath(path): + raise OSError("failure before temporary-file creation") + return real_open(path, flags, mode) + + monkeypatch.setattr(auth_mod.os, "open", fail_temp_open) + else: + def fail_replace(*_args): + raise OSError("failure after temporary-file creation") + + monkeypatch.setattr(auth_mod, "atomic_replace", fail_replace) + + with pytest.raises(OSError, match="temporary-file creation"): + with auth_mod._auth_store_lock(): + auth_mod._save_auth_store( + {"providers": {"nous": {"generation": "new"}}} + ) + + assert json.loads(auth_file.read_text(encoding="utf-8"))["providers"]["nous"] == { + "generation": "old" + } + assert json.loads(backup.read_text(encoding="utf-8"))["providers"]["nous"] == { + "generation": "old" + } + assert not list(home.glob("auth.json.tmp.*")) + assert auth_file.stat().st_mode & 0o777 == 0o600 + + +def test_store_symlink_substitution_while_acquiring_lock_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import hermes_cli.auth as auth_mod + + home = tmp_path / "hermes" + home.mkdir() + (home / "config.yaml").write_text( + "auth:\n authority: shared\n", encoding="utf-8" + ) + outside = tmp_path / "outside.json" + outside.write_text('{"providers":{"outside":{}}}', encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home)) + real_file_lock = auth_mod._file_lock + substituted = False + + @contextmanager + def substitute_after_lock(lock_path, holder, timeout_seconds, timeout_message): + nonlocal substituted + with real_file_lock(lock_path, holder, timeout_seconds, timeout_message): + if Path(lock_path) == home / "auth.lock" and not substituted: + substituted = True + (home / "auth.json").symlink_to(outside) + yield + + monkeypatch.setattr(auth_mod, "_file_lock", substitute_after_lock) + + with pytest.raises(RuntimeError, match="symlink auth transaction path"): + with auth_mod._auth_store_lock(): + auth_mod._save_auth_store({"providers": {"replacement": {}}}) + + assert substituted is True + assert (home / "auth.json").is_symlink() + assert json.loads(outside.read_text(encoding="utf-8")) == { + "providers": {"outside": {}} + } + + +def test_implicit_auth_transaction_pins_the_resolved_authority( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A topology change cannot split one locked load/modify/save transaction.""" + from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store + from hermes_cli.auth_authority import AuthAuthority + + first = tmp_path / "first.json" + second = tmp_path / "second.json" + first.write_text('{"providers": {"nous": {"value": "first"}}}', encoding="utf-8") + second.write_text('{"providers": {"nous": {"value": "second"}}}', encoding="utf-8") + calls = 0 + + def make_authority(path: Path) -> AuthAuthority: + return AuthAuthority( + requested_mode="shared", + effective_mode="shared", + auth_path=path, + lock_path=path.with_suffix(".lock"), + profile_home=tmp_path, + shared_root=tmp_path, + profile_id=None, + config_path=tmp_path / "config.yaml", + ) + + def changing_resolver(**_kwargs) -> AuthAuthority: + nonlocal calls + calls += 1 + return make_authority(first if calls == 1 else second) + + monkeypatch.setattr("hermes_cli.auth.resolve_auth_authority", changing_resolver) + + with _auth_store_lock(): + store = _load_auth_store() + store["providers"]["nous"]["value"] = "updated" + _save_auth_store(store) + + assert json.loads(first.read_text(encoding="utf-8"))["providers"]["nous"]["value"] == "updated" + assert json.loads(second.read_text(encoding="utf-8"))["providers"]["nous"]["value"] == "second" + + +def test_nested_auth_transaction_does_not_invert_transition_lock( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A reentrant auth lock must not wait behind a transition waiting on it.""" + from hermes_cli.auth import _auth_store_lock, _auth_transition_lock + + root = tmp_path / ".hermes" + profile = root / "profiles" / "coder" + profile.mkdir(parents=True) + (profile / "config.yaml").write_text( + "auth:\n authority: profile\n", encoding="utf-8" + ) + monkeypatch.setenv("HERMES_HOME", str(profile)) + transition_acquired = threading.Event() + + def transition() -> None: + with _auth_transition_lock(timeout_seconds=2): + transition_acquired.set() + with _auth_store_lock( + target_path=profile / "auth.json", timeout_seconds=2 + ): + pass + + with _auth_store_lock(timeout_seconds=2): + worker = threading.Thread(target=transition) + worker.start() + assert transition_acquired.wait(1) + with _auth_store_lock(timeout_seconds=2): + pass + assert worker.is_alive() + + worker.join(2) + assert not worker.is_alive() + assert transition_acquired.is_set() diff --git a/tests/hermes_cli/test_auth_migration.py b/tests/hermes_cli/test_auth_migration.py new file mode 100644 index 000000000000..20802b6247d8 --- /dev/null +++ b/tests/hermes_cli/test_auth_migration.py @@ -0,0 +1,488 @@ +"""End-to-end contracts for shared-auth migration and recovery artifacts.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import threading +import time + +import pytest + + +@pytest.fixture() +def migration_home(tmp_path: Path, monkeypatch): + root = tmp_path / ".hermes" + profile = root / "profiles" / "coder" + profile.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(profile)) + return root, profile + + +def _write(path: Path, value: dict) -> bytes: + path.parent.mkdir(parents=True, exist_ok=True) + raw = (json.dumps(value, indent=2) + "\n").encode() + path.write_bytes(raw) + path.chmod(0o600) + return raw + + +def test_dry_run_is_redacted_and_uses_private_artifact(migration_home): + from hermes_cli.auth_migration import plan_shared_migration + + _, profile = migration_home + token = "super-secret-access-token" + _write(profile / "auth.json", {"providers": {"nous": {"access_token": token}}}) + + plan = plan_shared_migration(profile="coder") + + public = json.dumps(plan.manifest, sort_keys=True) + artifact = plan.artifact_path.read_text() + assert token not in public + assert token not in artifact + assert plan.plan_digest in artifact + assert plan.manifest["sources"][0]["providers"] == ["nous"] + if os.name != "nt": + assert plan.artifact_path.stat().st_mode & 0o777 == 0o600 + + +def test_apply_merges_under_shared_authority_without_overwriting_source(migration_home): + from hermes_cli.auth_migration import apply_shared_migration, plan_shared_migration + + root, profile = migration_home + source_raw = _write( + profile / "auth.json", + {"providers": {"nous": {"access_token": "profile-token"}}}, + ) + _write( + root / "auth.json", {"providers": {"openai-codex": {"access_token": "shared"}}} + ) + + plan = plan_shared_migration(profile="coder") + applied = apply_shared_migration( + plan_id=plan.plan_id, + plan_digest=plan.plan_digest, + conflict_policy="abort", + ) + + assert applied == plan.plan_id + merged = json.loads((root / "auth.json").read_text()) + assert set(merged["providers"]) == {"nous", "openai-codex"} + assert (profile / "auth.json").read_bytes() == source_raw + assert "authority: shared" in (profile / "config.yaml").read_text() + journal = json.loads( + ( + root + / "state-snapshots" + / "auth-migrations" + / "journals" + / f"{plan.plan_id}.json" + ).read_text() + ) + assert journal["phase"] == "committed" + if os.name != "nt": + assert (root / "auth.json").stat().st_mode & 0o777 == 0o600 + + +def test_apply_rejects_stale_plan_before_writing(migration_home): + from hermes_cli.auth_migration import ( + AuthMigrationError, + apply_shared_migration, + plan_shared_migration, + ) + + root, profile = migration_home + _write(profile / "auth.json", {"providers": {"nous": {"access_token": "before"}}}) + plan = plan_shared_migration(profile="coder") + _write(profile / "auth.json", {"providers": {"nous": {"access_token": "after"}}}) + + with pytest.raises(AuthMigrationError, match="changed after dry-run"): + apply_shared_migration( + plan_id=plan.plan_id, + plan_digest=plan.plan_digest, + conflict_policy="abort", + ) + assert not (root / "auth.json").exists() + + +def test_explicit_rollback_restores_committed_pre_state(migration_home): + from hermes_cli.auth_migration import ( + apply_shared_migration, + plan_shared_migration, + rollback_shared_migration, + ) + + root, profile = migration_home + shared_raw = _write( + root / "auth.json", {"providers": {"openai-codex": {"access_token": "shared"}}} + ) + profile_raw = _write( + profile / "auth.json", {"providers": {"nous": {"access_token": "profile"}}} + ) + config_raw = b"display:\n skin: mono\n" + (profile / "config.yaml").write_bytes(config_raw) + plan = plan_shared_migration(profile="coder") + apply_shared_migration( + plan_id=plan.plan_id, + plan_digest=plan.plan_digest, + conflict_policy="abort", + ) + + assert rollback_shared_migration(plan_id=plan.plan_id) == "rolled_back" + assert (root / "auth.json").read_bytes() == shared_raw + assert (profile / "auth.json").read_bytes() == profile_raw + assert (profile / "config.yaml").read_bytes() == config_raw + assert rollback_shared_migration(plan_id=plan.plan_id) == "rolled_back" + + +def test_explicit_rollback_refuses_changed_committed_state(migration_home): + from hermes_cli.auth_migration import ( + AuthMigrationError, + apply_shared_migration, + plan_shared_migration, + rollback_shared_migration, + ) + + root, profile = migration_home + _write(profile / "auth.json", {"providers": {"nous": {"access_token": "profile"}}}) + plan = plan_shared_migration(profile="coder") + apply_shared_migration( + plan_id=plan.plan_id, + plan_digest=plan.plan_digest, + conflict_policy="abort", + ) + changed = _write( + root / "auth.json", {"providers": {"nous": {"access_token": "rotated"}}} + ) + + with pytest.raises(AuthMigrationError, match="changed after migration"): + rollback_shared_migration(plan_id=plan.plan_id) + assert (root / "auth.json").read_bytes() == changed + + +def test_conflict_policy_is_explicit_and_abort_is_non_destructive(migration_home): + from hermes_cli.auth_migration import ( + AuthMigrationError, + apply_shared_migration, + plan_shared_migration, + ) + + root, profile = migration_home + shared_raw = _write( + root / "auth.json", {"providers": {"nous": {"access_token": "shared"}}} + ) + profile_raw = _write( + profile / "auth.json", {"providers": {"nous": {"access_token": "profile"}}} + ) + plan = plan_shared_migration(profile="coder") + + with pytest.raises(AuthMigrationError, match="Divergent"): + apply_shared_migration( + plan_id=plan.plan_id, + plan_digest=plan.plan_digest, + conflict_policy="abort", + ) + assert (root / "auth.json").read_bytes() == shared_raw + assert (profile / "auth.json").read_bytes() == profile_raw + + +def test_apply_rejects_unreviewed_digest(migration_home): + from hermes_cli.auth_migration import ( + AuthMigrationError, + apply_shared_migration, + plan_shared_migration, + ) + + _, profile = migration_home + _write(profile / "auth.json", {"providers": {"nous": {"access_token": "token"}}}) + plan = plan_shared_migration(profile="coder") + with pytest.raises(AuthMigrationError, match="digest"): + apply_shared_migration( + plan_id=plan.plan_id, + plan_digest="0" * 64, + conflict_policy="prefer-shared", + ) + + +def test_migration_rejects_profile_outside_profiles_root( + migration_home, tmp_path: Path +): + from hermes_cli.auth_migration import AuthMigrationError, plan_shared_migration + + external = tmp_path / "external-profile" + external.mkdir() + _write(external / "auth.json", {"providers": {}}) + (migration_home[0] / "profiles" / "escaped").symlink_to( + external, target_is_directory=True + ) + with pytest.raises(AuthMigrationError, match="outside the Hermes profiles root"): + plan_shared_migration(profile="escaped") + + +def test_migration_aborts_when_relevant_gateway_is_running( + migration_home, monkeypatch +): + from gateway import status as gateway_status + from hermes_cli.auth_migration import ( + AuthMigrationError, + apply_shared_migration, + plan_shared_migration, + ) + + root, profile = migration_home + plan = plan_shared_migration(profile="coder") + monkeypatch.setattr(gateway_status, "get_running_pid", lambda *args, **kwargs: 12345) + + with pytest.raises(AuthMigrationError, match="gateway.*12345"): + apply_shared_migration( + plan_id=plan.plan_id, + plan_digest=plan.plan_digest, + conflict_policy="abort", + ) + + journal = json.loads( + ( + root + / "state-snapshots" + / "auth-migrations" + / "journals" + / f"{plan.plan_id}.json" + ).read_text() + ) + assert journal["phase"] == "aborted" + + +def test_quick_snapshot_excludes_auth_by_default_and_restores_encrypted_auth( + migration_home, +): + from hermes_cli.backup import create_quick_snapshot, restore_quick_snapshot + + root, profile = migration_home + _write(root / "auth.json", {"providers": {"nous": {"access_token": "before"}}}) + (profile / "config.yaml").write_text("auth:\n authority: shared\n") + default_id = create_quick_snapshot(label="no-auth", hermes_home=profile) + assert default_id is not None + default_manifest = json.loads( + (profile / "state-snapshots" / default_id / "manifest.json").read_text() + ) + assert default_manifest["auth_authority"] is None + + passphrase = profile / "passphrase" + passphrase.write_text("correct horse battery staple", encoding="utf-8") + snap_id = create_quick_snapshot( + label="with-auth", + hermes_home=profile, + auth_mode="include-encrypted", + auth_passphrase_file=str(passphrase), + ) + assert snap_id is not None + snap_dir = profile / "state-snapshots" / snap_id + manifest = json.loads((snap_dir / "manifest.json").read_text()) + assert manifest["auth_authority"]["authority"] == "shared" + assert b"before" not in (snap_dir / "_auth" / "authority.enc").read_bytes() + + _write(root / "auth.json", {"providers": {"nous": {"access_token": "after"}}}) + assert restore_quick_snapshot(snap_id, hermes_home=profile) + current = json.loads((root / "auth.json").read_text()) + assert current["providers"]["nous"]["access_token"] == "after" + + # Naming a restore destination is not enough: callers must explicitly opt + # into restoring credentials as well. + assert restore_quick_snapshot( + snap_id, + hermes_home=profile, + auth_action="restore-shared", + auth_passphrase_file=str(passphrase), + ) + still_current = json.loads((root / "auth.json").read_text()) + assert still_current["providers"]["nous"]["access_token"] == "after" + + assert restore_quick_snapshot( + snap_id, + hermes_home=profile, + include_auth=True, + auth_action="restore-shared", + auth_passphrase_file=str(passphrase), + ) + restored = json.loads((root / "auth.json").read_text()) + assert restored["providers"]["nous"]["access_token"] == "before" + + +def test_quick_snapshot_include_encrypted_fails_without_passphrase(migration_home): + from hermes_cli.backup import create_quick_snapshot + + root, profile = migration_home + _write(root / "auth.json", {"providers": {"nous": {"access_token": "before"}}}) + (profile / "config.yaml").write_text("auth:\n authority: shared\n") + + with pytest.raises(ValueError, match="passphrase file is required"): + create_quick_snapshot( + label="missing-passphrase", + hermes_home=profile, + auth_mode="include-encrypted", + ) + + +def test_recovery_rolls_back_interrupted_commit(migration_home, monkeypatch): + import hermes_cli.auth_migration as migration + + root, profile = migration_home + original_shared = _write( + root / "auth.json", {"providers": {"openai-codex": {"access_token": "shared"}}} + ) + original_profile = _write( + profile / "auth.json", {"providers": {"nous": {"access_token": "profile"}}} + ) + plan = migration.plan_shared_migration(profile="coder") + + def crash(_path): + raise RuntimeError("injected crash after target write") + + monkeypatch.setattr(migration, "_set_shared_authority", crash) + with pytest.raises(RuntimeError, match="injected crash"): + migration.apply_shared_migration( + plan_id=plan.plan_id, + plan_digest=plan.plan_digest, + conflict_policy="abort", + ) + merged = json.loads((root / "auth.json").read_text()) + assert set(merged["providers"]) == {"nous", "openai-codex"} + + assert migration.recover_shared_migration(plan_id=plan.plan_id) == "rolled_back" + assert (root / "auth.json").read_bytes() == original_shared + assert (profile / "auth.json").read_bytes() == original_profile + assert not (profile / "config.yaml").exists() + assert migration.recover_shared_migration(plan_id=plan.plan_id) == "rolled_back" + + +def test_recovery_refuses_to_overwrite_state_changed_after_interrupted_commit( + migration_home, +): + import hermes_cli.auth_migration as migration + + root, profile = migration_home + _write(root / "auth.json", {"providers": {"openai-codex": {"token": "before"}}}) + _write(profile / "auth.json", {"providers": {"nous": {"token": "profile"}}}) + plan = migration.plan_shared_migration(profile="coder") + + def fail(phase: str) -> None: + if phase == "target_written": + raise RuntimeError("injected after target write") + + with pytest.raises(RuntimeError, match="injected"): + migration.apply_shared_migration( + plan_id=plan.plan_id, + plan_digest=plan.plan_digest, + conflict_policy="abort", + failure_injector=fail, + ) + changed = _write( + root / "auth.json", {"providers": {"nous": {"token": "rotated-after-crash"}}} + ) + + with pytest.raises(migration.AuthMigrationError, match="changed after interruption"): + migration.recover_shared_migration(plan_id=plan.plan_id) + + assert (root / "auth.json").read_bytes() == changed + journal = json.loads( + ( + root + / "state-snapshots" + / "auth-migrations" + / "journals" + / f"{plan.plan_id}.json" + ).read_text() + ) + assert journal["phase"] == "manual_required" + assert journal["reason"] == "committed_state_changed" + + +def test_recovery_after_backup_only_preserves_later_writes(migration_home): + import hermes_cli.auth_migration as migration + + root, profile = migration_home + _write(root / "auth.json", {"providers": {"openai-codex": {"token": "before"}}}) + _write(profile / "auth.json", {"providers": {"nous": {"token": "profile"}}}) + plan = migration.plan_shared_migration(profile="coder") + + def fail(phase: str) -> None: + if phase == "backed_up": + raise RuntimeError("injected after backup") + + with pytest.raises(RuntimeError, match="injected"): + migration.apply_shared_migration( + plan_id=plan.plan_id, + plan_digest=plan.plan_digest, + conflict_policy="abort", + failure_injector=fail, + ) + changed = _write( + root / "auth.json", {"providers": {"openai-codex": {"token": "later"}}} + ) + + assert migration.recover_shared_migration(plan_id=plan.plan_id) == "aborted" + assert (root / "auth.json").read_bytes() == changed + journal = json.loads( + ( + root + / "state-snapshots" + / "auth-migrations" + / "journals" + / f"{plan.plan_id}.json" + ).read_text() + ) + assert journal["phase"] == "aborted" + assert journal["reason"] == "external_change_after_backup" + + +def test_concurrent_source_write_is_not_lost_and_invalidates_plan(migration_home): + from hermes_cli.auth import _auth_store_lock + from hermes_cli.auth_migration import AuthMigrationError, apply_shared_migration + from hermes_cli.auth_migration import plan_shared_migration + + root, profile = migration_home + source = profile / "auth.json" + _write(source, {"providers": {"nous": {"access_token": "planned"}}}) + plan = plan_shared_migration(profile="coder") + writer_locked = threading.Event() + allow_write = threading.Event() + apply_done = threading.Event() + outcome: list[BaseException] = [] + + def writer(): + with _auth_store_lock(target_path=source): + writer_locked.set() + assert allow_write.wait(5) + _write(source, {"providers": {"nous": {"access_token": "concurrent"}}}) + + def apply(): + try: + apply_shared_migration( + plan_id=plan.plan_id, + plan_digest=plan.plan_digest, + conflict_policy="abort", + ) + except BaseException as exc: + outcome.append(exc) + finally: + apply_done.set() + + writer_thread = threading.Thread(target=writer) + apply_thread = threading.Thread(target=apply) + writer_thread.start() + assert writer_locked.wait(5) + apply_thread.start() + time.sleep(0.1) + assert not apply_done.is_set() + allow_write.set() + writer_thread.join(5) + apply_thread.join(5) + + assert len(outcome) == 1 + assert isinstance(outcome[0], AuthMigrationError) + assert "changed after dry-run" in str(outcome[0]) + saved = json.loads(source.read_text()) + assert saved["providers"]["nous"]["access_token"] == "concurrent" + assert not (root / "auth.json").exists() diff --git a/tests/hermes_cli/test_auth_migration_adversarial.py b/tests/hermes_cli/test_auth_migration_adversarial.py new file mode 100644 index 000000000000..92a4c13a6cbf --- /dev/null +++ b/tests/hermes_cli/test_auth_migration_adversarial.py @@ -0,0 +1,269 @@ +"""Adversarial concurrency and crash-window contracts for shared auth migration.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + + +@pytest.fixture() +def migration_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + root = tmp_path / ".hermes" + profile = root / "profiles" / "coder" + profile.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(profile)) + return root, profile + + +def _write(path: Path, value: dict) -> bytes: + path.parent.mkdir(parents=True, exist_ok=True) + raw = (json.dumps(value, indent=2) + "\n").encode() + path.write_bytes(raw) + path.chmod(0o600) + return raw + + +def test_gateway_quiescence_includes_unselected_shared_profiles( + migration_home, monkeypatch: pytest.MonkeyPatch +) -> None: + from gateway import status as gateway_status + from hermes_cli.auth_migration import ( + AuthMigrationError, + apply_shared_migration, + plan_shared_migration, + ) + + root, profile = migration_home + _write(profile / "auth.json", {"providers": {"nous": {"token": "profile"}}}) + peer = root / "profiles" / "already-shared" + peer.mkdir(parents=True) + (peer / "config.yaml").write_text( + "auth:\n authority: shared\n", encoding="utf-8" + ) + plan = plan_shared_migration(profile="coder") + assert str(peer) in json.loads(plan.artifact_path.read_text())["gateway_homes"] + + def running_peer(pid_path: Path, **_kwargs): + return 9876 if pid_path == peer / "gateway.pid" else None + + monkeypatch.setattr(gateway_status, "get_running_pid", running_peer) + with pytest.raises(AuthMigrationError, match="gateway.*9876"): + apply_shared_migration( + plan_id=plan.plan_id, + plan_digest=plan.plan_digest, + conflict_policy="abort", + ) + + +def test_gateway_quiescence_uses_runtime_status_when_pid_artifacts_are_missing( + migration_home, monkeypatch: pytest.MonkeyPatch +) -> None: + from gateway import status as gateway_status + from hermes_cli.auth_migration import ( + AuthMigrationError, + apply_shared_migration, + plan_shared_migration, + ) + + root, profile = migration_home + _write(profile / "auth.json", {"providers": {"nous": {"token": "profile"}}}) + peer = root / "profiles" / "runtime-only" + peer.mkdir(parents=True) + (peer / "config.yaml").write_text( + "auth:\n authority: shared\n", encoding="utf-8" + ) + (peer / "gateway_state.json").write_text( + json.dumps({"pid": os.getpid(), "gateway_state": "running"}), + encoding="utf-8", + ) + plan = plan_shared_migration(profile="coder") + monkeypatch.setattr(gateway_status, "get_running_pid", lambda *_args, **_kwargs: None) + + with pytest.raises(AuthMigrationError, match=f"gateway.*{os.getpid()}"): + apply_shared_migration( + plan_id=plan.plan_id, + plan_digest=plan.plan_digest, + conflict_policy="abort", + ) + + +@pytest.mark.parametrize("failure_phase", ["planned", "locked", "backed_up"]) +def test_recovery_aborts_pre_mutation_phases_without_claiming_rollback( + migration_home, failure_phase: str +) -> None: + import hermes_cli.auth_migration as migration + + root, profile = migration_home + original_shared = _write( + root / "auth.json", {"providers": {"openai-codex": {"token": "shared"}}} + ) + _write(profile / "auth.json", {"providers": {"nous": {"token": "profile"}}}) + plan = migration.plan_shared_migration(profile="coder") + + def fail(phase: str) -> None: + if phase == failure_phase: + raise RuntimeError(f"injected at {phase}") + + with pytest.raises(RuntimeError, match="injected"): + migration.apply_shared_migration( + plan_id=plan.plan_id, + plan_digest=plan.plan_digest, + conflict_policy="abort", + failure_injector=fail, + ) + + assert migration.recover_shared_migration(plan_id=plan.plan_id) == "aborted" + assert (root / "auth.json").read_bytes() == original_shared + journal = json.loads( + ( + root + / "state-snapshots" + / "auth-migrations" + / "journals" + / f"{plan.plan_id}.json" + ).read_text() + ) + assert journal["phase"] == "aborted" + assert journal["reason"] == "interrupted_before_mutation" + + +@pytest.mark.parametrize("failure_phase", ["target_write_pending", "profile_written"]) +def test_recovery_covers_mutation_journal_windows( + migration_home, failure_phase: str +) -> None: + import hermes_cli.auth_migration as migration + + root, profile = migration_home + original_shared = _write( + root / "auth.json", {"providers": {"openai-codex": {"token": "shared"}}} + ) + _write(profile / "auth.json", {"providers": {"nous": {"token": "profile"}}}) + plan = migration.plan_shared_migration(profile="coder") + + def fail(phase: str) -> None: + if phase == failure_phase: + raise RuntimeError(f"injected at {phase}") + + with pytest.raises(RuntimeError, match="injected"): + migration.apply_shared_migration( + plan_id=plan.plan_id, + plan_digest=plan.plan_digest, + conflict_policy="abort", + failure_injector=fail, + ) + assert migration.recover_shared_migration(plan_id=plan.plan_id) in { + "aborted", + "rolled_back", + } + assert (root / "auth.json").read_bytes() == original_shared + assert not (profile / "config.yaml").exists() + + +def test_manual_recovery_is_retryable_after_expected_state_is_restored( + migration_home, +) -> None: + import hermes_cli.auth_migration as migration + + root, profile = migration_home + original_shared = _write( + root / "auth.json", {"providers": {"openai-codex": {"token": "shared"}}} + ) + _write(profile / "auth.json", {"providers": {"nous": {"token": "profile"}}}) + plan = migration.plan_shared_migration(profile="coder") + + def fail(phase: str) -> None: + if phase == "target_written": + raise RuntimeError("injected after target") + + with pytest.raises(RuntimeError, match="injected"): + migration.apply_shared_migration( + plan_id=plan.plan_id, + plan_digest=plan.plan_digest, + conflict_policy="abort", + failure_injector=fail, + ) + expected_migration = (root / "auth.json").read_bytes() + _write(root / "auth.json", {"providers": {"nous": {"token": "external"}}}) + with pytest.raises(migration.AuthMigrationError, match="changed after interruption"): + migration.recover_shared_migration(plan_id=plan.plan_id) + + (root / "auth.json").write_bytes(expected_migration) + assert migration.recover_shared_migration(plan_id=plan.plan_id) == "rolled_back" + assert (root / "auth.json").read_bytes() == original_shared + + +def test_recovery_detects_changed_committed_state(migration_home) -> None: + import hermes_cli.auth_migration as migration + + root, profile = migration_home + _write(profile / "auth.json", {"providers": {"nous": {"token": "profile"}}}) + plan = migration.plan_shared_migration(profile="coder") + migration.apply_shared_migration( + plan_id=plan.plan_id, + plan_digest=plan.plan_digest, + conflict_policy="abort", + ) + changed = _write(root / "auth.json", {"providers": {"nous": {"token": "later"}}}) + + assert ( + migration.recover_shared_migration(plan_id=plan.plan_id) + == "committed_state_changed" + ) + assert (root / "auth.json").read_bytes() == changed + journal = json.loads( + ( + root + / "state-snapshots" + / "auth-migrations" + / "journals" + / f"{plan.plan_id}.json" + ).read_text() + ) + assert journal["phase"] == "committed_state_changed" + assert journal["reason"] == "committed_state_changed" + assert ( + migration.recover_shared_migration(plan_id=plan.plan_id) + == "committed_state_changed" + ) + from hermes_cli.auth_authority import resolve_auth_authority + + resolved = resolve_auth_authority(profile_home=profile, shared_root=root) + assert resolved.effective_mode == "shared" + + +def test_manifest_records_each_artifact_with_stable_profile_identity( + migration_home, +) -> None: + import hermes_cli.auth_migration as migration + + root, profile = migration_home + (profile / "config.yaml").write_text( + "auth:\n authority: profile\n", + encoding="utf-8", + ) + _write(profile / "auth.json", {"providers": {}}) + + plan = migration.plan_shared_migration(profile="coder") + + source = plan.manifest["sources"][0] + assert source["profile_id"] == "coder" + assert source["artifacts"] == [ + { + "artifact_class": "profile-auth", + "exists": True, + "profile_id": "coder", + }, + { + "artifact_class": "profile-config", + "exists": True, + "profile_id": "coder", + }, + ] + assert plan.manifest["target_artifact"] == { + "artifact_class": "shared-auth", + "exists": False, + } diff --git a/tests/hermes_cli/test_auth_profile_fallback.py b/tests/hermes_cli/test_auth_profile_fallback.py index 410137f6510c..51580a41ba1b 100644 --- a/tests/hermes_cli/test_auth_profile_fallback.py +++ b/tests/hermes_cli/test_auth_profile_fallback.py @@ -52,15 +52,108 @@ def _write(path: Path, payload: dict) -> None: path.write_text(json.dumps(payload, indent=2)) +def _write_profile_auth_mode(profile: Path) -> None: + """Opt into profile authority for profile-only write and lock tests.""" + (profile / "config.yaml").write_text("auth:\n authority: profile\n", encoding="utf-8") + + # --------------------------------------------------------------------------- # read_credential_pool — provider-slice reads # --------------------------------------------------------------------------- +def test_profile_with_zero_entries_falls_back_to_global(profile_env): + """Empty profile pool inherits the global-root entries for that provider.""" + from hermes_cli.auth import read_credential_pool + + _write(profile_env["global"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "glob-1", + "label": "global-key", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-global", + }], + })) + # Profile auth.json: exists but has no openrouter entries. + _write(profile_env["profile"] / "auth.json", _make_auth_store(pool={})) + entries = read_credential_pool("openrouter") + assert len(entries) == 1 + assert entries[0]["id"] == "glob-1" + assert entries[0]["access_token"] == "sk-or-global" +def test_profile_with_entries_fully_shadows_global(profile_env): + """Once the profile has any entries for a provider, global is ignored.""" + from hermes_cli.auth import read_credential_pool + + _write(profile_env["global"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "glob-1", + "label": "global-key", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-global", + }], + })) + _write(profile_env["profile"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "prof-1", + "label": "profile-key", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-profile", + }], + })) + + entries = read_credential_pool("openrouter") + assert len(entries) == 1 + assert entries[0]["id"] == "prof-1" + assert entries[0]["access_token"] == "sk-or-profile" + + +def test_per_provider_shadowing_is_independent(profile_env): + """Profile can override one provider while inheriting another from global.""" + from hermes_cli.auth import read_credential_pool + + _write(profile_env["global"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "glob-or", + "label": "global-or", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-global", + }], + "anthropic": [{ + "id": "glob-ant", + "label": "global-ant", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-ant-global", + }], + })) + _write(profile_env["profile"] / "auth.json", _make_auth_store(pool={ + # Profile has openrouter only — anthropic should still fall back. + "openrouter": [{ + "id": "prof-or", + "label": "profile-or", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-profile", + }], + })) + or_entries = read_credential_pool("openrouter") + ant_entries = read_credential_pool("anthropic") + assert [e["id"] for e in or_entries] == ["prof-or"] + assert [e["id"] for e in ant_entries] == ["glob-ant"] def test_missing_global_auth_file_is_safe(profile_env): @@ -109,6 +202,44 @@ def test_malformed_global_auth_file_does_not_break_profile_read(profile_env): # --------------------------------------------------------------------------- +def test_whole_pool_merges_global_providers_when_missing_locally(profile_env): + from hermes_cli.auth import read_credential_pool + + _write(profile_env["global"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "glob-or", + "label": "global-or", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-global", + }], + "anthropic": [{ + "id": "glob-ant", + "label": "global-ant", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-ant-global", + }], + })) + _write(profile_env["profile"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "prof-or", + "label": "profile-or", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-profile", + }], + })) + + pool = read_credential_pool(None) + # Profile wins for openrouter, global fills in anthropic. + assert [e["id"] for e in pool["openrouter"]] == ["prof-or"] + assert [e["id"] for e in pool["anthropic"]] == ["glob-ant"] + + # --------------------------------------------------------------------------- # get_provider_auth_state — singleton fallback # --------------------------------------------------------------------------- @@ -127,6 +258,21 @@ def test_provider_auth_state_falls_back_to_global_when_profile_has_none(profile_ assert state["access_token"] == "nous-global" +def test_provider_auth_state_profile_wins_when_present(profile_env): + from hermes_cli.auth import get_provider_auth_state + + _write(profile_env["global"] / "auth.json", _make_auth_store(providers={ + "nous": {"access_token": "nous-global"}, + })) + _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={ + "nous": {"access_token": "nous-profile"}, + })) + + state = get_provider_auth_state("nous") + assert state is not None + assert state["access_token"] == "nous-profile" + + def test_provider_auth_state_returns_none_when_neither_has_it(profile_env): from hermes_cli.auth import get_provider_auth_state @@ -149,8 +295,83 @@ def test_provider_auth_state_returns_none_when_neither_has_it(profile_env): # --------------------------------------------------------------------------- +def test_load_provider_state_falls_back_to_global(profile_env): + """When the loaded profile store has no provider entry, fall back to global.""" + from hermes_cli.auth import _load_auth_store, _load_provider_state + + _write(profile_env["global"] / "auth.json", _make_auth_store(providers={ + "nous": {"access_token": "global-nous-token", "refresh_token": "rt"}, + })) + _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={})) + + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "nous") + assert state is not None + assert state["access_token"] == "global-nous-token" + + +def test_load_provider_state_profile_wins_over_global(profile_env): + from hermes_cli.auth import _load_auth_store, _load_provider_state + + _write(profile_env["global"] / "auth.json", _make_auth_store(providers={ + "nous": {"access_token": "global-token"}, + })) + _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={ + "nous": {"access_token": "profile-token"}, + })) + + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "nous") + assert state is not None + assert state["access_token"] == "profile-token" +def test_load_provider_state_returns_none_when_neither_has_it(profile_env): + from hermes_cli.auth import _load_auth_store, _load_provider_state + + _write(profile_env["global"] / "auth.json", _make_auth_store(providers={})) + _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={})) + + auth_store = _load_auth_store() + assert _load_provider_state(auth_store, "nous") is None + + +def test_load_provider_state_classic_mode_no_fallback(tmp_path, monkeypatch): + """In classic mode there is no global to fall back to; behavior is unchanged.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: fake_home) + hermes_home = tmp_path / "classic" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + _write(hermes_home / "auth.json", _make_auth_store(providers={ + "nous": {"access_token": "classic-token"}, + })) + + from hermes_cli.auth import _load_auth_store, _load_provider_state + + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "nous") + assert state is not None + assert state["access_token"] == "classic-token" + # Absent providers still return None. + assert _load_provider_state(auth_store, "anthropic") is None + + +def test_load_provider_state_malformed_global_does_not_break_profile(profile_env): + """A corrupt global auth.json must not break profile reads.""" + (profile_env["global"] / "auth.json").write_text("{not valid json") + _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={ + "nous": {"access_token": "profile-token"}, + })) + + from hermes_cli.auth import _load_auth_store, _load_provider_state + + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "nous") + assert state is not None + assert state["access_token"] == "profile-token" # --------------------------------------------------------------------------- @@ -158,6 +379,44 @@ def test_provider_auth_state_returns_none_when_neither_has_it(profile_env): # --------------------------------------------------------------------------- +def test_classic_mode_does_not_double_read_same_file(tmp_path, monkeypatch): + """In classic mode (HERMES_HOME == global root), no fallback path runs. + + This guards against the merge accidentally duplicating entries when the + profile and global resolve to the same directory. + """ + # Put Path.home() under a subdir so the seat belt in _auth_file_path() + # sees tmp_path/home/.hermes as the "real home" — which is NOT equal + # to the HERMES_HOME we set (tmp_path/classic), so the guard passes. + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: fake_home) + hermes_home = tmp_path / "classic" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + _write(hermes_home / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "only", + "label": "classic", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-classic", + }], + })) + + from hermes_cli.auth import read_credential_pool, _global_auth_file_path + + # Classic mode: HERMES_HOME is set to a custom path that is NOT under + # ~/.hermes/profiles/ — get_default_hermes_root() returns HERMES_HOME + # itself, so the profile root and global root are the same directory, + # and the helper correctly returns None (no fallback). + assert _global_auth_file_path() is None + # And the read should return exactly one entry (not two). + entries = read_credential_pool("openrouter") + assert len(entries) == 1 + assert entries[0]["id"] == "only" # --------------------------------------------------------------------------- @@ -168,6 +427,7 @@ def test_provider_auth_state_returns_none_when_neither_has_it(profile_env): def test_write_credential_pool_targets_profile_not_global(profile_env): from hermes_cli.auth import read_credential_pool, write_credential_pool + _write_profile_auth_mode(profile_env["profile"]) _write(profile_env["global"] / "auth.json", _make_auth_store(pool={ "openrouter": [{ "id": "glob-1", @@ -200,15 +460,55 @@ def test_write_credential_pool_targets_profile_not_global(profile_env): assert [e["id"] for e in read_credential_pool("openrouter")] == ["prof-new"] +def test_provider_state_transaction_locks_global_fallback_before_use( + profile_env, + monkeypatch, +): + """Profile refreshes lock the root source before provider-specific locks.""" + import hermes_cli.auth as auth + + _write( + profile_env["global"] / "auth.json", + _make_auth_store(providers={"nous": {"access_token": "global-token"}}), + ) + _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={})) + + entered = [] + real_file_lock = auth._file_lock + + @contextmanager + def recording_file_lock(lock_path, holder, timeout_seconds, timeout_message): + entered.append(lock_path) + with real_file_lock( + lock_path, + holder, + timeout_seconds, + timeout_message, + ): + yield + + monkeypatch.setattr(auth, "_file_lock", recording_file_lock) + with auth._provider_state_transaction("nous") as (_store, state, source): + assert state == {"access_token": "global-token"} + assert source == profile_env["global"] / "auth.json" -def test_auth_lock_reentrancy_is_scoped_after_profile_context_switch(profile_env): - """Changing profile context cannot inherit another store's lock depth.""" + assert entered == [ + profile_env["global"] / "auth-transition.lock", + profile_env["global"] / "auth.lock", + profile_env["profile"] / "auth.lock", + ] + + +def test_auth_transaction_remains_pinned_after_profile_context_switch(profile_env): + """A context switch cannot retarget an already-pinned auth transaction.""" import hermes_cli.auth as auth from hermes_constants import reset_hermes_home_override, set_hermes_home_override profile_b = profile_env["global"] / "profiles" / "reviewer" profile_b.mkdir(parents=True) + _write_profile_auth_mode(profile_env["profile"]) + _write_profile_auth_mode(profile_b) profile_b_lock = profile_b / "auth.lock" with auth._auth_store_lock(): @@ -223,13 +523,22 @@ def test_auth_lock_reentrancy_is_scoped_after_profile_context_switch(profile_env assert not profile_b_lock.exists() with auth._auth_store_lock(): - assert profile_b_lock.exists() - assert getattr(holder_b, "depth", 0) == 1 + assert not profile_b_lock.exists() + assert getattr(holder_a, "depth", 0) == 2 + assert getattr(holder_b, "depth", 0) == 0 finally: reset_hermes_home_override(token) assert getattr(holder_a, "depth", 0) == 0 + token = set_hermes_home_override(profile_b) + try: + with auth._auth_store_lock(): + assert profile_b_lock.exists() + assert getattr(holder_b, "depth", 0) == 1 + finally: + reset_hermes_home_override(token) + # --------------------------------------------------------------------------- # write_credential_pool — stale-snapshot cooldown merge @@ -261,6 +570,63 @@ def _pool_entry(**overrides) -> dict: return entry +@pytest.mark.parametrize( + "disk_status,error_code", + [("exhausted", 429), ("dead", 401)], +) +def test_write_pool_stale_snapshot_keeps_newer_disk_cooldown( + classic_env, disk_status, error_code, +): + """A stale healthy snapshot must not erase a newer binding cooldown. + + Process A benches a key (EXHAUSTED with an unexpired cooldown, or DEAD); + process B persists a snapshot taken *before* that. The on-disk status is + strictly newer and still binding, so it must survive the rewrite instead + of the key being resurrected as healthy. + """ + from hermes_cli.auth import write_credential_pool + + benched_at = time.time() - 60 # newer than the snapshot, cooldown unexpired + _write(classic_env / "auth.json", _make_auth_store(pool={ + "openrouter": [_pool_entry( + last_status=disk_status, + last_status_at=benched_at, + last_error_code=error_code, + )], + })) + + # Stale in-memory snapshot: same entry, still healthy (no status fields). + write_credential_pool("openrouter", [_pool_entry()]) + + data = json.loads((classic_env / "auth.json").read_text()) + persisted = data["credential_pool"]["openrouter"][0] + assert persisted["last_status"] == disk_status + assert persisted["last_status_at"] == benched_at + assert persisted["last_error_code"] == error_code + + +def test_write_pool_expired_disk_cooldown_is_not_resurrected(classic_env): + """An expired on-disk cooldown is NOT re-adopted onto the snapshot. + + The pool's own expiry-clear (and any caller that legitimately observed + the cooldown lapse) must win: only still-binding cooldowns are merged. + """ + from hermes_cli.auth import write_credential_pool + + _write(classic_env / "auth.json", _make_auth_store(pool={ + "openrouter": [_pool_entry( + last_status="exhausted", + last_status_at=time.time() - 90_000, # far past the 1h 429 TTL + last_error_code=429, + )], + })) + + write_credential_pool("openrouter", [_pool_entry()]) + + data = json.loads((classic_env / "auth.json").read_text()) + persisted = data["credential_pool"]["openrouter"][0] + assert persisted.get("last_status") != "exhausted" + assert persisted.get("last_error_code") is None def test_write_pool_never_merges_cooldown_onto_reauthed_entry(classic_env): diff --git a/tests/hermes_cli/test_auth_recovery.py b/tests/hermes_cli/test_auth_recovery.py new file mode 100644 index 000000000000..c9505751e884 --- /dev/null +++ b/tests/hermes_cli/test_auth_recovery.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def test_auth_migrate_recover_command_dispatches(monkeypatch) -> None: + from hermes_cli import auth_commands + import hermes_cli.auth_migration as migration + + calls: list[str] = [] + monkeypatch.setattr( + migration, + "recover_shared_migration", + lambda *, plan_id: calls.append(plan_id) or "rolled_back", + ) + + auth_commands.auth_migrate_shared_command( + SimpleNamespace(recover=True, plan_id="abc123") + ) + + assert calls == ["abc123"] + + +@pytest.mark.parametrize( + ("failure_phase", "expected_result"), + [ + ("planned", "aborted"), + ("locked", "aborted"), + ("backed_up", "aborted"), + ("target_written", "rolled_back"), + ("profiles_configured", "rolled_back"), + ], +) +def test_failure_after_every_journal_phase_blocks_until_recovery( + tmp_path: Path, monkeypatch, failure_phase: str, expected_result: str +) -> None: + from hermes_cli.auth_authority import AuthAuthorityConfigError, get_auth_store_path + from hermes_cli.auth_migration import ( + apply_shared_migration, + plan_shared_migration, + recover_shared_migration, + ) + + root = tmp_path / ".hermes" + profile = root / "profiles" / "coder" + profile.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(profile)) + shared_raw = b'{"providers":{"openai-codex":{"access_token":"shared"}}}\n' + profile_raw = b'{"providers":{"nous":{"access_token":"profile"}}}\n' + (root / "auth.json").write_bytes(shared_raw) + (profile / "auth.json").write_bytes(profile_raw) + plan = plan_shared_migration(profile="coder") + + def fail(phase: str) -> None: + if phase == failure_phase: + raise RuntimeError(f"injected after {phase}") + + with pytest.raises(RuntimeError, match="injected"): + apply_shared_migration( + plan_id=plan.plan_id, + plan_digest=plan.plan_digest, + conflict_policy="abort", + failure_injector=fail, + ) + + with pytest.raises(AuthAuthorityConfigError, match=plan.plan_id): + get_auth_store_path() + + assert recover_shared_migration(plan_id=plan.plan_id) == expected_result + assert (root / "auth.json").read_bytes() == shared_raw + assert (profile / "auth.json").read_bytes() == profile_raw + assert not (profile / "config.yaml").exists() + assert get_auth_store_path() == profile / "auth.json" diff --git a/tests/hermes_cli/test_auth_store_consumer_inventory.py b/tests/hermes_cli/test_auth_store_consumer_inventory.py new file mode 100644 index 000000000000..58eb52674ed8 --- /dev/null +++ b/tests/hermes_cli/test_auth_store_consumer_inventory.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path + + +def _shared_profile(tmp_path: Path) -> tuple[Path, Path, Path]: + root = tmp_path / "hermes" + profile = root / "profiles" / "consumer" + profile.mkdir(parents=True) + (profile / "config.yaml").write_text( + "auth:\n authority: shared\n", encoding="utf-8" + ) + shared_auth = root / "auth.json" + shared_auth.write_text('{"providers": {}}', encoding="utf-8") + return root, profile, shared_auth + + +def test_auth_store_consumers_resolve_one_shared_authority( + tmp_path: Path, monkeypatch +) -> None: + root = tmp_path / "hermes" + profile = root / "profiles" / "consumer" + profile.mkdir(parents=True) + (profile / "config.yaml").write_text( + "auth:\n authority: shared\n", encoding="utf-8" + ) + expected = root / "auth.json" + expected.write_text( + json.dumps( + { + "providers": { + "xai-oauth": { + "tokens": {"access_token": "test-access-token"}, + } + } + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(profile)) + + from agent.auxiliary_client import _auth_json_path as auxiliary_auth_path + from hermes_cli.auth import _auth_file_path as cli_auth_path + from plugins.platforms.photon.auth import _auth_json_path as photon_auth_path + from tools.managed_tool_gateway import auth_json_path as managed_tool_auth_path + from tools.xai_http import has_xai_credentials + + consumers = { + "CLI authentication": cli_auth_path(), + "auxiliary model client": auxiliary_auth_path(), + "managed tool gateway": managed_tool_auth_path(), + "Photon platform": photon_auth_path(), + } + assert consumers == {name: expected for name in consumers} + assert has_xai_credentials() is True + + +def test_model_cache_fingerprint_reads_shared_authority(tmp_path, monkeypatch) -> None: + root, profile, shared_auth = _shared_profile(tmp_path) + monkeypatch.setenv("HERMES_HOME", str(profile)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + from hermes_cli.models import _credential_fingerprint + + first = _credential_fingerprint("nous") + stat = shared_auth.stat() + os.utime(shared_auth, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000)) + second = _credential_fingerprint("nous") + + assert first != second + + +def test_setup_readiness_uses_canonical_authority(monkeypatch, tmp_path) -> None: + root, profile, _shared_auth = _shared_profile(tmp_path) + monkeypatch.setenv("HERMES_HOME", str(profile)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + calls: list[Path] = [] + + import hermes_cli.auth_authority as authority + + real_resolver = authority.get_auth_store_path + + def tracked_resolver() -> Path: + path = real_resolver() + calls.append(path) + return path + + monkeypatch.setattr(authority, "get_auth_store_path", tracked_resolver) + + import hermes_cli.auth as auth + + monkeypatch.setattr(auth, "get_auth_status", lambda _provider: {"logged_in": False}) + + from hermes_cli.main import _has_any_provider_configured + + _has_any_provider_configured() + assert calls == [root / "auth.json"] + + +def test_gateway_startup_gate_resolves_authority(monkeypatch, tmp_path) -> None: + _root, profile, _shared_auth = _shared_profile(tmp_path) + monkeypatch.setenv("HERMES_HOME", str(profile)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + calls = 0 + + import hermes_cli.auth_authority as authority + + real_resolver = authority.resolve_auth_authority + + def tracked_resolver(*args, **kwargs): + nonlocal calls + calls += 1 + return real_resolver(*args, **kwargs) + + monkeypatch.setattr(authority, "resolve_auth_authority", tracked_resolver) + + from gateway.run import _auth_migration_startup_ready + + assert _auth_migration_startup_ready() is True + assert calls == 1 + + +def test_profile_authority_keeps_consumer_paths_profile_local( + tmp_path: Path, monkeypatch +) -> None: + root = tmp_path / "hermes" + profile = root / "profiles" / "consumer" + profile.mkdir(parents=True) + (profile / "config.yaml").write_text( + "auth:\n authority: profile\n", encoding="utf-8" + ) + monkeypatch.setenv("HERMES_HOME", str(profile)) + + from agent.auxiliary_client import _auth_json_path as auxiliary_auth_path + from hermes_cli.auth import _auth_file_path as cli_auth_path + from plugins.platforms.photon.auth import _auth_json_path as photon_auth_path + from tools.managed_tool_gateway import auth_json_path as managed_tool_auth_path + + expected = profile / "auth.json" + assert cli_auth_path() == expected + assert auxiliary_auth_path() == expected + assert managed_tool_auth_path() == expected + assert photon_auth_path() == expected diff --git a/tests/hermes_cli/test_auth_store_validation.py b/tests/hermes_cli/test_auth_store_validation.py new file mode 100644 index 000000000000..bff5f8dd445d --- /dev/null +++ b/tests/hermes_cli/test_auth_store_validation.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from hermes_cli.auth import _load_auth_store + + +@pytest.mark.parametrize( + "payload", + [ + {"providers": [], "credential_pool": {}}, + {"providers": {}, "credential_pool": []}, + ], +) +def test_load_auth_store_quarantines_non_mapping_sections( + tmp_path: Path, + payload: dict, +) -> None: + auth_path = tmp_path / "auth.json" + raw = json.dumps(payload).encode("utf-8") + auth_path.write_bytes(raw) + + loaded = _load_auth_store(auth_path) + + assert loaded["providers"] == {} + assert not loaded.get("credential_pool") + assert auth_path.read_bytes() == raw + assert auth_path.with_suffix(".json.corrupt").read_bytes() == raw \ No newline at end of file diff --git a/tests/hermes_cli/test_backup.py b/tests/hermes_cli/test_backup.py index 0fb69caa3ec6..1213bf1da10c 100644 --- a/tests/hermes_cli/test_backup.py +++ b/tests/hermes_cli/test_backup.py @@ -348,9 +348,8 @@ def test_restores_secret_files_with_0600_perms(self, tmp_path, monkeypatch): self._make_backup_zip(zip_path, { "config.yaml": "model: openrouter\n", ".env": "OPENROUTER_API_KEY=sk-secret\n", - "auth.json": '{"providers": {"nous": "token"}}', "state.db": b"SQLite format 3\x00", - "profiles/coder/.env": "ANTHROPIC_API_KEY=sk-ant-secret\n", + "profiles/coder/.env": "ANTHROPIC_API_KEY=test-value\n", }) args = Namespace(zipfile=str(zip_path), force=True) @@ -358,7 +357,7 @@ def test_restores_secret_files_with_0600_perms(self, tmp_path, monkeypatch): from hermes_cli.backup import run_import run_import(args) - for rel in (".env", "auth.json", "state.db", "profiles/coder/.env"): + for rel in (".env", "state.db", "profiles/coder/.env"): mode = (hermes_home / rel).stat().st_mode & 0o777 assert mode == 0o600, f"{rel} restored with mode {oct(mode)}, expected 0o600" diff --git a/tests/hermes_cli/test_backup_auth_authority.py b/tests/hermes_cli/test_backup_auth_authority.py new file mode 100644 index 000000000000..fa9413649d69 --- /dev/null +++ b/tests/hermes_cli/test_backup_auth_authority.py @@ -0,0 +1,748 @@ +from __future__ import annotations + +from contextlib import contextmanager +import json +import os +from pathlib import Path +import threading +from types import SimpleNamespace +import zipfile + +import pytest + + +@pytest.fixture +def backup_home(tmp_path, monkeypatch): + root = tmp_path / ".hermes" + root.mkdir() + monkeypatch.setenv("HERMES_HOME", str(root)) + (root / "config.yaml").write_text("auth:\n authority: shared\n", encoding="utf-8") + (root / "auth.json").write_text( + json.dumps({"providers": {"nous": {"access_token": "super-secret"}}}), + encoding="utf-8", + ) + return root + + +def _backup_args(root: Path, **overrides): + values = { + "output": str(root / "backups"), + "label": None, + "auth_mode": "exclude", + "auth_passphrase_file": None, + } + values.update(overrides) + return SimpleNamespace(**values) + + +def _assert_transition_gate_held(auth_mod) -> None: + competing_result: list[str] = [] + + def compete() -> None: + try: + with auth_mod._auth_transition_lock(timeout_seconds=0.1): + competing_result.append("acquired") + except TimeoutError: + competing_result.append("blocked") + + thread = threading.Thread(target=compete) + thread.start() + thread.join(timeout=2) + assert not thread.is_alive() + assert competing_result == ["blocked"] + + +def test_full_backup_excludes_auth_by_default(backup_home): + from hermes_cli.backup import run_backup + + run_backup(_backup_args(backup_home)) + archive = backup_home / "backups.zip" + with zipfile.ZipFile(archive) as zf: + assert not any( + Path(name).name in {"auth.json", "auth.lock"} + for name in zf.namelist() + ) + payload = b"".join(zf.read(name) for name in zf.namelist()) + assert b"super-secret" not in payload + + +def test_full_backup_encrypts_and_restores_to_explicit_authority(backup_home): + from hermes_cli.backup import run_backup, run_import + + passphrase = backup_home.parent / "passphrase" + passphrase.write_text("correct horse battery staple", encoding="utf-8") + (backup_home / "config.yaml").write_text( + "auth:\n authority: shared\nmarker: archived\n", encoding="utf-8" + ) + run_backup( + _backup_args( + backup_home, + auth_mode="include-encrypted", + auth_passphrase_file=str(passphrase), + ) + ) + archive = backup_home / "backups.zip" + with zipfile.ZipFile(archive) as zf: + assert "_auth/manifest.json" in zf.namelist() + assert "_auth/authority.enc" in zf.namelist() + assert b"super-secret" not in zf.read("_auth/authority.enc") + + (backup_home / "auth.json").write_text('{"providers":{}}', encoding="utf-8") + (backup_home / "config.yaml").write_text( + "auth:\n authority: shared\nmarker: destination\n", encoding="utf-8" + ) + run_import( + SimpleNamespace( + zipfile=str(archive), + force=True, + clean=False, + auth_action="restore-shared", + auth_passphrase_file=str(passphrase), + ) + ) + restored = json.loads((backup_home / "auth.json").read_text()) + assert restored["providers"]["nous"]["access_token"] == "super-secret" + assert (backup_home / "auth.json").stat().st_mode & 0o777 == 0o600 + assert "marker: archived" in (backup_home / "config.yaml").read_text() + + +def test_pre_update_backup_excludes_all_auth_stores(backup_home): + from hermes_cli.backup import create_pre_update_backup + + profile = backup_home / "profiles" / "coder" + profile.mkdir(parents=True) + (profile / "auth.json").write_text('{"profile":"secret"}', encoding="utf-8") + + archive = create_pre_update_backup(backup_home) + + assert archive is not None + with zipfile.ZipFile(archive) as zf: + assert not any( + Path(name).name in {"auth.json", "auth.lock"} + for name in zf.namelist() + ) + + +def test_restore_rejects_archive_authority_topology_mismatch_before_writes( + backup_home, monkeypatch +): + import hermes_cli.backup as backup_mod + + passphrase = backup_home.parent / "passphrase-mismatch" + passphrase.write_text("correct horse battery staple", encoding="utf-8") + profile = backup_home / "profiles" / "source" + profile.mkdir(parents=True) + (profile / "config.yaml").write_text( + "auth:\n authority: profile\n", encoding="utf-8" + ) + (profile / "auth.json").write_text( + '{"providers":{"profile":{}}}', encoding="utf-8" + ) + monkeypatch.setenv("HERMES_HOME", str(profile)) + backup_mod.run_backup( + _backup_args( + backup_home, + auth_mode="include-encrypted", + auth_passphrase_file=str(passphrase), + ) + ) + archive = backup_home / "backups.zip" + + with pytest.raises(SystemExit): + backup_mod.run_import( + SimpleNamespace( + zipfile=str(archive), + force=True, + clean=False, + auth_action="restore-shared", + auth_passphrase_file=str(passphrase), + ) + ) + + assert json.loads((backup_home / "auth.json").read_text())["providers"]["nous"] + + +def test_auth_restore_rolls_back_target_when_config_commit_fails( + backup_home, monkeypatch +): + import hermes_cli.backup as backup_mod + + passphrase = backup_home.parent / "passphrase-rollback" + passphrase.write_text("correct horse battery staple", encoding="utf-8") + backup_mod.run_backup( + _backup_args( + backup_home, + auth_mode="include-encrypted", + auth_passphrase_file=str(passphrase), + ) + ) + archive = backup_home / "backups.zip" + old_auth = b'{"providers":{"old":{}}}' + (backup_home / "auth.json").write_bytes(old_auth) + old_config = b"auth:\n authority: shared\noperator_marker: destination\n" + (backup_home / "config.yaml").write_bytes(old_config) + real_write = getattr(backup_mod, "_atomic_private_write", None) + failed = False + + def fail_config(path, raw): + nonlocal failed + if Path(path).name == "config.yaml" and not failed: + failed = True + assert real_write is not None + real_write(path, raw) + raise OSError("forced config commit failure") + assert real_write is not None + return real_write(path, raw) + + monkeypatch.setattr( + backup_mod, "_atomic_private_write", fail_config, raising=False + ) + + with pytest.raises(SystemExit): + backup_mod.run_import( + SimpleNamespace( + zipfile=str(archive), + force=True, + clean=False, + auth_action="restore-shared", + auth_passphrase_file=str(passphrase), + ) + ) + + assert (backup_home / "auth.json").read_bytes() == old_auth + assert (backup_home / "config.yaml").read_bytes() == old_config + + +@pytest.mark.parametrize("failure_phase", ["planned", "backed_up", "auth_written"]) +def test_auth_restore_journal_recovers_interrupted_restore_by_rollback( + backup_home, failure_phase: str +): + import hermes_cli.backup as backup_mod + from hermes_cli.auth_authority import AuthAuthorityConfigError, resolve_auth_authority + + old_auth = (backup_home / "auth.json").read_bytes() + old_config = (backup_home / "config.yaml").read_bytes() + restored_auth = b'{"providers":{"restored":{"token":"new"}}}' + + def crash(phase: str) -> None: + if phase == failure_phase: + raise SystemExit(f"crashed at {phase}") + + with pytest.raises(SystemExit, match="crashed"): + backup_mod._restore_auth_transactionally( + restored_auth, + "restore-profile", + config_home=backup_home, + failure_injector=crash, + ) + + with pytest.raises(AuthAuthorityConfigError, match="incomplete restore"): + resolve_auth_authority(profile_home=backup_home, shared_root=backup_home) + + expected = "aborted" if failure_phase in {"planned", "backed_up"} else "rolled_back" + assert backup_mod._recover_incomplete_auth_restores() == [expected] + assert (backup_home / "auth.json").read_bytes() == old_auth + assert (backup_home / "config.yaml").read_bytes() == old_config + resolve_auth_authority(profile_home=backup_home, shared_root=backup_home) + + +def test_auth_restore_journal_commits_forward_after_both_writes_land(backup_home): + import hermes_cli.backup as backup_mod + + restored_auth = b'{"providers":{"restored":{"token":"new"}}}' + + def crash(phase: str) -> None: + if phase == "config_written": + raise SystemExit("crashed after config write") + + with pytest.raises(SystemExit, match="config write"): + backup_mod._restore_auth_transactionally( + restored_auth, + "restore-profile", + config_home=backup_home, + failure_injector=crash, + ) + + assert backup_mod._recover_incomplete_auth_restores() == ["committed"] + assert (backup_home / "auth.json").read_bytes() == restored_auth + assert "authority: profile" in (backup_home / "config.yaml").read_text() + journal_path = next( + (backup_home / "state-snapshots" / "auth-restores" / "journals").glob( + "*.json" + ) + ) + assert json.loads(journal_path.read_text())["phase"] == "committed" + + +def test_auth_restore_recovery_preserves_unrecognized_external_change(backup_home): + import hermes_cli.backup as backup_mod + + restored_auth = b'{"providers":{"restored":{"token":"new"}}}' + + def crash(phase: str) -> None: + if phase == "auth_written": + raise SystemExit("crashed after auth write") + + with pytest.raises(SystemExit): + backup_mod._restore_auth_transactionally( + restored_auth, + "restore-shared", + config_home=backup_home, + failure_injector=crash, + ) + external = b'{"providers":{"external":{"token":"preserve"}}}' + (backup_home / "auth.json").write_bytes(external) + + with pytest.raises(RuntimeError, match="manual recovery required"): + backup_mod._recover_incomplete_auth_restores() + + assert (backup_home / "auth.json").read_bytes() == external + journal_path = next( + (backup_home / "state-snapshots" / "auth-restores" / "journals").glob( + "*.json" + ) + ) + journal = json.loads(journal_path.read_text()) + assert journal["phase"] == "manual_required" + assert journal["reason"] == "restore_state_changed" + + +def test_quick_auth_restore_requires_explicit_action_before_any_write( + backup_home, tmp_path +): + import hermes_cli.backup as backup_mod + + passphrase = tmp_path / "quick-passphrase" + passphrase.write_text("correct horse battery staple", encoding="utf-8") + (backup_home / "config.yaml").write_text( + "auth:\n authority: shared\nmarker: snapshot\n", encoding="utf-8" + ) + snapshot_id = backup_mod.create_quick_snapshot( + label="auth-contract", + hermes_home=backup_home, + auth_mode="include-encrypted", + auth_passphrase_file=str(passphrase), + ) + assert snapshot_id is not None + destination = b"auth:\n authority: shared\nmarker: destination\n" + (backup_home / "config.yaml").write_bytes(destination) + + assert backup_mod.restore_quick_snapshot( + snapshot_id, + hermes_home=backup_home, + include_auth=True, + auth_passphrase_file=str(passphrase), + ) is False + assert (backup_home / "config.yaml").read_bytes() == destination + + +def test_quick_auth_restore_rolls_back_destination_config_on_commit_failure( + backup_home, tmp_path, monkeypatch +): + import hermes_cli.backup as backup_mod + + passphrase = tmp_path / "quick-rollback-passphrase" + passphrase.write_text("correct horse battery staple", encoding="utf-8") + (backup_home / "config.yaml").write_text( + "auth:\n authority: shared\nmarker: snapshot\n", encoding="utf-8" + ) + snapshot_id = backup_mod.create_quick_snapshot( + label="auth-rollback", + hermes_home=backup_home, + auth_mode="include-encrypted", + auth_passphrase_file=str(passphrase), + ) + assert snapshot_id is not None + + old_auth = b'{"providers":{"old":{}}}' + old_config = b"auth:\n authority: shared\nmarker: destination\n" + (backup_home / "auth.json").write_bytes(old_auth) + (backup_home / "config.yaml").write_bytes(old_config) + real_write = backup_mod._atomic_private_write + failed = False + + def fail_config(path, raw): + nonlocal failed + real_write(path, raw) + if Path(path).name == "config.yaml" and not failed: + failed = True + raise OSError("forced config commit failure") + + monkeypatch.setattr(backup_mod, "_atomic_private_write", fail_config) + + assert backup_mod.restore_quick_snapshot( + snapshot_id, + hermes_home=backup_home, + include_auth=True, + auth_action="restore-shared", + auth_passphrase_file=str(passphrase), + ) is False + assert (backup_home / "auth.json").read_bytes() == old_auth + assert (backup_home / "config.yaml").read_bytes() == old_config + + +def test_quick_auth_restore_rejects_live_gateway_before_any_write( + backup_home, tmp_path, monkeypatch +): + import hermes_cli.backup as backup_mod + import gateway.status as gateway_status + + passphrase = tmp_path / "quick-live-passphrase" + passphrase.write_text("correct horse battery staple", encoding="utf-8") + (backup_home / "config.yaml").write_text( + "auth:\n authority: shared\nmarker: snapshot\n", encoding="utf-8" + ) + snapshot_id = backup_mod.create_quick_snapshot( + label="live-gateway", + hermes_home=backup_home, + auth_mode="include-encrypted", + auth_passphrase_file=str(passphrase), + ) + assert snapshot_id is not None + destination = b"auth:\n authority: shared\nmarker: destination\n" + (backup_home / "config.yaml").write_bytes(destination) + monkeypatch.setattr( + gateway_status, + "get_running_pid", + lambda *_args, **_kwargs: os.getpid(), + ) + + assert backup_mod.restore_quick_snapshot( + snapshot_id, + hermes_home=backup_home, + include_auth=True, + auth_action="restore-shared", + auth_passphrase_file=str(passphrase), + ) is False + assert (backup_home / "config.yaml").read_bytes() == destination + + +def test_full_restore_refuses_running_shared_gateway_before_any_write( + backup_home, monkeypatch +): + import hermes_cli.backup as backup_mod + from gateway import status as gateway_status + + passphrase = backup_home.parent / "passphrase-running" + passphrase.write_text("correct horse battery staple", encoding="utf-8") + (backup_home / "MEMORY.md").write_text("archive value", encoding="utf-8") + backup_mod.run_backup( + _backup_args( + backup_home, + auth_mode="include-encrypted", + auth_passphrase_file=str(passphrase), + ) + ) + archive = backup_home / "backups.zip" + (backup_home / "MEMORY.md").write_text("destination value", encoding="utf-8") + monkeypatch.setattr( + gateway_status, + "get_running_pid", + lambda *_args, **_kwargs: os.getpid(), + ) + + with pytest.raises(SystemExit): + backup_mod.run_import( + SimpleNamespace( + zipfile=str(archive), + force=True, + clean=False, + auth_action="restore-shared", + auth_passphrase_file=str(passphrase), + ) + ) + + assert (backup_home / "MEMORY.md").read_text() == "destination value" + + +def test_auth_restore_rechecks_quiescence_under_transition_gate_before_mutation( + backup_home, monkeypatch +): + import hermes_cli.auth as auth_mod + import hermes_cli.backup as backup_mod + + passphrase = backup_home.parent / "passphrase-racing-gateway" + passphrase.write_text("correct horse battery staple", encoding="utf-8") + backup_mod.run_backup( + _backup_args( + backup_home, + auth_mode="include-encrypted", + auth_passphrase_file=str(passphrase), + ) + ) + archive = backup_home / "backups.zip" + old_auth = b'{"providers":{"destination":{}}}' + old_config = b"auth:\n authority: shared\nmarker: destination\n" + (backup_home / "auth.json").write_bytes(old_auth) + (backup_home / "config.yaml").write_bytes(old_config) + checks = 0 + store_lock_held = False + real_store_locks = auth_mod._auth_store_locks + + @contextmanager + def tracked_store_locks(*args, **kwargs): + nonlocal store_lock_held + with real_store_locks(*args, **kwargs) as locked: + store_lock_held = True + try: + yield locked + finally: + store_lock_held = False + + monkeypatch.setattr(auth_mod, "_auth_store_locks", tracked_store_locks) + + def gateway_starts_after_preflight(_home, _auth_action): + nonlocal checks + checks += 1 + if checks == 2: + assert store_lock_held + _assert_transition_gate_held(auth_mod) + raise RuntimeError("gateway started after preflight") + + monkeypatch.setattr( + backup_mod, + "_assert_auth_restore_quiescent", + gateway_starts_after_preflight, + ) + + with pytest.raises(SystemExit): + backup_mod.run_import( + SimpleNamespace( + zipfile=str(archive), + force=True, + clean=False, + auth_action="restore-shared", + auth_passphrase_file=str(passphrase), + ) + ) + + assert checks == 2 + assert (backup_home / "auth.json").read_bytes() == old_auth + assert (backup_home / "config.yaml").read_bytes() == old_config + + +@pytest.mark.parametrize( + "malformed_store", + [ + {"providers": [], "credential_pool": {}}, + {"providers": {}, "credential_pool": []}, + ], +) +def test_encrypted_restore_rejects_malformed_auth_sections_before_writes( + backup_home, malformed_store +): + import hermes_cli.backup as backup_mod + + passphrase = backup_home.parent / "passphrase-malformed-sections" + passphrase.write_text("correct horse battery staple", encoding="utf-8") + (backup_home / "auth.json").write_text( + json.dumps(malformed_store), encoding="utf-8" + ) + (backup_home / "MEMORY.md").write_text("archive value", encoding="utf-8") + backup_mod.run_backup( + _backup_args( + backup_home, + auth_mode="include-encrypted", + auth_passphrase_file=str(passphrase), + ) + ) + archive = backup_home / "backups.zip" + old_auth = b'{"providers":{"destination":{}}}' + old_config = b"auth:\n authority: shared\nmarker: destination\n" + (backup_home / "auth.json").write_bytes(old_auth) + (backup_home / "config.yaml").write_bytes(old_config) + (backup_home / "MEMORY.md").write_text("destination value", encoding="utf-8") + + with pytest.raises(SystemExit): + backup_mod.run_import( + SimpleNamespace( + zipfile=str(archive), + force=True, + clean=False, + auth_action="restore-shared", + auth_passphrase_file=str(passphrase), + ) + ) + + assert (backup_home / "auth.json").read_bytes() == old_auth + assert (backup_home / "config.yaml").read_bytes() == old_config + assert (backup_home / "MEMORY.md").read_text() == "destination value" + + +def test_full_backup_wrong_passphrase_and_legacy_auth_fail_closed( + backup_home, tmp_path +): + from hermes_cli.backup import run_backup, run_import + + good = backup_home.parent / "good-passphrase" + good.write_text("correct horse battery staple", encoding="utf-8") + bad = backup_home.parent / "bad-passphrase" + bad.write_text("wrong", encoding="utf-8") + run_backup( + _backup_args( + backup_home, + auth_mode="include-encrypted", + auth_passphrase_file=str(good), + ) + ) + archive = backup_home / "backups.zip" + with pytest.raises(SystemExit): + run_import( + SimpleNamespace( + zipfile=str(archive), + force=True, + clean=False, + auth_action="restore-shared", + auth_passphrase_file=str(bad), + ) + ) + + legacy = tmp_path / "legacy.zip" + with zipfile.ZipFile(legacy, "w") as zf: + zf.writestr(".hermes/config.yaml", "model: {}\n") + zf.writestr(".hermes/auth.json", '{"providers":{}}') + with pytest.raises(SystemExit): + run_import( + SimpleNamespace( + zipfile=str(legacy), + force=True, + clean=False, + auth_action="skip", + auth_passphrase_file=None, + ) + ) + + +def test_auth_restore_recovery_validates_all_backups_before_mutating_targets( + backup_home, +): + import hermes_cli.backup as backup_mod + + restored_auth = b'{"providers":{"restored":{"token":"new"}}}' + + def crash(phase: str) -> None: + if phase == "auth_written": + raise SystemExit("simulated process death") + + with pytest.raises(SystemExit, match="simulated process death"): + backup_mod._restore_auth_transactionally( + restored_auth, + "restore-profile", + config_home=backup_home, + failure_injector=crash, + ) + + journal_path = next( + (backup_home / "state-snapshots" / "auth-restores" / "journals").glob( + "*.json" + ) + ) + journal = json.loads(journal_path.read_text(encoding="utf-8")) + config_before_recovery = (backup_home / "config.yaml").read_bytes() + auth_before_recovery = (backup_home / "auth.json").read_bytes() + (Path(journal["current_dir"]) / "config.yaml").write_bytes(b"corrupt backup") + + with pytest.raises(RuntimeError, match="backup verification failed"): + backup_mod._recover_incomplete_auth_restores() + + assert (backup_home / "auth.json").read_bytes() == auth_before_recovery + assert (backup_home / "config.yaml").read_bytes() == config_before_recovery + assert json.loads(journal_path.read_text(encoding="utf-8"))["phase"] == ( + "manual_required" + ) + + +def test_auth_restore_holds_transition_gate_through_target_write( + backup_home, monkeypatch +): + import hermes_cli.auth as auth_mod + import hermes_cli.backup as backup_mod + + restored_auth = b'{"providers":{"restored":{"token":"new"}}}' + real_write = backup_mod._atomic_private_write + competing_result: list[str] = [] + checked = False + + def checked_write(path: Path, raw: bytes) -> None: + nonlocal checked + if Path(path) == backup_home / "auth.json" and not checked: + checked = True + + def compete() -> None: + try: + with auth_mod._auth_transition_lock(timeout_seconds=0.1): + competing_result.append("acquired") + except TimeoutError: + competing_result.append("blocked") + + thread = threading.Thread(target=compete) + thread.start() + thread.join(timeout=2) + assert not thread.is_alive() + real_write(path, raw) + + monkeypatch.setattr(backup_mod, "_atomic_private_write", checked_write) + + backup_mod._restore_auth_transactionally( + restored_auth, + "restore-profile", + config_home=backup_home, + ) + + assert competing_result == ["blocked"] + + +def test_encrypted_backup_snapshot_serializes_against_auth_writer( + backup_home, monkeypatch +): + import hermes_cli.auth as auth_mod + import hermes_cli.backup as backup_mod + + passphrase = backup_home.parent / "passphrase-concurrent-writer" + passphrase.write_text("correct horse battery staple", encoding="utf-8") + before = b'{"providers":{"nous":{"generation":"before-backup"}}}' + (backup_home / "auth.json").write_bytes(before) + writer_timed_out = threading.Event() + retry_writer = threading.Event() + writer_finished = threading.Event() + captured: list[bytes] = [] + + def writer() -> None: + try: + with auth_mod._auth_store_lock(timeout_seconds=0.1): + pytest.fail("writer acquired auth lock during encrypted snapshot") + except TimeoutError: + writer_timed_out.set() + assert retry_writer.wait(5) + with auth_mod._auth_store_lock(timeout_seconds=2): + store = auth_mod._load_auth_store() + store["providers"]["nous"]["generation"] = "after-backup" + auth_mod._save_auth_store(store) + writer_finished.set() + + worker = threading.Thread(target=writer) + + def controlled_encrypt(raw: bytes, _passphrase: str): + captured.append(raw) + worker.start() + assert writer_timed_out.wait(3) + return b"test-encrypted-envelope", {"version": 2, "sha256": "test"} + + monkeypatch.setattr(backup_mod, "_encrypt_auth", controlled_encrypt) + backup_mod.run_backup( + _backup_args( + backup_home, + auth_mode="include-encrypted", + auth_passphrase_file=str(passphrase), + ) + ) + retry_writer.set() + worker.join(5) + + assert not worker.is_alive() + assert writer_finished.is_set() + assert captured == [before] + final = json.loads((backup_home / "auth.json").read_text(encoding="utf-8")) + assert final["providers"]["nous"]["generation"] == "after-backup" + with zipfile.ZipFile(backup_home / "backups.zip") as zf: + assert zf.read("_auth/authority.enc") == b"test-encrypted-envelope" diff --git a/tests/hermes_cli/test_profile_auth_lifecycle.py b/tests/hermes_cli/test_profile_auth_lifecycle.py new file mode 100644 index 000000000000..36955a31ead8 --- /dev/null +++ b/tests/hermes_cli/test_profile_auth_lifecycle.py @@ -0,0 +1,1109 @@ +from __future__ import annotations + +import json +import os +import stat +import threading +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml + + +@pytest.fixture +def profile_root(tmp_path, monkeypatch): + root = tmp_path / ".hermes" + root.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(root)) + return root + + +def test_clone_profile_creates_empty_local_authority_without_oauth_fork(profile_root): + from hermes_cli.profiles import create_profile + + source = { + "providers": { + "openai-codex": { + "access_token": "shared-access", + "refresh_token": "single-use-refresh", + } + } + } + (profile_root / "auth.json").write_text(json.dumps(source), encoding="utf-8") + + local = create_profile( + "local", + clone_from="default", + auth_mode="profile", + no_alias=True, + ) + local_auth = json.loads((local / "auth.json").read_text()) + assert local_auth["providers"] == {} + assert "credential_pool" not in local_auth + assert "single-use-refresh" not in (local / "auth.json").read_text() + assert (local / "auth.json").stat().st_mode & 0o777 == 0o600 + assert yaml.safe_load((local / "config.yaml").read_text())["auth"]["authority"] == "profile" + + shared = create_profile( + "shared", + clone_from="default", + auth_mode="shared", + no_alias=True, + ) + assert not (shared / "auth.json").exists() + assert yaml.safe_load((shared / "config.yaml").read_text())["auth"]["authority"] == "shared" + + +@pytest.mark.parametrize("auth_mode", ["shared", "profile"]) +def test_clone_all_sets_requested_auth_authority(profile_root, auth_mode): + from hermes_cli.profiles import create_profile + + (profile_root / "config.yaml").write_text( + "display:\n skin: mono\n", encoding="utf-8" + ) + + cloned = create_profile( + f"clone-{auth_mode}", + clone_from="default", + clone_all=True, + auth_mode=auth_mode, + no_alias=True, + ) + + config = yaml.safe_load((cloned / "config.yaml").read_text(encoding="utf-8")) + assert config["display"]["skin"] == "mono" + assert config["auth"]["authority"] == auth_mode + assert (cloned / "auth.json").is_file() is (auth_mode == "profile") + + +@pytest.mark.parametrize("auth_mode", ["shared", "profile"]) +def test_clone_all_rejects_symlinked_config_without_external_mutation( + profile_root, tmp_path, auth_mode +): + from hermes_cli.profiles import create_profile + + outside = tmp_path / "outside" + outside.mkdir() + sentinel = outside / "external-config.yaml" + sentinel_content = b"external: sentinel\n" + sentinel.write_bytes(sentinel_content) + sentinel.chmod(0o640) + (profile_root / "config.yaml").symlink_to(sentinel) + target = profile_root / "profiles" / f"escaped-{auth_mode}" + + error = None + try: + create_profile( + f"escaped-{auth_mode}", + clone_from="default", + clone_all=True, + auth_mode=auth_mode, + no_alias=True, + ) + except Exception as exc: # assertions below verify the fail-closed contract + error = exc + + assert sentinel.read_bytes() == sentinel_content + assert stat.S_IMODE(sentinel.stat().st_mode) == 0o640 + assert list(outside.iterdir()) == [sentinel] + assert (profile_root / "config.yaml").is_symlink() + assert not os.path.lexists(target) + assert isinstance(error, ValueError) + assert "regular file" in str(error) + + +def test_auth_injection_rejects_replaced_profile_directory( + profile_root, tmp_path, monkeypatch +): + import hermes_cli.profiles as profiles + + target = profile_root / "profiles" / "raced" + saved_created = profile_root / "profiles" / ".raced-created" + replacement = tmp_path / "replacement" + replacement.mkdir() + sentinel = replacement / "config.yaml" + sentinel_content = b"external: sentinel\n" + sentinel.write_bytes(sentinel_content) + sentinel.chmod(0o640) + + real_open = profiles.os.open + swapped = False + raced_path = None + + def swap_before_directory_open(path, flags, *args, dir_fd=None, **kwargs): + nonlocal raced_path, swapped + path_name = Path(path).name + opens_target = ( + path_name == "profile" + and Path(path).parent.name.startswith(".raced.create-") + and flags & getattr(profiles.os, "O_DIRECTORY", 0) + ) + if not swapped and opens_target: + swapped = True + raced_path = Path(path) + profiles.os.rename(raced_path, saved_created) + profiles.os.rename(replacement, raced_path) + return real_open(path, flags, *args, dir_fd=dir_fd, **kwargs) + + supported_dir_fd = set(profiles.os.supports_dir_fd) + supported_dir_fd.discard(real_open) + supported_dir_fd.add(swap_before_directory_open) + monkeypatch.setattr(profiles.os, "open", swap_before_directory_open) + monkeypatch.setattr(profiles.os, "supports_dir_fd", supported_dir_fd) + + with pytest.raises((ValueError, RuntimeError), match="identity|changed|refusing"): + profiles.create_profile("raced", auth_mode="shared", no_alias=True) + + assert swapped + assert raced_path is not None + assert (raced_path / "config.yaml").read_bytes() == sentinel_content + assert stat.S_IMODE((raced_path / "config.yaml").stat().st_mode) == 0o640 + assert not (raced_path / ".env").exists() + assert not os.path.lexists(target) + assert saved_created.is_dir() + + +def test_profile_identity_remains_bound_after_authority_injection( + profile_root, tmp_path, monkeypatch +): + import hermes_cli.profiles as profiles + + target = profile_root / "profiles" / "post-authority-race" + saved_created = profile_root / "profiles" / ".post-authority-created" + replacement = tmp_path / "post-authority-replacement" + replacement.mkdir() + sentinel = replacement / "sentinel.txt" + sentinel.write_text("external sentinel", encoding="utf-8") + sentinel.chmod(0o640) + + real_write_authority = profiles._write_profile_auth_authority + swapped = False + + def swap_after_authority(*args, **kwargs): + nonlocal swapped + result = real_write_authority(*args, **kwargs) + profile_path = Path(args[0]) + profiles.os.rename(profile_path, saved_created) + profiles.os.rename(replacement, profile_path) + swapped = True + return result + + monkeypatch.setattr( + profiles, "_write_profile_auth_authority", swap_after_authority + ) + + with pytest.raises( + (FileExistsError, ValueError, RuntimeError), + match="exists|identity|changed|refusing", + ): + profiles.create_profile( + "post-authority-race", + auth_mode="profile", + description="must stay transaction-local", + no_alias=True, + ) + + assert swapped + raced_path = next( + (profile_root / "profiles").glob(".post-authority-race.create-*/profile") + ) + assert (raced_path / "sentinel.txt").read_text(encoding="utf-8") == "external sentinel" + assert stat.S_IMODE((raced_path / "sentinel.txt").stat().st_mode) == 0o640 + assert {entry.name for entry in raced_path.iterdir()} == {"sentinel.txt"} + assert not os.path.lexists(target) + assert saved_created.is_dir() + + +def test_pre_authority_profile_swap_cannot_redirect_generated_writes( + profile_root, tmp_path, monkeypatch +): + import hermes_cli.profiles as profiles + + target = profile_root / "profiles" / "pre-authority-race" + saved_created = profile_root / "profiles" / ".pre-authority-created" + replacement = tmp_path / "pre-authority-replacement" + replacement.mkdir() + sentinel = replacement / "sentinel.txt" + sentinel.write_text("external sentinel", encoding="utf-8") + sentinel.chmod(0o640) + before = {entry.name for entry in replacement.iterdir()} + + real_populate = profiles._populate_staged_profile + swapped_path = None + + def swap_after_population(profile_dir, *args, **kwargs): + nonlocal swapped_path + result = real_populate(profile_dir, *args, **kwargs) + swapped_path = Path(profile_dir) + profiles.os.rename(swapped_path, saved_created) + profiles.os.rename(replacement, swapped_path) + return result + + monkeypatch.setattr(profiles, "_populate_staged_profile", swap_after_population) + + with pytest.raises((ValueError, RuntimeError), match="identity|changed|refusing"): + profiles.create_profile( + "pre-authority-race", + auth_mode="profile", + description="must stay transaction-local", + no_alias=True, + ) + + assert swapped_path is not None + assert {entry.name for entry in swapped_path.iterdir()} == before + moved_sentinel = swapped_path / sentinel.name + assert moved_sentinel.read_text(encoding="utf-8") == "external sentinel" + assert stat.S_IMODE(moved_sentinel.stat().st_mode) == 0o640 + assert saved_created.is_dir() + + +def test_clone_all_rejects_dangling_env_symlink_without_external_creation( + profile_root, tmp_path +): + import hermes_cli.profiles as profiles + + outside = tmp_path / "outside-env" + outside.mkdir() + external_env = outside / "created-through-symlink.env" + (profile_root / ".env").symlink_to(external_env) + + with pytest.raises(ValueError, match=r"\.env.*regular file"): + profiles.create_profile( + "dangling-env", + clone_from="default", + clone_all=True, + auth_mode="shared", + no_alias=True, + ) + + assert not external_env.exists() + assert not os.path.lexists(profile_root / "profiles" / "dangling-env") + + +def test_profile_creation_never_replaces_a_racing_final_directory( + profile_root, monkeypatch +): + import hermes_cli.profiles as profiles + + target = profile_root / "profiles" / "publication-race" + real_noreplace = profiles._rename_directory_noreplace + injected = False + + def collide_with_publication(source, destination, **kwargs): + nonlocal injected + destination = Path(destination) + if not injected and destination.name == target.name: + target.mkdir() + (target / "sentinel.txt").write_text( + "external sentinel", encoding="utf-8" + ) + injected = True + return real_noreplace(Path(source), destination, **kwargs) + + monkeypatch.setattr( + profiles, "_rename_directory_noreplace", collide_with_publication + ) + + with pytest.raises(FileExistsError, match="already exists"): + profiles.create_profile("publication-race", auth_mode="profile", no_alias=True) + + assert injected + assert (target / "sentinel.txt").read_text(encoding="utf-8") == "external sentinel" + assert not (target / ".env").exists() + assert not (target / "SOUL.md").exists() + assert not (target / "auth.json").exists() + assert not (target / "profile.json").exists() + + +@pytest.mark.skipif(os.name != "posix", reason="uses POSIX descriptor paths") +def test_publication_source_substitution_is_restored_not_committed( + profile_root, tmp_path, monkeypatch +): + import hermes_cli.profiles as profiles + + target = profile_root / "profiles" / "source-race" + replacement = tmp_path / "source-replacement" + replacement.mkdir() + sentinel = replacement / "sentinel.txt" + sentinel.write_text("external sentinel", encoding="utf-8") + real_noreplace = profiles._rename_directory_noreplace + raced_source = None + saved_created = None + + def substitute_source(source, destination, **kwargs): + nonlocal raced_source, saved_created + source = Path(source) + destination = Path(destination) + source_parent_fd = kwargs.get("source_parent_fd") + if ( + raced_source is None + and destination.name == target.name + and source_parent_fd is not None + ): + parent = Path(os.readlink(f"/proc/self/fd/{source_parent_fd}")) + raced_source = parent / source.name + saved_created = parent / "saved-created" + profiles.os.rename(raced_source, saved_created) + profiles.os.rename(replacement, raced_source) + return real_noreplace(source, destination, **kwargs) + + monkeypatch.setattr( + profiles, "_rename_directory_noreplace", substitute_source + ) + + with pytest.raises(ValueError, match="published profile directory identity changed"): + profiles.create_profile("source-race", auth_mode="shared", no_alias=True) + + assert raced_source is not None + assert saved_created is not None and saved_created.is_dir() + assert not os.path.lexists(target) + assert (raced_source / sentinel.name).read_text(encoding="utf-8") == "external sentinel" + + +@pytest.mark.skipif(os.name != "posix", reason="uses POSIX dir_fd publication") +def test_post_publication_verification_failure_restores_before_error( + profile_root, monkeypatch +): + import hermes_cli.profiles as profiles + + target = profile_root / "profiles" / "verify-failure" + real_open = profiles.os.open + injected = False + + def fail_published_open(path, flags, *args, dir_fd=None, **kwargs): + nonlocal injected + if not injected and dir_fd is not None and Path(path).name == target.name: + injected = True + raise OSError("forced post-publication verification failure") + return real_open(path, flags, *args, dir_fd=dir_fd, **kwargs) + + supported_dir_fd = set(profiles.os.supports_dir_fd) + supported_dir_fd.discard(real_open) + supported_dir_fd.add(fail_published_open) + monkeypatch.setattr(profiles.os, "open", fail_published_open) + monkeypatch.setattr(profiles.os, "supports_dir_fd", supported_dir_fd) + + with pytest.raises(OSError, match="forced post-publication verification failure"): + profiles.create_profile("verify-failure", auth_mode="shared", no_alias=True) + + assert injected + assert not os.path.lexists(target) + containers = list((profile_root / "profiles").glob(".verify-failure.create-*")) + assert len(containers) == 1 + assert list(containers[0].glob(".profile.rollback-*")) + + +def test_portable_authority_injection_fails_closed_on_directory_swap( + profile_root, tmp_path, monkeypatch +): + import hermes_cli.profiles as profiles + + fixed_time_ns = 4242 + target = profile_root / "profiles" / "portable-race" + saved_created = profile_root / "profiles" / ".portable-created" + replacement = tmp_path / "portable-replacement" + replacement.mkdir() + sentinel = replacement / "config.yaml" + sentinel_content = b"external: sentinel\n" + sentinel.write_bytes(sentinel_content) + sentinel.chmod(0o640) + staged_name = f".config.yaml.{os.getpid()}.{fixed_time_ns}.tmp" + (replacement / staged_name).write_text("attacker: staged\n", encoding="utf-8") + + real_replace = profiles.os.replace + swapped = False + + def swap_inside_replace(source, destination, *args, **kwargs): + nonlocal swapped + if not swapped and Path(destination).name == "config.yaml": + swapped = True + profiles.os.rename(target, saved_created) + profiles.os.rename(replacement, target) + return real_replace(source, destination, *args, **kwargs) + + monkeypatch.setattr(profiles.os, "supports_dir_fd", set()) + monkeypatch.setattr(profiles.time, "time_ns", lambda: fixed_time_ns) + monkeypatch.setattr(profiles.os, "replace", swap_inside_replace) + + error = None + try: + profiles.create_profile("portable-race", auth_mode="shared", no_alias=True) + except Exception as exc: # assertions below verify the fail-closed contract + error = exc + + assert isinstance(error, RuntimeError) + assert not swapped + assert sentinel.read_bytes() == sentinel_content + assert stat.S_IMODE(sentinel.stat().st_mode) == 0o640 + assert not os.path.lexists(target) + + +def test_supported_portable_profile_creation_writes_authority( + profile_root, monkeypatch +): + import contextlib + import ctypes + import hermes_cli.profiles as profiles + + @contextlib.contextmanager + def stable_portable_directory(_profile_dir, _created_identity): + yield + + def portable_regular_file(path, _identity): + return Path(path).read_bytes() + + class FakeMoveFile: + argtypes = None + restype = None + + def __call__(self, source, destination, flags): + assert flags == 0 + os.rename(source, destination) + return 1 + + class FakeKernel32: + MoveFileExW = FakeMoveFile() + + monkeypatch.setattr(profiles.os, "supports_dir_fd", set()) + monkeypatch.setattr(profiles, "_IS_WINDOWS", True, raising=False) + monkeypatch.setattr( + ctypes, "WinDLL", lambda *_args, **_kwargs: FakeKernel32(), raising=False + ) + monkeypatch.setattr( + profiles, + "_windows_profile_directory_guard", + stable_portable_directory, + raising=False, + ) + monkeypatch.setattr( + profiles, + "_read_windows_regular_file_no_follow", + portable_regular_file, + ) + (profile_root / "config.yaml").write_text( + "display:\n skin: mono\n", encoding="utf-8" + ) + + created = profiles.create_profile( + "portable-supported", + clone_config=True, + auth_mode="shared", + description="portable metadata", + no_alias=True, + ) + + raw = yaml.safe_load((created / "config.yaml").read_text(encoding="utf-8")) + assert raw["display"]["skin"] == "mono" + assert raw["auth"]["authority"] == "shared" + metadata = yaml.safe_load((created / "profile.yaml").read_text(encoding="utf-8")) + assert metadata == { + "description": "portable metadata", + "description_auto": False, + } + + +def test_windows_portable_handles_deny_delete_sharing_and_open_reparse_points( + profile_root, monkeypatch +): + import ctypes + import sys + from types import SimpleNamespace + import hermes_cli.profiles as profiles + + calls = [] + + class FakeFunction: + argtypes = None + restype = None + + def __init__(self, callback): + self.callback = callback + + def __call__(self, *args): + return self.callback(*args) + + def create_file(path, access, share, _security, _creation, flags, _template): + calls.append((Path(path), access, share, flags)) + return os.open(path, os.O_RDONLY) + + class FakeKernel32: + CreateFileW = FakeFunction(create_file) + CloseHandle = FakeFunction(lambda handle: (os.close(handle), 1)[1]) + + monkeypatch.setattr(profiles, "_IS_WINDOWS", True, raising=False) + monkeypatch.setattr( + ctypes, "WinDLL", lambda *_args, **_kwargs: FakeKernel32(), raising=False + ) + monkeypatch.setitem( + sys.modules, + "msvcrt", + SimpleNamespace(open_osfhandle=lambda handle, _flags: handle), + ) + + staged = profile_root / "profiles" / ".portable-handles.create-test" + staged.mkdir(parents=True) + config = staged / "config.yaml" + config.write_bytes(b"display:\n skin: mono\n") + staged_stat = staged.lstat() + config_stat = config.lstat() + + directory_guard = getattr(profiles, "_windows_profile_directory_guard") + secure_read = getattr(profiles, "_read_windows_regular_file_no_follow") + with directory_guard( + staged, (staged_stat.st_dev, staged_stat.st_ino) + ): + assert secure_read( + config, (config_stat.st_dev, config_stat.st_ino) + ).startswith(b"display:") + + directory_call, file_call = calls + assert directory_call[2] == 0x00000001 | 0x00000002 + assert directory_call[3] & 0x02000000 # FILE_FLAG_BACKUP_SEMANTICS + assert directory_call[3] & 0x00200000 # FILE_FLAG_OPEN_REPARSE_POINT + assert file_call[2] == 0x00000001 | 0x00000002 + assert file_call[3] == 0x00200000 # FILE_FLAG_OPEN_REPARSE_POINT + + +def test_rollback_never_recursively_deletes_replacement_directory( + profile_root, tmp_path, monkeypatch +): + import hermes_cli.profiles as profiles + + target = profile_root / "profiles" / "rollback-race" + saved_created = profile_root / "profiles" / ".rollback-created" + replacement = tmp_path / "rollback-replacement" + replacement.mkdir() + sentinel = replacement / "do-not-delete.txt" + sentinel.write_text("external sentinel", encoding="utf-8") + + def fail_authority_write(*_args, **_kwargs): + raise ValueError("forced authority failure") + + real_rename = profiles.os.rename + real_rmtree = profiles.shutil.rmtree + real_noreplace = getattr(profiles, "_rename_directory_noreplace") + swapped = False + + swapped_path = None + + def swap_once(path: Path) -> None: + nonlocal swapped, swapped_path + if swapped: + return + swapped = True + swapped_path = path + real_rename(path, saved_created) + real_rename(replacement, path) + + def swap_before_quarantine(source, destination): + if ".rollback-" in Path(destination).name: + swap_once(Path(source)) + return real_noreplace(Path(source), Path(destination)) + + def swap_before_legacy_rmtree(path, *args, **kwargs): + if Path(path).name.startswith(".rollback-race.create-"): + swap_once(Path(path)) + return real_rmtree(path, *args, **kwargs) + + monkeypatch.setattr(profiles, "_write_profile_auth_authority", fail_authority_write) + monkeypatch.setattr( + profiles, "_rename_directory_noreplace", swap_before_quarantine + ) + monkeypatch.setattr(profiles.shutil, "rmtree", swap_before_legacy_rmtree) + + with pytest.raises(ValueError, match="forced authority failure"): + profiles.create_profile("rollback-race", auth_mode="shared", no_alias=True) + + assert swapped + assert swapped_path is not None + assert (swapped_path / sentinel.name).read_text(encoding="utf-8") == "external sentinel" + assert not os.path.lexists(target) + assert saved_created.is_dir(), "unsafe cleanup must retain the transaction inode" + + +def test_rollback_quarantine_publication_never_replaces_a_racing_directory( + profile_root, monkeypatch +): + import hermes_cli.profiles as profiles + + real_noreplace = getattr(profiles, "_rename_directory_noreplace") + injected = None + + def fail_authority_write(*_args, **_kwargs): + raise ValueError("forced authority failure") + + def collide_with_quarantine(source, destination): + nonlocal injected + destination = Path(destination) + if injected is None and ".rollback-" in destination.name: + destination.mkdir() + injected = destination + return real_noreplace(Path(source), destination) + + monkeypatch.setattr(profiles, "_write_profile_auth_authority", fail_authority_write) + monkeypatch.setattr( + profiles, "_rename_directory_noreplace", collide_with_quarantine + ) + + with pytest.raises(ValueError, match="forced authority failure"): + profiles.create_profile( + "rollback-destination-race", auth_mode="shared", no_alias=True + ) + + assert injected is not None + assert injected.is_dir(), "no-replace quarantine must preserve a racing directory" + assert not os.path.lexists( + profile_root / "profiles" / "rollback-destination-race" + ) + + +def test_downstream_failure_quarantines_profile_out_of_creation_namespace( + profile_root, monkeypatch +): + import hermes_cli.profiles as profiles + + gateway_registrations = [] + + def fail_migration(_profile_dir): + raise ValueError("forced downstream migration failure") + + monkeypatch.setattr( + profiles, "_migrate_profile_config_if_outdated", fail_migration + ) + monkeypatch.setattr( + profiles, + "_maybe_register_gateway_service", + lambda name: gateway_registrations.append(name), + ) + + with pytest.raises(ValueError, match="forced downstream migration failure"): + profiles.create_profile("full-transaction", auth_mode="profile", no_alias=True) + + profiles_root = profile_root / "profiles" + assert not os.path.lexists(profiles_root / "full-transaction") + containers = list(profiles_root.glob(".full-transaction.create-*")) + assert len(containers) == 1 + assert not (containers[0] / "profile").exists() + assert list(containers[0].glob(".profile.rollback-*")) + assert gateway_registrations == [] + + +def test_postcommit_gateway_probe_failure_does_not_report_creation_failure( + profile_root, monkeypatch +): + import hermes_cli.profiles as profiles + + def fail_gateway_probe(_name): + raise OSError("forced post-commit gateway probe failure") + + monkeypatch.setattr(profiles, "_maybe_register_gateway_service", fail_gateway_probe) + + created = profiles.create_profile( + "postcommit-gateway", auth_mode="shared", no_alias=True + ) + + assert created == profile_root / "profiles" / "postcommit-gateway" + assert created.is_dir() + assert yaml.safe_load((created / "config.yaml").read_text())["auth"][ + "authority" + ] == "shared" + + +def test_posix_rollback_never_rmdirs_a_last_moment_replacement( + profile_root, tmp_path, monkeypatch +): + import hermes_cli.profiles as profiles + + target = profile_root / "profiles" / "posix-rmdir-race" + saved_created = profile_root / "profiles" / ".posix-rmdir-created" + replacement = tmp_path / "empty-external-directory" + replacement.mkdir() + real_rmdir = profiles.os.rmdir + real_rename = profiles.os.rename + swapped = False + + def fail_authority_write(*_args, **_kwargs): + raise ValueError("forced authority failure") + + def swap_inside_final_rmdir(path, *args, **kwargs): + nonlocal swapped + candidate = Path(path) + if not swapped and ".rollback-" in candidate.name: + swapped = True + real_rename(candidate, saved_created) + real_rename(replacement, candidate) + return real_rmdir(path, *args, **kwargs) + + monkeypatch.setattr(profiles, "_write_profile_auth_authority", fail_authority_write) + supported_dir_fd = set(profiles.os.supports_dir_fd) + supported_dir_fd.discard(real_rmdir) + supported_dir_fd.add(swap_inside_final_rmdir) + monkeypatch.setattr(profiles.os, "rmdir", swap_inside_final_rmdir) + monkeypatch.setattr(profiles.os, "supports_dir_fd", supported_dir_fd) + + with pytest.raises(ValueError, match="forced authority failure"): + profiles.create_profile("posix-rmdir-race", auth_mode="shared", no_alias=True) + + assert not swapped, "rollback must not issue a path-bound final rmdir" + assert replacement.is_dir() + + +def test_portable_rollback_never_rmtrees_a_replaced_profile( + profile_root, tmp_path, monkeypatch +): + import hermes_cli.profiles as profiles + + target = profile_root / "profiles" / "portable-rmtree-race" + saved_created = profile_root / "profiles" / ".portable-rmtree-created" + replacement = tmp_path / "portable-external-directory" + replacement.mkdir() + sentinel = replacement / "sentinel.txt" + sentinel.write_text("external sentinel", encoding="utf-8") + real_rmtree = profiles.shutil.rmtree + real_rename = profiles.os.rename + swapped = False + + def fail_authority_write(*_args, **_kwargs): + raise ValueError("forced authority failure") + + def swap_inside_rmtree(path, *args, **kwargs): + nonlocal swapped + candidate = Path(path) + if not swapped and ".rollback-" in candidate.name: + swapped = True + real_rename(candidate, saved_created) + real_rename(replacement, candidate) + return real_rmtree(path, *args, **kwargs) + + monkeypatch.setattr(profiles, "_write_profile_auth_authority", fail_authority_write) + monkeypatch.setattr(profiles.os, "supports_dir_fd", set()) + monkeypatch.setattr(profiles.shutil, "rmtree", swap_inside_rmtree) + + with pytest.raises( + (ValueError, RuntimeError), match="forced|secure directory|descriptor-bound" + ): + profiles.create_profile("portable-rmtree-race", auth_mode="shared", no_alias=True) + + assert not swapped, "rollback must not recursively delete by pathname" + assert sentinel.read_text(encoding="utf-8") == "external sentinel" + + +def test_clone_all_description_rejects_symlinked_profile_metadata( + profile_root, tmp_path +): + import hermes_cli.profiles as profiles + + external = tmp_path / "external-profile.yaml" + sentinel = b"description: external sentinel\n" + external.write_bytes(sentinel) + (profile_root / "profile.yaml").symlink_to(external) + + with pytest.raises(ValueError, match="profile.yaml.*regular file"): + profiles.create_profile( + "meta-symlink", + clone_all=True, + auth_mode="shared", + description="must not escape staging", + no_alias=True, + ) + + assert external.read_bytes() == sentinel + assert not os.path.lexists(profile_root / "profiles" / "meta-symlink") + + +@pytest.mark.parametrize("auth_mode", ["shared", "profile"]) +def test_clone_all_description_rejects_profile_metadata_swap_without_external_mutation( + profile_root, tmp_path, monkeypatch, auth_mode +): + import hermes_cli.profiles as profiles + + source_metadata = profile_root / "profile.yaml" + source_content = b"description: source profile\ndescription_auto: true\n" + source_metadata.write_bytes(source_content) + + outside = tmp_path / "outside-metadata" + outside.mkdir() + external = outside / "external-profile.yaml" + sentinel = b"description: external sentinel\n" + external.write_bytes(sentinel) + external.chmod(0o640) + outside_entries = {entry.name for entry in outside.iterdir()} + + real_write_profile_meta = profiles.write_profile_meta + swapped = False + + def swap_before_metadata_write(profile_dir, *args, **kwargs): + nonlocal swapped + metadata = Path(profile_dir) / "profile.yaml" + metadata.unlink() + metadata.symlink_to(external) + swapped = True + return real_write_profile_meta(profile_dir, *args, **kwargs) + + monkeypatch.setattr(profiles, "write_profile_meta", swap_before_metadata_write) + target = profile_root / "profiles" / f"metadata-race-{auth_mode}" + + with pytest.raises(ValueError, match="profile.yaml.*regular file|changed"): + profiles.create_profile( + f"metadata-race-{auth_mode}", + clone_from="default", + clone_all=True, + auth_mode=auth_mode, + description="transaction local", + no_alias=True, + ) + + assert swapped + assert external.read_bytes() == sentinel + assert stat.S_IMODE(external.stat().st_mode) == 0o640 + assert {entry.name for entry in outside.iterdir()} == outside_entries + assert source_metadata.read_bytes() == source_content + assert not source_metadata.is_symlink() + assert not os.path.lexists(target) + + +@pytest.mark.parametrize("auth_mode", ["shared", "profile"]) +def test_profile_metadata_swap_after_secure_write_is_not_published( + profile_root, tmp_path, monkeypatch, auth_mode +): + import hermes_cli.profiles as profiles + + outside = tmp_path / "outside-post-metadata" + outside.mkdir() + external = outside / "external-profile.yaml" + sentinel = b"description: external sentinel\n" + external.write_bytes(sentinel) + external.chmod(0o640) + outside_entries = {entry.name for entry in outside.iterdir()} + + real_write_authority = profiles._write_profile_auth_authority + swapped = False + + def swap_after_authority(profile_dir, *args, **kwargs): + nonlocal swapped + real_write_authority(profile_dir, *args, **kwargs) + metadata = Path(profile_dir) / "profile.yaml" + metadata.unlink() + metadata.symlink_to(external) + swapped = True + + monkeypatch.setattr( + profiles, "_write_profile_auth_authority", swap_after_authority + ) + target = profile_root / "profiles" / f"post-metadata-race-{auth_mode}" + + with pytest.raises(ValueError, match="profile.yaml.*regular file|changed"): + profiles.create_profile( + f"post-metadata-race-{auth_mode}", + auth_mode=auth_mode, + description="transaction local", + no_alias=True, + ) + + assert swapped + assert external.read_bytes() == sentinel + assert stat.S_IMODE(external.stat().st_mode) == 0o640 + assert {entry.name for entry in outside.iterdir()} == outside_entries + assert not os.path.lexists(target) + + +def test_clone_profile_during_source_rotation_never_copies_oauth_chain( + profile_root, monkeypatch +): + import hermes_cli.profiles as profiles + + source_config = profile_root / "config.yaml" + source_config.write_text("display:\n skin: mono\n", encoding="utf-8") + source_auth = profile_root / "auth.json" + source_auth.write_text( + json.dumps( + { + "providers": { + "openai-codex": { + "access_token": "access-before", + "refresh_token": "refresh-before", + } + } + } + ), + encoding="utf-8", + ) + + clone_started = threading.Event() + allow_clone = threading.Event() + real_copy2 = profiles.shutil.copy2 + + def pause_config_copy(source, destination, *args, **kwargs): + if Path(source) == source_config: + clone_started.set() + assert allow_clone.wait(timeout=5) + return real_copy2(source, destination, *args, **kwargs) + + monkeypatch.setattr(profiles.shutil, "copy2", pause_config_copy) + outcome: dict[str, object] = {} + + def clone() -> None: + try: + outcome["profile"] = profiles.create_profile( + "racing", + clone_from="default", + auth_mode="profile", + no_alias=True, + ) + except BaseException as exc: # surfaced in the parent thread below + outcome["error"] = exc + + worker = threading.Thread(target=clone) + worker.start() + assert clone_started.wait(timeout=5) + source_auth.write_text( + json.dumps( + { + "providers": { + "openai-codex": { + "access_token": "access-after", + "refresh_token": "refresh-after", + } + } + } + ), + encoding="utf-8", + ) + allow_clone.set() + worker.join(timeout=5) + + assert not worker.is_alive() + if "error" in outcome: + raise outcome["error"] # type: ignore[misc] + local = Path(outcome["profile"]) # type: ignore[arg-type] + local_auth = json.loads((local / "auth.json").read_text(encoding="utf-8")) + assert local_auth["providers"] == {} + assert "refresh-before" not in (local / "auth.json").read_text(encoding="utf-8") + assert "refresh-after" not in (local / "auth.json").read_text(encoding="utf-8") + assert json.loads(source_auth.read_text(encoding="utf-8"))["providers"][ + "openai-codex" + ]["refresh_token"] == "refresh-after" + + +def test_new_profile_defaults_to_explicit_shared_authority(profile_root): + from hermes_cli.profiles import create_profile + + created = create_profile("fresh", no_alias=True) + + assert yaml.safe_load((created / "config.yaml").read_text())["auth"]["authority"] == "shared" + assert not (created / "auth.json").exists() + + +def test_delete_profile_local_auth_requires_purge_or_archive(profile_root): + from hermes_cli.profiles import create_profile, delete_profile + + local = create_profile("local", auth_mode="profile", no_alias=True) + (local / "auth.json").write_text('{"providers":{}}', encoding="utf-8") + + with pytest.raises(ValueError, match="--auth-action"): + delete_profile("local", yes=True) + + with patch("hermes_cli.profiles._cleanup_gateway_service"): + delete_profile("local", yes=True, auth_action="archive") + archives = list( + (profile_root / "state-snapshots" / "auth-profile-deletions").glob( + "local-*.json" + ) + ) + assert len(archives) == 1 + assert archives[0].stat().st_mode & 0o777 == 0o600 + + +def test_delete_shared_profile_does_not_treat_shared_store_as_local(profile_root): + from hermes_cli.profiles import create_profile, delete_profile + + (profile_root / "auth.json").write_text('{"providers":{}}', encoding="utf-8") + shared = create_profile("shared", auth_mode="shared", no_alias=True) + + with patch("hermes_cli.profiles._cleanup_gateway_service"): + deleted = delete_profile("shared", yes=True) + + assert deleted == shared + assert not shared.exists() + assert (profile_root / "auth.json").is_file() + + +def test_rename_moves_profile_local_authority_without_touching_shared(profile_root): + from hermes_cli.profiles import create_profile, rename_profile + + shared_raw = '{"providers":{"nous":{"access_token":"shared"}}}' + (profile_root / "auth.json").write_text(shared_raw, encoding="utf-8") + local = create_profile("before", auth_mode="profile", no_alias=True) + (local / "auth.json").write_text('{"providers":{"nous":{"access_token":"local"}}}', encoding="utf-8") + + with patch("hermes_cli.profiles._cleanup_gateway_service"): + renamed = rename_profile("before", "after") + assert (renamed / "auth.json").is_file() + assert yaml.safe_load((renamed / "config.yaml").read_text())["auth"]["authority"] == "profile" + assert (profile_root / "auth.json").read_text() == shared_raw + + +def test_rename_stops_profile_backends_before_moving_local_authority(profile_root): + from hermes_cli.profiles import create_profile, rename_profile + + local = create_profile("before", auth_mode="profile", no_alias=True) + (local / "auth.json").write_text('{"providers":{}}', encoding="utf-8") + events = [] + real_rename = Path.rename + + with patch( + "hermes_cli.profiles._stop_profile_backends", + side_effect=lambda *_args: events.append("backends-stopped"), + ), patch( + "hermes_cli.profiles._cleanup_gateway_service", + side_effect=lambda *_args, **_kwargs: events.append("gateway-stopped"), + ), patch.object( + Path, + "rename", + autospec=True, + side_effect=lambda source, destination: ( + events.append("renamed"), + real_rename(source, destination), + )[1], + ): + rename_profile("before", "after") + + assert events.index("backends-stopped") < events.index("renamed") + assert events.index("gateway-stopped") < events.index("renamed") + + +def test_delete_archives_profile_auth_only_after_writers_stop( + tmp_path, monkeypatch +) -> None: + import hermes_cli.profiles as profiles + + root = tmp_path / ".hermes" + target = root / "profiles" / "local" + target.mkdir(parents=True) + (target / "config.yaml").write_text( + "auth:\n authority: profile\n", encoding="utf-8" + ) + (target / "auth.json").write_text('{"providers": {}}', encoding="utf-8") + events: list[str] = [] + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(root)) + monkeypatch.setattr( + profiles, + "_stop_profile_backends", + lambda *_args, **_kwargs: events.append("stop"), + ) + monkeypatch.setattr( + profiles, "_cleanup_gateway_service", lambda *_args, **_kwargs: None + ) + monkeypatch.setattr(profiles, "_stop_gateway_process", lambda *_args: None) + monkeypatch.setattr( + profiles, + "_archive_profile_auth", + lambda *_args, **_kwargs: events.append("archive") or (root / "archived"), + ) + + assert profiles.delete_profile("local", yes=True, auth_action="archive") == target + assert events.index("stop") < events.index("archive") diff --git a/tests/hermes_cli/test_result_metadata.py b/tests/hermes_cli/test_result_metadata.py new file mode 100644 index 000000000000..c51c0336ae14 --- /dev/null +++ b/tests/hermes_cli/test_result_metadata.py @@ -0,0 +1,889 @@ +from __future__ import annotations + +import json +import os +import stat +import subprocess +import sys + +import pytest + + +@pytest.mark.parametrize( + ("result", "max_iterations", "failure_class", "api_calls"), + [ + pytest.param( + { + "completed": True, + "failed": False, + "partial": False, + "interrupted": False, + "api_calls": 1, + "turn_exit_reason": "max_iterations_reached(1/1)", + }, + 1, + "unknown_failure", + 1, + id="completed-max-iterations-is-contradictory", + ), + pytest.param( + { + "completed": True, + "failed": False, + "partial": False, + "interrupted": False, + "api_calls": 1, + "turn_exit_reason": "budget_exhausted", + }, + 1, + "unknown_failure", + 1, + id="completed-budget-exhausted-is-contradictory", + ), + pytest.param( + { + "completed": True, + "failed": False, + "partial": False, + "interrupted": False, + "api_calls": 1, + "turn_exit_reason": "all_retries_exhausted_no_response", + }, + 1, + "unknown_failure", + 1, + id="completed-retries-exhausted-is-contradictory", + ), + pytest.param( + { + "completed": True, + "failed": False, + "partial": False, + "interrupted": False, + "api_calls": 0, + }, + 3, + "none", + 0, + id="clean-success-zero-api-calls", + ), + pytest.param( + { + "completed": True, + "failed": False, + "partial": False, + "interrupted": False, + "api_calls": 4, + }, + 3, + "none", + 4, + id="clean-success-grace-call-boundary", + ), + pytest.param( + { + "completed": False, + "failed": False, + "partial": True, + "interrupted": False, + "api_calls": 2, + }, + 3, + "max_turns_or_incomplete", + 2, + id="valid-partial", + ), + pytest.param( + { + "completed": False, + "failed": False, + "partial": False, + "interrupted": False, + "api_calls": 2, + }, + 3, + "max_turns_or_incomplete", + 2, + id="valid-incomplete", + ), + pytest.param( + { + "completed": True, + "failed": False, + "partial": False, + "interrupted": True, + "api_calls": 1, + }, + 3, + "unknown_failure", + 1, + id="completed-interrupted-is-contradictory", + ), + pytest.param( + { + "completed": False, + "failed": True, + "partial": False, + "interrupted": True, + "api_calls": 1, + "error": "content_policy_blocked: private detail", + "failure_reason": "rate_limit", + }, + 3, + "unknown_failure", + 1, + id="failed-interrupted-is-contradictory", + ), + pytest.param( + { + "completed": False, + "failed": True, + "partial": False, + "interrupted": False, + "api_calls": 1, + "error": "content_policy_blocked: private detail", + "failure_reason": "rate_limit", + }, + 3, + "content_policy_blocked", + 1, + id="content-policy-precedes-provider", + ), + pytest.param( + { + "completed": False, + "failed": True, + "partial": False, + "interrupted": False, + "api_calls": 1, + "failure_reason": "rate_limit", + }, + 3, + "provider_api_terminal", + 1, + id="trusted-provider-failure", + ), + pytest.param( + { + "completed": True, + "failed": False, + "partial": False, + "interrupted": False, + "api_calls": True, + }, + 3, + "unknown_failure", + 0, + id="boolean-api-calls-is-invalid", + ), + pytest.param( + { + "completed": True, + "failed": False, + "partial": False, + "interrupted": False, + "api_calls": 5, + }, + 3, + "unknown_failure", + 0, + id="api-calls-over-grace-boundary-is-invalid", + ), + ], +) +def test_projection_matrix_is_closed_world_bounded_and_serializable( + result, max_iterations, failure_class, api_calls +): + from hermes_cli.result_metadata import ( + MAX_METADATA_BYTES, + build_result_metadata, + serialize_result_metadata, + ) + + raw_marker = "raw-marker-must-not-leak" + result = {**result, "final_response": raw_marker, "tool_output": raw_marker} + + metadata = build_result_metadata(result, max_iterations=max_iterations) + encoded = serialize_result_metadata(metadata) + + assert metadata["failure_class"] == failure_class + assert metadata["api_calls"] == api_calls + assert set(metadata) == { + "schema_version", + "completed", + "failed", + "partial", + "interrupted", + "api_calls", + "failure_class", + } + assert json.loads(encoded) == metadata + assert len(encoded) <= MAX_METADATA_BYTES + assert raw_marker.encode() not in encoded + + +@pytest.mark.parametrize( + ("result", "failure_class", "expected_statuses"), + [ + ( + {"completed": False, "failed": False, "partial": False, "interrupted": True, "api_calls": 1}, + "interrupted", + (False, False, False, True), + ), + ( + { + "completed": False, + "failed": True, + "partial": False, + "interrupted": False, + "api_calls": 1, + "error": "content_policy_blocked: private provider detail", + }, + "content_policy_blocked", + (False, True, False, False), + ), + ( + { + "completed": False, + "failed": True, + "partial": False, + "interrupted": False, + "api_calls": 2, + "failure_reason": "rate_limit", + }, + "provider_api_terminal", + (False, True, False, False), + ), + ( + {"completed": False, "failed": False, "partial": True, "interrupted": False, "api_calls": 2}, + "max_turns_or_incomplete", + (False, False, True, False), + ), + ( + { + "completed": False, + "failed": False, + "partial": False, + "interrupted": False, + "api_calls": 4, + "turn_exit_reason": "max_iterations_reached(3/3)", + }, + "max_turns_or_incomplete", + (False, False, False, False), + ), + ( + {"completed": False, "failed": True, "partial": False, "interrupted": False, "api_calls": 1}, + "unknown_failure", + (False, True, False, False), + ), + ( + {"completed": True, "failed": True, "partial": False, "interrupted": False, "api_calls": 1}, + "unknown_failure", + (True, True, False, False), + ), + ], +) +def test_failure_class_precedence_and_status_invariants(result, failure_class, expected_statuses): + from hermes_cli.result_metadata import build_result_metadata + + metadata = build_result_metadata(result, max_iterations=3) + + assert metadata["failure_class"] == failure_class + assert ( + metadata["completed"], + metadata["failed"], + metadata["partial"], + metadata["interrupted"], + ) == expected_statuses + + +def test_multiple_true_statuses_fail_closed_before_interrupted_precedence(): + from hermes_cli.result_metadata import build_result_metadata + + metadata = build_result_metadata( + { + "completed": False, + "failed": True, + "partial": False, + "interrupted": True, + "api_calls": 1, + "error": "content_policy_blocked: private detail", + "failure_reason": "rate_limit", + }, + max_iterations=3, + ) + + assert metadata["failure_class"] == "unknown_failure" + assert metadata["interrupted"] is True + assert metadata["failed"] is True + + +def test_canonical_turn_result_defaults_absent_negative_flags_to_false(): + from hermes_cli.result_metadata import build_result_metadata + + metadata = build_result_metadata( + {"completed": True, "partial": False, "interrupted": False, "api_calls": 1}, + max_iterations=3, + ) + + assert metadata["failure_class"] == "none" + assert metadata["failed"] is False + + +@pytest.mark.parametrize("api_calls", [True, -1, 5, "1", None]) +def test_api_calls_must_be_a_bounded_non_boolean_integer(api_calls): + from hermes_cli.result_metadata import build_result_metadata + + metadata = build_result_metadata( + { + "completed": True, + "failed": False, + "partial": False, + "interrupted": False, + "api_calls": api_calls, + }, + max_iterations=3, + ) + + assert metadata["api_calls"] == 0 + assert metadata["failure_class"] == "unknown_failure" + assert metadata["failed"] is False + + +def test_status_values_must_be_strict_booleans(): + from hermes_cli.result_metadata import build_result_metadata + + metadata = build_result_metadata( + {"completed": 1, "failed": False, "partial": False, "interrupted": False, "api_calls": 0}, + max_iterations=3, + ) + + assert metadata["failure_class"] == "unknown_failure" + assert metadata["failed"] is False + + +def test_provider_terminal_class_uses_only_structured_classifier_values(): + from agent.error_classifier import FailoverReason + from hermes_cli.result_metadata import build_result_metadata + + base = { + "completed": False, + "failed": True, + "partial": False, + "interrupted": False, + "api_calls": 1, + } + for reason in FailoverReason: + metadata = build_result_metadata( + {**base, "failure_reason": reason.value}, max_iterations=3 + ) + assert metadata["failure_class"] == "provider_api_terminal" + + metadata = build_result_metadata( + {**base, "failure_reason": "human display text"}, max_iterations=3 + ) + assert metadata["failure_class"] == "unknown_failure" + + +def test_success_metadata_is_closed_world_and_canonical(): + from hermes_cli.result_metadata import ( + SCHEMA_VERSION, + build_result_metadata, + serialize_result_metadata, + ) + + result = { + "completed": True, + "failed": False, + "partial": False, + "interrupted": False, + "api_calls": 1, + "final_response": "secret response", + "error": "secret error", + "messages": [{"role": "user", "content": "secret prompt"}], + "provider": "secret provider", + "model": "secret model", + "session_id": "secret session", + "tool_output": "secret tool output", + "path": "/secret/result/path", + "hash": "secret hash", + "exception": "secret exception", + } + + metadata = build_result_metadata(result, max_iterations=3) + encoded = serialize_result_metadata(metadata) + + assert metadata == { + "schema_version": SCHEMA_VERSION, + "completed": True, + "failed": False, + "partial": False, + "interrupted": False, + "api_calls": 1, + "failure_class": "none", + } + assert encoded == ( + b'{"api_calls":1,"completed":true,"failed":false,' + b'"failure_class":"none","interrupted":false,"partial":false,' + b'"schema_version":"hermes-agent-result-meta-v1"}\n' + ) + assert json.loads(encoded) == metadata + for secret in ( + b"secret response", + b"secret error", + b"secret prompt", + b"secret provider", + b"secret model", + b"secret session", + b"secret tool output", + b"/secret/result/path", + b"secret hash", + b"secret exception", + ): + assert secret not in encoded + + +@pytest.mark.parametrize( + ("failure_class", "statuses"), + [ + ("none", (False, False, False, False)), + ("interrupted", (False, False, False, False)), + ("content_policy_blocked", (False, False, False, False)), + ("provider_api_terminal", (False, False, False, False)), + ("max_turns_or_incomplete", (True, False, False, False)), + ], +) +def test_serializer_rejects_failure_class_status_invariant_violations( + failure_class, statuses +): + from hermes_cli.result_metadata import ( + ResultMetadataError, + SCHEMA_VERSION, + serialize_result_metadata, + ) + + completed, failed, partial, interrupted = statuses + metadata = { + "schema_version": SCHEMA_VERSION, + "completed": completed, + "failed": failed, + "partial": partial, + "interrupted": interrupted, + "api_calls": 0, + "failure_class": failure_class, + } + + with pytest.raises(ResultMetadataError): + serialize_result_metadata(metadata) + + +def _success_result() -> dict[str, object]: + return { + "completed": True, + "failed": False, + "partial": False, + "interrupted": False, + "api_calls": 1, + } + + +def _success_metadata() -> dict[str, object]: + from hermes_cli.result_metadata import build_result_metadata + + return build_result_metadata(_success_result(), max_iterations=3) + + +def _metadata_temps(directory): + return [path for path in directory.iterdir() if path.name.endswith(".result-meta.tmp")] + + +@pytest.mark.parametrize("bad_path", ["relative.json", "/tmp/../tmp/result.json"]) +def test_destination_requires_absolute_path_without_parent_traversal(bad_path): + from hermes_cli.result_metadata import ResultMetadataError, validate_result_metadata_destination + + with pytest.raises(ResultMetadataError): + validate_result_metadata_destination(bad_path) + + +def test_destination_rejects_missing_or_symlinked_parent_and_existing_leaf(tmp_path): + from hermes_cli.result_metadata import ResultMetadataError, validate_result_metadata_destination + + with pytest.raises(ResultMetadataError): + validate_result_metadata_destination(tmp_path / "missing" / "result.json") + + real_parent = tmp_path / "real" + real_parent.mkdir() + linked_parent = tmp_path / "linked" + linked_parent.symlink_to(real_parent, target_is_directory=True) + with pytest.raises(ResultMetadataError): + validate_result_metadata_destination(linked_parent / "result.json") + + existing = real_parent / "result.json" + existing.write_text("caller data", encoding="utf-8") + with pytest.raises(ResultMetadataError): + validate_result_metadata_destination(existing) + + +def test_write_result_metadata_is_atomic_private_and_leaves_no_temp(tmp_path): + from hermes_cli.result_metadata import SCHEMA_VERSION, write_result_metadata + + destination = tmp_path / "result.json" + metadata = write_result_metadata(destination, _success_metadata()) + + assert json.loads(destination.read_bytes()) == metadata + assert metadata["schema_version"] == SCHEMA_VERSION + assert stat.S_IMODE(destination.stat().st_mode) == 0o600 + assert _metadata_temps(tmp_path) == [] + + +def test_write_result_metadata_handles_short_writes(monkeypatch, tmp_path): + from hermes_cli.result_metadata import write_result_metadata + + real_write = os.write + + def short_write(fd, data): + return real_write(fd, data[: max(1, len(data) // 2)]) + + monkeypatch.setattr(os, "write", short_write) + destination = tmp_path / "result.json" + + metadata = write_result_metadata(destination, _success_metadata()) + + assert json.loads(destination.read_bytes()) == metadata + + +def test_publish_race_never_clobbers_destination(monkeypatch, tmp_path): + from hermes_cli.result_metadata import ResultMetadataError, write_result_metadata + + destination = tmp_path / "result.json" + real_link = os.link + + def racing_link(src, dst, **kwargs): + destination.write_text("caller won", encoding="utf-8") + return real_link(src, dst, **kwargs) + + monkeypatch.setattr(os, "link", racing_link) + + with pytest.raises(ResultMetadataError): + write_result_metadata(destination, _success_metadata()) + + assert destination.read_text(encoding="utf-8") == "caller won" + assert _metadata_temps(tmp_path) == [] + + +@pytest.mark.parametrize("fault", ["write", "file_fsync", "link"]) +def test_publication_faults_leave_no_destination_or_temp(monkeypatch, tmp_path, fault): + from hermes_cli.result_metadata import ResultMetadataError, write_result_metadata + + def raise_os_error(*_args, **_kwargs): + raise OSError(fault) + + if fault == "write": + monkeypatch.setattr(os, "write", raise_os_error) + elif fault == "file_fsync": + monkeypatch.setattr(os, "fsync", raise_os_error) + else: + monkeypatch.setattr(os, "link", raise_os_error) + + destination = tmp_path / "result.json" + with pytest.raises(ResultMetadataError): + write_result_metadata(destination, _success_metadata()) + + assert not destination.exists() + assert _metadata_temps(tmp_path) == [] + + +def test_parent_fsync_failure_rolls_back_published_file(monkeypatch, tmp_path): + from hermes_cli.result_metadata import ResultMetadataError, write_result_metadata + + real_fsync = os.fsync + calls = 0 + + def fail_parent_fsync(fd): + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("directory fsync") + return real_fsync(fd) + + monkeypatch.setattr(os, "fsync", fail_parent_fsync) + destination = tmp_path / "result.json" + + with pytest.raises(ResultMetadataError): + write_result_metadata(destination, _success_metadata()) + + assert not destination.exists() + assert _metadata_temps(tmp_path) == [] + + +def test_temp_unlink_failure_rolls_back_and_retries_cleanup(monkeypatch, tmp_path): + from hermes_cli.result_metadata import ResultMetadataError, write_result_metadata + + real_unlink = os.unlink + calls = 0 + + def fail_first_unlink(path, **kwargs): + nonlocal calls + calls += 1 + if calls == 1: + raise OSError("unlink") + return real_unlink(path, **kwargs) + + monkeypatch.setattr(os, "unlink", fail_first_unlink) + destination = tmp_path / "result.json" + + with pytest.raises(ResultMetadataError): + write_result_metadata(destination, _success_metadata()) + + assert calls >= 2 + assert not destination.exists() + assert _metadata_temps(tmp_path) == [] + + +def test_symlink_parent_swap_cannot_redirect_publication(monkeypatch, tmp_path): + from hermes_cli.result_metadata import ResultMetadataError, write_result_metadata + + parent = tmp_path / "parent" + parent.mkdir() + attacker = tmp_path / "attacker" + attacker.mkdir() + moved_parent = tmp_path / "moved-parent" + real_link = os.link + + def swap_parent_then_link(src, dst, **kwargs): + parent.rename(moved_parent) + parent.symlink_to(attacker, target_is_directory=True) + return real_link(src, dst, **kwargs) + + monkeypatch.setattr(os, "link", swap_parent_then_link) + + with pytest.raises(ResultMetadataError): + write_result_metadata(parent / "result.json", _success_metadata()) + + assert not (attacker / "result.json").exists() + assert not (moved_parent / "result.json").exists() + + +@pytest.mark.parametrize("leaf_kind", ["symlink", "directory", "fifo"]) +def test_destination_rejects_every_existing_leaf_type(tmp_path, leaf_kind): + from hermes_cli.result_metadata import ( + ResultMetadataError, + validate_result_metadata_destination, + write_result_metadata, + ) + + destination = tmp_path / "special-result" + if leaf_kind == "symlink": + destination.symlink_to(tmp_path / "missing-target") + elif leaf_kind == "directory": + destination.mkdir() + else: + os.mkfifo(destination) + + with pytest.raises(ResultMetadataError): + validate_result_metadata_destination(destination) + with pytest.raises(ResultMetadataError): + write_result_metadata(destination, _success_metadata()) + + assert os.path.lexists(destination) + + +def test_destination_rejects_existing_device(): + from hermes_cli.result_metadata import ResultMetadataError, validate_result_metadata_destination + + if not os.path.exists("/dev/null"): + pytest.skip("POSIX null device is unavailable") + with pytest.raises(ResultMetadataError): + validate_result_metadata_destination("/dev/null") + + +def test_temp_symlink_swap_never_publishes_or_unlinks_attacker_file(monkeypatch, tmp_path): + from hermes_cli.result_metadata import ResultMetadataError, write_result_metadata + + attacker_file = tmp_path / "attacker.txt" + attacker_file.write_text("caller data", encoding="utf-8") + destination = tmp_path / "result.json" + real_link = os.link + swapped_temp = None + + def swap_temp_then_link(src, dst, **kwargs): + nonlocal swapped_temp + [swapped_temp] = _metadata_temps(tmp_path) + swapped_temp.unlink() + swapped_temp.symlink_to(attacker_file) + return real_link(src, dst, **kwargs) + + monkeypatch.setattr(os, "link", swap_temp_then_link) + + with pytest.raises(ResultMetadataError): + write_result_metadata(destination, _success_metadata()) + + assert not destination.exists() + assert attacker_file.read_text(encoding="utf-8") == "caller data" + assert swapped_temp is not None and swapped_temp.is_symlink() + + +def test_claim_result_metadata_fd_accepts_blocking_fifo_writer_and_sets_cloexec(): + from hermes_cli.result_metadata import claim_result_metadata_fd + + read_fd, write_fd = os.pipe() + os.set_inheritable(write_fd, True) + owner = claim_result_metadata_fd(write_fd) + try: + assert owner.fileno() == write_fd + assert os.get_inheritable(write_fd) is False + assert os.fpathconf(write_fd, "PC_PIPE_BUF") >= 1024 + descendant = subprocess.run( + [ + sys.executable, + "-c", + f"import os,sys;\ntry: os.fstat({write_fd})\nexcept OSError: sys.exit(0)\nsys.exit(1)", + ], + close_fds=False, + check=False, + ) + assert descendant.returncode == 0 + finally: + owner.close() + os.close(read_fd) + + with pytest.raises(OSError): + os.fstat(write_fd) + + +@pytest.mark.parametrize("invalid", [True, False, "3", 3.0, None, 0, 1, 2]) +def test_claim_result_metadata_fd_rejects_noncanonical_values(invalid): + from hermes_cli.result_metadata import ResultMetadataError, claim_result_metadata_fd + + with pytest.raises(ResultMetadataError): + claim_result_metadata_fd(invalid) + + +def test_claim_result_metadata_fd_rejects_closed_read_end_and_regular_file(tmp_path): + from hermes_cli.result_metadata import ResultMetadataError, claim_result_metadata_fd + + closed_read, closed_fd = os.pipe() + os.close(closed_read) + os.close(closed_fd) + with pytest.raises(ResultMetadataError): + claim_result_metadata_fd(closed_fd) + + read_fd, write_fd = os.pipe() + try: + with pytest.raises(ResultMetadataError): + claim_result_metadata_fd(read_fd) + finally: + os.close(read_fd) + os.close(write_fd) + + regular_fd = os.open(tmp_path / "regular", os.O_WRONLY | os.O_CREAT, 0o600) + try: + with pytest.raises(ResultMetadataError): + claim_result_metadata_fd(regular_fd) + finally: + os.close(regular_fd) + + fifo = tmp_path / "duplex-fifo" + os.mkfifo(fifo) + duplex_fd = os.open(fifo, os.O_RDWR) + try: + with pytest.raises(ResultMetadataError): + claim_result_metadata_fd(duplex_fd) + finally: + os.close(duplex_fd) + + +def test_claim_result_metadata_fd_rejects_nonblocking_or_small_pipe(monkeypatch): + from hermes_cli import result_metadata + + read_fd, write_fd = os.pipe() + try: + os.set_blocking(write_fd, False) + with pytest.raises(result_metadata.ResultMetadataError): + result_metadata.claim_result_metadata_fd(write_fd) + finally: + os.close(read_fd) + os.close(write_fd) + + read_fd, write_fd = os.pipe() + monkeypatch.setattr(result_metadata.os, "fpathconf", lambda *_args: 512) + try: + with pytest.raises(result_metadata.ResultMetadataError): + result_metadata.claim_result_metadata_fd(write_fd) + finally: + os.close(read_fd) + os.close(write_fd) + + +@pytest.mark.parametrize( + ("failure_class", "statuses"), + [ + ("none", (True, False, False, False)), + ("interrupted", (False, False, False, True)), + ("content_policy_blocked", (False, True, False, False)), + ("provider_api_terminal", (False, True, False, False)), + ("max_turns_or_incomplete", (False, False, True, False)), + ("unknown_failure", (False, True, False, False)), + ], +) +def test_write_result_metadata_fd_emits_one_bounded_atomic_frame( + monkeypatch, failure_class, statuses +): + from hermes_cli import result_metadata + + read_fd, write_fd = os.pipe() + owner = result_metadata.claim_result_metadata_fd(write_fd) + real_write = os.write + calls = [] + + def counted_write(target_fd, payload): + calls.append((target_fd, bytes(payload))) + return real_write(target_fd, payload) + + monkeypatch.setattr(result_metadata.os, "write", counted_write) + try: + completed, failed, partial, interrupted = statuses + metadata = { + **_success_metadata(), + "completed": completed, + "failed": failed, + "partial": partial, + "interrupted": interrupted, + "failure_class": failure_class, + } + result_metadata.write_result_metadata_fd(owner, metadata) + expected = result_metadata.serialize_result_metadata(metadata) + assert os.read(read_fd, 1024) == expected + assert calls == [(write_fd, expected)] + assert len(expected) <= 1024 + finally: + owner.close() + os.close(read_fd) + + +@pytest.mark.parametrize("fault", ["short", "epipe", "eagain"]) +def test_write_result_metadata_fd_fails_closed_without_retry(monkeypatch, fault): + import errno + + from hermes_cli import result_metadata + + read_fd, write_fd = os.pipe() + owner = result_metadata.claim_result_metadata_fd(write_fd) + calls = 0 + + def faulty_write(_fd, payload): + nonlocal calls + calls += 1 + if fault == "short": + return len(payload) - 1 + error_number = errno.EPIPE if fault == "epipe" else errno.EAGAIN + raise OSError(error_number, fault) + + monkeypatch.setattr(result_metadata.os, "write", faulty_write) + try: + with pytest.raises(result_metadata.ResultMetadataError): + result_metadata.write_result_metadata_fd(owner, _success_metadata()) + assert calls == 1 + finally: + owner.close() + os.close(read_fd) diff --git a/tests/hermes_cli/test_result_metadata_cli.py b/tests/hermes_cli/test_result_metadata_cli.py new file mode 100644 index 000000000000..2def43288839 --- /dev/null +++ b/tests/hermes_cli/test_result_metadata_cli.py @@ -0,0 +1,604 @@ +from __future__ import annotations + +import json +import os +import sys +import types + +import pytest + + +def _install_fake_cli_dependencies(monkeypatch, fake_main): + fake_cli = types.ModuleType("cli") + fake_cli.main = fake_main + fake_banner = types.ModuleType("hermes_cli.banner") + fake_banner.prefetch_update_check = lambda: None + fake_skills_sync = types.ModuleType("tools.skills_sync") + fake_skills_sync.sync_skills = lambda quiet=True: None + monkeypatch.setitem(sys.modules, "cli", fake_cli) + monkeypatch.setitem(sys.modules, "hermes_cli.banner", fake_banner) + monkeypatch.setitem(sys.modules, "tools.skills_sync", fake_skills_sync) + + +def _install_direct_api_fake_cli(monkeypatch): + import cli as cli_mod + + real_cli = cli_mod.HermesCLI + + class FakeCLI(real_cli): + def __init__(self, **kwargs): + self.result_meta_file = kwargs.get("result_meta_file") + self.result_meta_fd = kwargs.get("result_meta_fd") + self.session_id = "session" + self.system_prompt = "" + self.preloaded_skills = [] + + def show_banner(self): + pass + + def show_tools(self): + pass + + monkeypatch.setattr(cli_mod, "HermesCLI", FakeCLI) + return cli_mod + + +def test_parser_accepts_result_meta_file_for_chat(tmp_path): + from hermes_cli._parser import build_top_level_parser + + destination = tmp_path / "result.json" + parser, _subparsers, _chat = build_top_level_parser() + args = parser.parse_args( + [ + "chat", + "--quiet", + "--toolsets", + "safe", + "--max-turns", + "1", + "--source", + "observer-test", + "--query", + "hello", + "--result-meta-file", + str(destination), + ] + ) + + assert args.result_meta_file == str(destination) + assert args.quiet is True + assert args.toolsets == "safe" + assert args.max_turns == 1 + assert args.source == "observer-test" + assert args.query == "hello" + + +def test_parser_accepts_result_meta_fd_for_chat(): + from hermes_cli._parser import build_top_level_parser + + parser, _subparsers, _chat = build_top_level_parser() + args = parser.parse_args(["chat", "--query", "hello", "--result-meta-fd", "9"]) + + assert args.result_meta_fd == 9 + + +@pytest.mark.parametrize("value", ["03", "+3", "-3", "3.0", "true", "2"]) +def test_parser_rejects_noncanonical_result_meta_fd(value): + from hermes_cli._parser import build_top_level_parser + + parser, _subparsers, _chat = build_top_level_parser() + with pytest.raises(SystemExit) as raised: + parser.parse_args(["chat", "--query", "hello", "--result-meta-fd", value]) + + assert raised.value.code == 2 + + +def test_parser_rejects_both_result_metadata_transports(tmp_path): + from hermes_cli._parser import build_top_level_parser + + parser, _subparsers, _chat = build_top_level_parser() + with pytest.raises(SystemExit) as raised: + parser.parse_args( + [ + "chat", + "--query", + "hello", + "--result-meta-file", + str(tmp_path / "result.json"), + "--result-meta-fd", + "9", + ] + ) + + assert raised.value.code == 2 + + +def test_cmd_chat_forwards_result_meta_file(monkeypatch, tmp_path): + import hermes_cli.main as main_mod + from hermes_cli._parser import build_top_level_parser + + destination = tmp_path / "result.json" + captured = {} + _install_fake_cli_dependencies(monkeypatch, lambda **kwargs: captured.update(kwargs)) + monkeypatch.setattr(main_mod, "_has_any_provider_configured", lambda: True) + monkeypatch.setattr(main_mod, "_pin_kanban_board_env", lambda: None) + monkeypatch.setattr(main_mod, "_termux_should_prefetch_update_check", lambda: False) + + parser, _subparsers, chat = build_top_level_parser() + chat.set_defaults(func=main_mod.cmd_chat) + args = parser.parse_args( + ["chat", "--query", "hello", "--result-meta-file", str(destination)] + ) + main_mod.cmd_chat(args) + + assert captured["result_meta_file"] == str(destination) + + +def test_cmd_chat_claims_forwards_and_closes_result_meta_fd(monkeypatch): + import hermes_cli.main as main_mod + from hermes_cli._parser import build_top_level_parser + + read_fd, fd = os.pipe() + captured = {} + + def fake_main(**kwargs): + owner = kwargs["result_meta_fd"] + captured["fd"] = owner.fileno() + assert os.get_inheritable(fd) is False + + _install_fake_cli_dependencies(monkeypatch, fake_main) + monkeypatch.setattr(main_mod, "_has_any_provider_configured", lambda: True) + monkeypatch.setattr(main_mod, "_pin_kanban_board_env", lambda: None) + monkeypatch.setattr(main_mod, "_termux_should_prefetch_update_check", lambda: False) + + parser, _subparsers, chat = build_top_level_parser() + chat.set_defaults(func=main_mod.cmd_chat) + main_mod.cmd_chat( + parser.parse_args(["chat", "--query", "hello", "--result-meta-fd", str(fd)]) + ) + + assert captured["fd"] == fd + with pytest.raises(OSError): + os.fstat(fd) + os.close(read_fd) + + +def test_invalid_result_meta_fd_fails_before_config_or_provider(monkeypatch, capsys): + import hermes_cli.main as main_mod + from hermes_cli import result_metadata + from hermes_cli._parser import build_top_level_parser + + closed_fd = os.open(os.devnull, os.O_WRONLY) + os.close(closed_fd) + monkeypatch.setattr( + main_mod, + "_resolve_use_tui", + lambda _args: (_ for _ in ()).throw(AssertionError("config resolution ran")), + ) + monkeypatch.setattr( + main_mod, + "_has_any_provider_configured", + lambda: (_ for _ in ()).throw(AssertionError("provider check ran")), + ) + parser, _subparsers, chat = build_top_level_parser() + chat.set_defaults(func=main_mod.cmd_chat) + + with pytest.raises(SystemExit) as raised: + main_mod.cmd_chat( + parser.parse_args( + ["chat", "--query", "hello", "--result-meta-fd", str(closed_fd)] + ) + ) + + captured = capsys.readouterr() + assert raised.value.code == 2 + assert captured.out == "" + assert captured.err == result_metadata.PUBLIC_ERROR_MESSAGE + "\n" + + +@pytest.mark.parametrize("failure", [RuntimeError("startup"), KeyboardInterrupt()]) +def test_cmd_chat_closes_result_meta_fd_on_startup_error_or_interrupt( + monkeypatch, failure +): + import hermes_cli.main as main_mod + from hermes_cli._parser import build_top_level_parser + + read_fd, fd = os.pipe() + + def fail_main(**_kwargs): + raise failure + + _install_fake_cli_dependencies(monkeypatch, fail_main) + monkeypatch.setattr(main_mod, "_has_any_provider_configured", lambda: True) + monkeypatch.setattr(main_mod, "_pin_kanban_board_env", lambda: None) + monkeypatch.setattr(main_mod, "_termux_should_prefetch_update_check", lambda: False) + parser, _subparsers, chat = build_top_level_parser() + chat.set_defaults(func=main_mod.cmd_chat) + + with pytest.raises(type(failure)): + main_mod.cmd_chat( + parser.parse_args(["chat", "--query", "hello", "--result-meta-fd", str(fd)]) + ) + + with pytest.raises(OSError): + os.fstat(fd) + os.close(read_fd) + + +def test_direct_api_closes_result_meta_fd_on_post_construction_skill_error(monkeypatch): + cli_mod = _install_direct_api_fake_cli(monkeypatch) + read_fd, write_fd = os.pipe() + + with pytest.raises(ValueError, match=r"Unknown skill\(s\): __review_missing_skill__"): + cli_mod.main( + query="x", + quiet=True, + toolsets="safe", + skills="__review_missing_skill__", + result_meta_fd=write_fd, + ) + + with pytest.raises(OSError): + os.fstat(write_fd) + os.close(read_fd) + + +def test_direct_api_closes_result_meta_fd_on_pre_query_list_tools_exit(monkeypatch): + cli_mod = _install_direct_api_fake_cli(monkeypatch) + read_fd, write_fd = os.pipe() + + with pytest.raises(SystemExit) as raised: + cli_mod.main(query="x", list_tools=True, result_meta_fd=write_fd) + + assert raised.value.code == 0 + with pytest.raises(OSError): + os.fstat(write_fd) + os.close(read_fd) + + +@pytest.mark.parametrize( + "argv", + [ + ["chat", "--result-meta-file", "/tmp/result.json"], + ["chat", "--tui", "--query", "hello", "--result-meta-file", "/tmp/result.json"], + ], +) +def test_cmd_chat_rejects_non_query_or_tui_mode_before_provider(monkeypatch, argv): + import hermes_cli.main as main_mod + from hermes_cli._parser import build_top_level_parser + + monkeypatch.setattr( + main_mod, + "_has_any_provider_configured", + lambda: (_ for _ in ()).throw(AssertionError("provider check ran")), + ) + parser, _subparsers, chat = build_top_level_parser() + chat.set_defaults(func=main_mod.cmd_chat) + + with pytest.raises(SystemExit) as raised: + main_mod.cmd_chat(parser.parse_args(argv)) + + assert raised.value.code == 2 + + +def test_cmd_chat_rejects_existing_destination_before_provider(monkeypatch, tmp_path): + import hermes_cli.main as main_mod + from hermes_cli._parser import build_top_level_parser + + destination = tmp_path / "result.json" + destination.write_text("keep", encoding="utf-8") + monkeypatch.setattr( + main_mod, + "_has_any_provider_configured", + lambda: (_ for _ in ()).throw(AssertionError("provider check ran")), + ) + parser, _subparsers, chat = build_top_level_parser() + chat.set_defaults(func=main_mod.cmd_chat) + + with pytest.raises(SystemExit) as raised: + main_mod.cmd_chat( + parser.parse_args( + ["chat", "--query", "hello", "--result-meta-file", str(destination)] + ) + ) + + assert raised.value.code == 2 + assert destination.read_text(encoding="utf-8") == "keep" + + +def test_publish_result_metadata_is_silent_and_uses_effective_max_turns(tmp_path, capsys): + from cli import HermesCLI + + destination = tmp_path / "result.json" + cli = HermesCLI.__new__(HermesCLI) + cli.result_meta_file = str(destination) + cli.max_turns = 7 + + cli._publish_result_metadata( + { + "completed": True, + "failed": False, + "partial": False, + "interrupted": False, + "api_calls": 2, + } + ) + + assert capsys.readouterr().out == "" + assert '"api_calls":2' in destination.read_text(encoding="utf-8") + + +def test_publish_failure_has_fixed_diagnostic_and_nonzero_exit( + monkeypatch, tmp_path, capsys +): + from cli import HermesCLI + from hermes_cli import result_metadata + + destination = tmp_path / "result.json" + cli = HermesCLI.__new__(HermesCLI) + cli.result_meta_file = str(destination) + cli.max_turns = 7 + + def fail(*_args, **_kwargs): + raise result_metadata.ResultMetadataError(f"secret path: {destination}") + + monkeypatch.setattr(result_metadata, "write_result_metadata", fail) + + with pytest.raises(SystemExit) as raised: + cli._publish_result_metadata({"completed": True, "api_calls": 1}) + + captured = capsys.readouterr() + assert raised.value.code == 1 + assert captured.out == "" + assert captured.err == result_metadata.PUBLIC_ERROR_MESSAGE + "\n" + assert str(destination) not in captured.err + + +def test_publish_result_metadata_fd_is_silent_and_closes_owner(capsys): + from cli import HermesCLI + from hermes_cli import result_metadata + + read_fd, fd = os.pipe() + cli = HermesCLI.__new__(HermesCLI) + cli.result_meta_file = None + cli.result_meta_fd = result_metadata.claim_result_metadata_fd(fd) + cli.max_turns = 7 + + cli._publish_result_metadata( + { + "completed": True, + "failed": False, + "partial": False, + "interrupted": False, + "api_calls": 2, + } + ) + + assert capsys.readouterr().out == "" + assert b'"api_calls":2' in os.read(read_fd, 1024) + with pytest.raises(OSError): + os.fstat(fd) + os.close(read_fd) + + +@pytest.mark.parametrize("fault", ["short", "epipe", "eagain"]) +def test_publish_result_metadata_fd_closes_owner_on_failure(monkeypatch, capsys, fault): + import errno + + from cli import HermesCLI + from hermes_cli import result_metadata + + read_fd, fd = os.pipe() + cli = HermesCLI.__new__(HermesCLI) + cli.result_meta_file = None + cli.result_meta_fd = result_metadata.claim_result_metadata_fd(fd) + cli.max_turns = 7 + def fail_write(_fd, payload): + if fault == "short": + return len(payload) - 1 + error_number = errno.EPIPE if fault == "epipe" else errno.EAGAIN + raise OSError(error_number, fault) + + monkeypatch.setattr(result_metadata.os, "write", fail_write) + monkeypatch.setattr( + result_metadata, + "write_result_metadata", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("file fallback attempted") + ), + ) + + with pytest.raises(SystemExit) as raised: + cli._publish_result_metadata({"completed": True, "api_calls": 1}) + + assert raised.value.code == 1 + assert capsys.readouterr().err == result_metadata.PUBLIC_ERROR_MESSAGE + "\n" + with pytest.raises(OSError): + os.fstat(fd) + os.close(read_fd) + + +def test_quiet_query_stdout_is_byte_identical_and_publishes_once(monkeypatch, tmp_path, capsys): + import cli as cli_mod + import signal + from hermes_cli import result_metadata + + result = { + "completed": True, + "failed": False, + "partial": False, + "interrupted": False, + "api_calls": 1, + "final_response": "exact response", + "messages": [], + } + write_calls = 0 + real_write = result_metadata.write_result_metadata + + def counted_write(*args, **kwargs): + nonlocal write_calls + write_calls += 1 + return real_write(*args, **kwargs) + + monkeypatch.setattr(result_metadata, "write_result_metadata", counted_write) + RealHermesCLI = cli_mod.HermesCLI + + class FakeAgent: + session_id = "session" + quiet_mode = False + _stream_callback = object() + + def run_conversation(self, *_args, **_kwargs): + return dict(result) + + class FakeCLI(RealHermesCLI): + def __init__(self, **kwargs): + self.result_meta_file = kwargs.get("result_meta_file") + self.result_meta_fd = kwargs.get("result_meta_fd") + self.max_turns = kwargs.get("max_turns") or 90 + self.agent = FakeAgent() + self.session_id = "session" + self.conversation_history = [] + self._active_agent_route_signature = "same" + + + def _claim_active_session(self, *_args, **_kwargs): + return True + + def _release_active_session(self): + pass + + def _ensure_runtime_credentials(self): + return True + + def _resolve_turn_agent_config(self, _query): + return {"signature": "same", "model": None, "runtime": None} + + def _init_agent(self, **_kwargs): + return True + + + monkeypatch.setattr(cli_mod, "HermesCLI", FakeCLI) + monkeypatch.setattr(cli_mod, "_finalize_single_query", lambda _cli: None) + monkeypatch.setattr(signal, "signal", lambda *_args: None) + + with pytest.raises(SystemExit) as baseline_exit: + cli_mod.main(query="hello", quiet=True, toolsets="safe") + baseline = capsys.readouterr() + + destination = tmp_path / "result.json" + with pytest.raises(SystemExit) as metadata_exit: + cli_mod.main( + query="hello", + quiet=True, + toolsets="safe", + result_meta_file=str(destination), + ) + with_metadata = capsys.readouterr() + + read_fd, write_fd = os.pipe() + with pytest.raises(SystemExit) as fd_exit: + cli_mod.main( + query="hello", + quiet=True, + toolsets="safe", + result_meta_fd=write_fd, + ) + with_fd = capsys.readouterr() + fd_metadata = json.loads(os.read(read_fd, 1024)) + os.close(read_fd) + + assert baseline_exit.value.code == metadata_exit.value.code == fd_exit.value.code == 0 + assert baseline.out == with_metadata.out == with_fd.out == "exact response\n" + assert baseline.err == with_metadata.err == with_fd.err == "\nsession_id: session\n" + assert write_calls == 1 + expected = result_metadata.build_result_metadata(result, max_iterations=90) + assert expected == json.loads(destination.read_bytes()) == fd_metadata + + +def test_native_windows_result_meta_fd_fails_before_config_or_provider(monkeypatch, capsys): + import hermes_cli.main as main_mod + from hermes_cli import result_metadata + from hermes_cli._parser import build_top_level_parser + + read_fd, write_fd = os.pipe() + monkeypatch.setattr(result_metadata.os, "name", "nt") + monkeypatch.setattr( + main_mod, + "_resolve_use_tui", + lambda _args: (_ for _ in ()).throw(AssertionError("config resolution ran")), + ) + monkeypatch.setattr( + main_mod, + "_has_any_provider_configured", + lambda: (_ for _ in ()).throw(AssertionError("provider check ran")), + ) + parser, _subparsers, chat = build_top_level_parser() + chat.set_defaults(func=main_mod.cmd_chat) + + with pytest.raises(SystemExit) as raised: + main_mod.cmd_chat( + parser.parse_args( + ["chat", "--query", "hello", "--result-meta-fd", str(write_fd)] + ) + ) + + captured = capsys.readouterr() + assert raised.value.code == 2 + assert captured.out == "" + assert captured.err == result_metadata.PUBLIC_ERROR_MESSAGE + "\n" + os.close(read_fd) + os.close(write_fd) + + +@pytest.mark.parametrize("extra_args", [[], ["--tui", "--query", "hello"]]) +def test_cmd_chat_rejects_result_meta_fd_without_query_or_with_tui( + monkeypatch, extra_args +): + import hermes_cli.main as main_mod + from hermes_cli._parser import build_top_level_parser + + read_fd, write_fd = os.pipe() + monkeypatch.setattr( + main_mod, + "_has_any_provider_configured", + lambda: (_ for _ in ()).throw(AssertionError("provider check ran")), + ) + parser, _subparsers, chat = build_top_level_parser() + chat.set_defaults(func=main_mod.cmd_chat) + argv = ["chat", *extra_args, "--result-meta-fd", str(write_fd)] + + with pytest.raises(SystemExit) as raised: + main_mod.cmd_chat(parser.parse_args(argv)) + + assert raised.value.code == 2 + with pytest.raises(OSError): + os.fstat(write_fd) + os.close(read_fd) + + +def test_publish_result_metadata_fd_close_fault_is_publication_failure(monkeypatch, capsys): + from cli import HermesCLI + from hermes_cli import result_metadata + + read_fd, write_fd = os.pipe() + cli = HermesCLI.__new__(HermesCLI) + cli.result_meta_file = None + cli.result_meta_fd = result_metadata.claim_result_metadata_fd(write_fd) + cli.max_turns = 7 + real_close = result_metadata.os.close + + def fail_target_close(fd): + if fd == write_fd: + raise OSError("close fault") + return real_close(fd) + + monkeypatch.setattr(result_metadata.os, "close", fail_target_close) + with pytest.raises(SystemExit) as raised: + cli._publish_result_metadata({"completed": True, "api_calls": 1}) + + assert raised.value.code == 1 + assert capsys.readouterr().err == result_metadata.PUBLIC_ERROR_MESSAGE + "\n" + monkeypatch.setattr(result_metadata.os, "close", real_close) + real_close(write_fd) + real_close(read_fd) diff --git a/tests/hermes_cli/test_snapshot_auth_restore_command.py b/tests/hermes_cli/test_snapshot_auth_restore_command.py new file mode 100644 index 000000000000..f091f8475307 --- /dev/null +++ b/tests/hermes_cli/test_snapshot_auth_restore_command.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from hermes_cli.cli_commands_mixin import CLICommandsMixin + + +def test_snapshot_restore_skips_auth_by_default(monkeypatch, capsys) -> None: + captured = {} + + def restore(snapshot_id, **kwargs): + captured.update(snapshot_id=snapshot_id, **kwargs) + return True + + monkeypatch.setattr("hermes_cli.backup.restore_quick_snapshot", restore) + monkeypatch.setattr("hermes_cli.backup.list_quick_snapshots", lambda **kwargs: []) + + CLICommandsMixin()._handle_snapshot_command("/snapshot restore snap-1") + + assert captured == { + "snapshot_id": "snap-1", + "include_auth": False, + "auth_action": "skip", + "auth_passphrase_file": None, + } + assert "Authentication was skipped" in capsys.readouterr().out + + +def test_snapshot_restore_auth_requires_explicit_destination_and_passphrase( + monkeypatch, capsys +) -> None: + called = False + + def restore(*args, **kwargs): + nonlocal called + called = True + return True + + monkeypatch.setattr("hermes_cli.backup.restore_quick_snapshot", restore) + monkeypatch.setattr("hermes_cli.backup.list_quick_snapshots", lambda **kwargs: []) + + CLICommandsMixin()._handle_snapshot_command( + "/snapshot restore snap-1 --include-auth" + ) + + assert called is False + assert "requires --auth-action" in capsys.readouterr().out + + +def test_snapshot_restore_passes_explicit_auth_gate(monkeypatch) -> None: + captured = {} + + def restore(snapshot_id, **kwargs): + captured.update(snapshot_id=snapshot_id, **kwargs) + return True + + monkeypatch.setattr("hermes_cli.backup.restore_quick_snapshot", restore) + monkeypatch.setattr("hermes_cli.backup.list_quick_snapshots", lambda **kwargs: []) + + CLICommandsMixin()._handle_snapshot_command( + "/snapshot restore snap-1 --include-auth --auth-action restore-profile " + "--auth-passphrase-file /secure/passphrase", + ) + + assert captured == { + "snapshot_id": "snap-1", + "include_auth": True, + "auth_action": "restore-profile", + "auth_passphrase_file": "/secure/passphrase", + } diff --git a/tests/plugins/platforms/photon/test_auth.py b/tests/plugins/platforms/photon/test_auth.py index 56e4c23d94dc..2767292418b9 100644 --- a/tests/plugins/platforms/photon/test_auth.py +++ b/tests/plugins/platforms/photon/test_auth.py @@ -75,6 +75,68 @@ def test_store_and_load_photon_token(tmp_hermes_home: Path) -> None: assert auth_json["credential_pool"]["photon"][0]["access_token"] == "abc123def456" +def test_clear_photon_token_preserves_other_rotated_credentials(tmp_hermes_home: Path) -> None: + auth_path = tmp_hermes_home / "auth.json" + auth_path.write_text( + json.dumps( + { + "providers": { + "photon": {"access_token": "legacy-photon"}, + "openai-codex": {"refresh_token": "rotated-refresh"}, + }, + "credential_pool": { + "photon": [{"access_token": "stale-photon"}], + "nous": [{"access_token": "rotated-nous"}], + }, + } + ), + encoding="utf-8", + ) + + photon_auth.clear_photon_token() + + saved = json.loads(auth_path.read_text(encoding="utf-8")) + assert saved["providers"]["photon"] == {} + assert saved["credential_pool"]["photon"] == [] + assert saved["providers"]["openai-codex"]["refresh_token"] == "rotated-refresh" + assert saved["credential_pool"]["nous"][0]["access_token"] == "rotated-nous" + + +def test_store_photon_token_preserves_concurrent_project_rotation( + tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + auth_path = tmp_hermes_home / "auth.json" + auth_path.write_text( + json.dumps( + { + "credential_pool": { + "photon": [{"access_token": "old-photon"}], + "photon_project": [{"project_secret": "old-project"}], + } + } + ), + encoding="utf-8", + ) + + def rotate_project_before_save() -> int: + current = json.loads(auth_path.read_text(encoding="utf-8")) + current["credential_pool"]["photon_project"] = [ + {"project_secret": "rotated-project"} + ] + auth_path.write_text(json.dumps(current), encoding="utf-8") + return 123 + + monkeypatch.setattr(photon_auth.time, "time", rotate_project_before_save) + + photon_auth.store_photon_token("new-photon") + + saved = json.loads(auth_path.read_text(encoding="utf-8")) + assert saved["credential_pool"]["photon"][0]["access_token"] == "new-photon" + assert saved["credential_pool"]["photon_project"] == [ + {"project_secret": "rotated-project"} + ] + + @pytest.mark.skipif(os.name != "posix", reason="POSIX mode bits only") def test_save_auth_never_world_readable(tmp_hermes_home: Path) -> None: """auth.json must be created 0o600 — no window at process umask.""" @@ -261,6 +323,43 @@ def fake_post(url: str, **kwargs: Any) -> _FakeResponse: assert captured["url"].endswith("/api/projects") +def test_create_project_unwraps_current_dashboard_response( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + photon_auth.httpx, + "post", + lambda *_args, **_kwargs: _FakeResponse( + json_body={"succeed": True, "data": {"id": "nested-project"}} + ), + ) + + assert photon_auth.create_project("tok")["id"] == "nested-project" + + +@pytest.mark.parametrize( + ("response", "message"), + [ + ([{"id": "not-a-mapping"}], "unexpected response"), + ({"succeed": False, "message": "denied"}, "denied"), + ({"succeed": True, "data": {}}, "did not return a project id"), + ], +) +def test_create_project_rejects_invalid_dashboard_responses( + monkeypatch: pytest.MonkeyPatch, + response: Any, + message: str, +) -> None: + monkeypatch.setattr( + photon_auth.httpx, + "post", + lambda *_args, **_kwargs: _FakeResponse(json_body=response), + ) + + with pytest.raises(RuntimeError, match=message): + photon_auth.create_project("tok") + + def test_regenerate_project_secret(monkeypatch: pytest.MonkeyPatch) -> None: def fake_post(url: str, **kwargs: Any) -> _FakeResponse: assert url.endswith("/regenerate-secret") diff --git a/tests/plugins/test_photon_auth.py b/tests/plugins/test_photon_auth.py new file mode 100644 index 000000000000..a90074961fac --- /dev/null +++ b/tests/plugins/test_photon_auth.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import importlib +import json +from pathlib import Path + + +def test_photon_save_preserves_concurrent_unrelated_pool_update( + tmp_path: Path, monkeypatch, +) -> None: + auth_path = tmp_path / "auth.json" + auth_path.write_text( + json.dumps( + { + "providers": {}, + "credential_pool": { + "photon": [{"access_token": "old-photon"}], + "nous": [{"access_token": "old-nous"}], + }, + } + ), + encoding="utf-8", + ) + module = importlib.import_module("plugins.platforms.photon.auth") + monkeypatch.setattr(module, "_auth_json_path", lambda: auth_path) + + stale = module._load_auth() + stale["credential_pool"]["photon"] = [{"access_token": "new-photon"}] + + current = json.loads(auth_path.read_text(encoding="utf-8")) + current["credential_pool"]["nous"] = [{"access_token": "rotated-nous"}] + auth_path.write_text(json.dumps(current), encoding="utf-8") + + module._save_auth(stale) + + saved = json.loads(auth_path.read_text(encoding="utf-8")) + assert saved["credential_pool"]["photon"][0]["access_token"] == "new-photon" + assert saved["credential_pool"]["nous"][0]["access_token"] == "rotated-nous" diff --git a/tests/scripts/test_check_auth_store_consumers.py b/tests/scripts/test_check_auth_store_consumers.py new file mode 100644 index 000000000000..a3786c9a61d5 --- /dev/null +++ b/tests/scripts/test_check_auth_store_consumers.py @@ -0,0 +1,1514 @@ +from __future__ import annotations + +import ast +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + + +_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "check_auth_store_consumers.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("check_auth_store_consumers", _SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _entry(category: str, reason: str) -> dict[str, str]: + return {"category": category, "reason": reason} + + +def _inventory(path: Path, consumers: dict[str, dict[str, str]]) -> Path: + inventory = path / "inventory.json" + inventory.write_text( + json.dumps({"version": 2, "consumers": consumers}), encoding="utf-8" + ) + return inventory + + +def test_audit_rejects_unclassified_python_path_construction(tmp_path: Path) -> None: + module = _load_module() + source = tmp_path / "new_consumer.py" + source.write_text( + 'from pathlib import Path\nAUTH = Path.home() / ".hermes" / "auth.json"\n', + encoding="utf-8", + ) + + unclassified, stale = module.audit(tmp_path, _inventory(tmp_path, {})) + + assert [(item.path, item.line, item.kind) for item in unclassified] == [ + ("new_consumer.py", 2, "path_division") + ] + assert stale == [] + + +def test_audit_accepts_reviewed_category_and_rejects_stale_entries( + tmp_path: Path, +) -> None: + module = _load_module() + source = tmp_path / "adapter.py" + source.write_text( + 'from pathlib import Path\nAUTH = Path("root").joinpath("auth.json")\n', + encoding="utf-8", + ) + inventory = _inventory( + tmp_path, + { + "adapter.py": _entry( + "whole_store_deployment_adapter", + "canonical_locked_deployment_seed", + ), + "removed.py": _entry( + "canonical_authority_owner", "canonical_auth_authority" + ), + }, + ) + + unclassified, stale = module.audit(tmp_path, inventory) + + assert unclassified == [] + assert stale == ["removed.py"] + + +def test_scan_ignores_tests_but_covers_non_python_deployment_adapters( + tmp_path: Path, +) -> None: + module = _load_module() + (tmp_path / "tests").mkdir() + (tmp_path / "tests" / "test_fixture.py").write_text( + 'AUTH = Path("root") / "auth.json"\n', encoding="utf-8" + ) + hook = tmp_path / "stage2-hook.sh" + hook.write_text('target="$HERMES_HOME/auth.json"\n', encoding="utf-8") + + findings = module.scan_repository(tmp_path) + + assert [(item.path, item.line, item.kind) for item in findings] == [ + ("stage2-hook.sh", 1, "text_reference") + ] + + +def test_scan_normalizes_relative_and_absolute_roots( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text( + 'open("auth.json", "rb")\n', encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + + relative_findings = module.scan_repository(Path(".")) + absolute_findings = module.scan_repository(tmp_path.resolve()) + + assert relative_findings == absolute_findings == [ + module.Finding("consumer.py", 1, "open") + ] + + +@pytest.mark.parametrize( + "source", + [ + 'AUTH_STORE = Path("auth.json")\n', + 'AUTH_STORE = Path(f"{home}/auth.json")\n', + 'AUTH_STORE = Path("auth" + ".json")\n', + 'AUTH_STORE = str(home) + "/auth.json"\n', + 'AUTH_STORE = Path(home, "auth.json")\n', + 'AUTH_STORE = Path("auth").with_suffix(".json")\n', + ], +) +def test_audit_rejects_constructed_auth_store_paths( + tmp_path: Path, source: str +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text( + "from pathlib import Path\n" + source, encoding="utf-8" + ) + + unclassified, stale = module.audit(tmp_path, _inventory(tmp_path, {})) + + assert [(item.path, item.line) for item in unclassified] == [("consumer.py", 2)] + assert stale == [] + + +def test_audit_rejects_direct_builtin_open_of_auth_store(tmp_path: Path) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text( + 'open("auth.json", "rb")\n', encoding="utf-8" + ) + + unclassified, stale = module.audit(tmp_path, _inventory(tmp_path, {})) + + assert [(item.path, item.line, item.kind) for item in unclassified] == [ + ("consumer.py", 1, "open") + ] + assert stale == [] + + +@pytest.mark.parametrize( + ("source", "expected_line"), + [ + ('open(file="auth.json", mode="rb")\n', 1), + ('import builtins\nbuiltins.open(file="auth.json", mode="rb")\n', 2), + ], +) +def test_audit_rejects_builtin_open_file_keyword( + tmp_path: Path, source: str, expected_line: int +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text(source, encoding="utf-8") + + unclassified, stale = module.audit(tmp_path, _inventory(tmp_path, {})) + + assert [(item.path, item.line, item.kind) for item in unclassified] == [ + ("consumer.py", expected_line, "open") + ] + assert stale == [] + + +def test_audit_rejects_direct_wrapper_receiving_static_auth_store( + tmp_path: Path, +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text( + "def consume(path):\n" + ' return open(path, "r").read()\n' + 'consume("auth.json")\n', + encoding="utf-8", + ) + + unclassified, stale = module.audit(tmp_path, _inventory(tmp_path, {})) + + assert [(item.path, item.line, item.kind) for item in unclassified] == [ + ("consumer.py", 2, "open") + ] + assert stale == [] + + +@pytest.mark.parametrize( + ("source", "expected_line"), + [ + pytest.param( + 'open(*("auth.json", "rb"))\n', + 1, + id="builtin-open-star-args", + ), + pytest.param( + 'open(**{"file": "auth.json", "mode": "rb"})\n', + 1, + id="builtin-open-star-star-kwargs", + ), + pytest.param( + "def consume(path):\n" + ' return open(path, "rb")\n' + 'consume(*(\"auth.json\",))\n', + 2, + id="wrapper-call-star-args", + ), + pytest.param( + "def consume(path):\n" + ' return open(path, "rb")\n' + 'consume(**{"path": "auth.json"})\n', + 2, + id="wrapper-call-star-star-kwargs", + ), + pytest.param( + "def consume(*args):\n" + " return open(*args)\n" + 'consume("auth.json", "rb")\n', + 2, + id="wrapper-forwards-varargs", + ), + pytest.param( + "def consume(**kwargs):\n" + " return open(**kwargs)\n" + 'consume(file="auth.json", mode="rb")\n', + 2, + id="wrapper-forwards-kwargs", + ), + ], +) +def test_audit_rejects_static_auth_store_through_argument_unpacking( + tmp_path: Path, source: str, expected_line: int +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text(source, encoding="utf-8") + + unclassified, stale = module.audit(tmp_path, _inventory(tmp_path, {})) + + assert [(item.path, item.line, item.kind) for item in unclassified] == [ + ("consumer.py", expected_line, "open") + ] + assert stale == [] + + (tmp_path / "consumer.py").unlink() + (tmp_path / "harmless.py").write_text( + source.replace('"auth.json"', '"other.json"'), + encoding="utf-8", + ) + assert module.scan_repository(tmp_path) == [] + + +@pytest.mark.parametrize( + ("source", "expected_line"), + [ + pytest.param( + "class Consumer:\n" + " @staticmethod\n" + " def read(path):\n" + ' return open(path, "rb")\n' + 'Consumer.read("auth.json")\n', + 4, + id="staticmethod-wrapper", + ), + pytest.param( + "class Consumer:\n" + " def read(self, path):\n" + ' return open(path, "rb")\n' + "consumer = Consumer()\n" + 'consumer.read("auth.json")\n', + 3, + id="instance-method-wrapper", + ), + pytest.param( + 'consume = lambda path: open(path, "rb")\n' + 'consume("auth.json")\n', + 1, + id="lambda-wrapper", + ), + ], +) +def test_audit_rejects_static_auth_store_through_callable_wrappers( + tmp_path: Path, source: str, expected_line: int +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text(source, encoding="utf-8") + + unclassified, stale = module.audit(tmp_path, _inventory(tmp_path, {})) + + assert [(item.path, item.line, item.kind) for item in unclassified] == [ + ("consumer.py", expected_line, "open") + ] + assert stale == [] + + (tmp_path / "consumer.py").unlink() + (tmp_path / "harmless.py").write_text( + source.replace('"auth.json"', '"other.json"'), + encoding="utf-8", + ) + assert module.scan_repository(tmp_path) == [] + + +@pytest.mark.parametrize( + ("source", "expected_line"), + [ + pytest.param( + "from pathlib import Path\n" + "def consume(path):\n" + " return Path(path)\n" + 'consume("auth.json")\n', + 4, + id="function-returns-path", + ), + pytest.param( + "from pathlib import Path\n" + "consume = lambda path: Path(path)\n" + 'consume("auth.json")\n', + 3, + id="lambda-returns-path", + ), + pytest.param( + "from pathlib import Path\n" + "def consume(stem, suffix):\n" + ' return Path(f"{stem}.{suffix}")\n' + 'consume("auth", "json")\n', + 4, + id="function-constructs-path-from-split-arguments", + ), + ], +) +def test_audit_rejects_auth_path_constructed_by_wrapper( + tmp_path: Path, source: str, expected_line: int +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text(source, encoding="utf-8") + + findings = module.scan_repository(tmp_path) + + assert [(item.path, item.line, item.kind) for item in findings] == [ + ("consumer.py", expected_line, "constructed_path") + ] + + (tmp_path / "consumer.py").unlink() + (tmp_path / "harmless.py").write_text( + source.replace('"auth.json"', '"other.json"').replace( + '"auth", "json"', '"other", "json"' + ), + encoding="utf-8", + ) + assert module.scan_repository(tmp_path) == [] + + +@pytest.mark.parametrize( + ("source", "expected_line"), + [ + pytest.param( + "from pathlib import Path\n" + 'Path(*(\"auth.json\",))\n', + 2, + id="direct-path-star-args", + ), + pytest.param( + "from pathlib import Path\n" + "def consume(parts):\n" + " return Path(*parts)\n" + 'consume((\"auth.json\",))\n', + 4, + id="wrapper-path-star-args", + ), + pytest.param( + "from pathlib import Path\n" + 'Path(*(*(\"auth.json\",),))\n', + 2, + id="direct-nested-sequence-unpacking", + ), + pytest.param( + "from pathlib import Path\n" + 'parts = (*(\"auth.json\",),)\n' + "Path(*parts)\n", + 3, + id="assigned-tuple-nested-sequence-unpacking", + ), + pytest.param( + "from pathlib import Path\n" + 'parts = [*(\"auth.json\",)]\n' + "Path(*parts)\n", + 3, + id="assigned-list-nested-sequence-unpacking", + ), + ], +) +def test_audit_rejects_static_auth_store_in_starred_path_constructor( + tmp_path: Path, source: str, expected_line: int +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text(source, encoding="utf-8") + + findings = module.scan_repository(tmp_path) + + assert [(item.path, item.line, item.kind) for item in findings] == [ + ("consumer.py", expected_line, "constructed_path") + ] + + (tmp_path / "consumer.py").unlink() + (tmp_path / "harmless.py").write_text( + source.replace('"auth.json"', '"other.json"'), + encoding="utf-8", + ) + assert module.scan_repository(tmp_path) == [] + + +def test_starred_path_constructor_fails_closed_on_sequence_overflow( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + module = _load_module() + monkeypatch.setattr(module, "_MAX_STRUCTURED_ALTERNATIVES", 1) + (tmp_path / "consumer.py").write_text( + "from pathlib import Path\n" + 'parts = ("other.json",) if enabled else ("auth.json",)\n' + "Path(*parts)\n", + encoding="utf-8", + ) + + findings = module.scan_repository(tmp_path) + + assert [(item.path, item.line, item.kind) for item in findings] == [ + ("consumer.py", 3, "constructed_path") + ] + + +def _starred_path_product_source(*, alternatives: int, auth_first: bool) -> str: + lead = ( + '("auth.json",) if lead_enabled else ("other.json",)' + if auth_first + else '("other.json",) if lead_enabled else ("auth.json",)' + ) + definitions = ["from pathlib import Path\n", f"lead = {lead}\n"] + arguments = ["*lead"] + for index in range(alternatives): + definitions.append( + f'pad_{index} = ("",) if pad_{index}_enabled else ("", "")\n' + ) + arguments.append(f"*pad_{index}") + return "".join(definitions) + f"Path({', '.join(arguments)})\n" + + +def test_starred_path_constructor_fails_closed_when_default_cap_omits_auth_ordering( + tmp_path: Path, +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text( + _starred_path_product_source(alternatives=7, auth_first=False), + encoding="utf-8", + ) + + findings = module.scan_repository(tmp_path) + + assert [(item.path, item.kind) for item in findings] == [ + ("consumer.py", "constructed_path") + ] + + +@pytest.mark.parametrize("auth_first", [True, False]) +def test_starred_path_constructor_cap_is_order_independent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, auth_first: bool +) -> None: + module = _load_module() + monkeypatch.setattr(module, "_MAX_FLOW_ALTERNATIVES", 2) + (tmp_path / "consumer.py").write_text( + _starred_path_product_source(alternatives=1, auth_first=auth_first), + encoding="utf-8", + ) + + findings = module.scan_repository(tmp_path) + + assert [(item.path, item.kind) for item in findings] == [ + ("consumer.py", "constructed_path") + ] + + +@pytest.mark.parametrize("alternatives", [1, 2]) +def test_starred_non_auth_path_at_or_below_cap_does_not_fail_closed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + alternatives: int, +) -> None: + module = _load_module() + monkeypatch.setattr(module, "_MAX_FLOW_ALTERNATIVES", 4) + source = _starred_path_product_source( + alternatives=alternatives, auth_first=False + ).replace('("auth.json",)', '("other.json",)') + (tmp_path / "harmless.py").write_text(source, encoding="utf-8") + + assert module.scan_repository(tmp_path) == [] + + +def test_nested_sequence_unpacking_fails_closed_on_structured_product_overflow( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + module = _load_module() + monkeypatch.setattr(module, "_MAX_STRUCTURED_ALTERNATIVES", 2) + (tmp_path / "consumer.py").write_text( + "from pathlib import Path\n" + 'left = ("left-a",) if first else ("left-b",)\n' + 'right = ("right-a",) if second else ("right-b",)\n' + "parts = (*left, *right)\n" + "Path(*parts)\n", + encoding="utf-8", + ) + + findings = module.scan_repository(tmp_path) + + assert [(item.path, item.line, item.kind) for item in findings] == [ + ("consumer.py", 5, "constructed_path") + ] + + +@pytest.mark.parametrize( + "source", + [ + pytest.param( + "from pathlib import Path\n" + "def provide():\n" + ' return ("auth.json",)\n' + "Path(*(*provide(),))\n", + id="function-return-nested-star", + ), + pytest.param( + "from pathlib import Path\n" + 'provide = lambda: ["auth.json"]\n' + "Path(*[*provide()])\n", + id="lambda-return-nested-star", + ), + pytest.param( + "from pathlib import Path\n" + "def provide():\n" + ' return ("auth.json",)\n' + "parts = provide()\n" + "Path(*parts)\n", + id="assigned-call-result", + ), + pytest.param( + "from pathlib import Path\n" + "def provide(inner):\n" + " return (*inner,)\n" + 'Path(*provide(("auth.json",)))\n', + id="bounded-wrapper-return", + ), + ], +) +def test_callable_returned_sequences_reach_starred_path_constructor( + tmp_path: Path, source: str +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text(source, encoding="utf-8") + + findings = module.scan_repository(tmp_path) + + assert [(item.path, item.kind) for item in findings] == [ + ("consumer.py", "constructed_path") + ] + + +@pytest.mark.parametrize( + "source", + [ + pytest.param( + "from pathlib import Path\n" + "def provide():\n" + ' return ("other.json",)\n' + "Path(*(*provide(),))\n", + id="non-auth-return", + ), + pytest.param( + "from pathlib import Path\n" + "if enabled:\n" + " def provide():\n" + ' return ("other.json",)\n' + " Path(*provide())\n" + "else:\n" + ' parts = ("auth.json",)\n', + id="impossible-sibling", + ), + ], +) +def test_callable_returned_sequences_do_not_create_false_positive( + tmp_path: Path, source: str +) -> None: + module = _load_module() + (tmp_path / "harmless.py").write_text(source, encoding="utf-8") + + assert module.scan_repository(tmp_path) == [] + + +def test_callable_returned_sequence_fails_closed_on_overflow( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + module = _load_module() + monkeypatch.setattr(module, "_MAX_STRUCTURED_ALTERNATIVES", 1) + (tmp_path / "consumer.py").write_text( + "from pathlib import Path\n" + "def provide():\n" + ' return ("other.json",) if enabled else ("auth.json",)\n' + "Path(*provide())\n", + encoding="utf-8", + ) + + findings = module.scan_repository(tmp_path) + + assert [(item.path, item.line, item.kind) for item in findings] == [ + ("consumer.py", 4, "constructed_path") + ] + + +@pytest.mark.parametrize("size", [1000, 3000, 6000]) +def test_large_flat_sequence_analysis_has_linear_work(size: int) -> None: + module = _load_module() + source = "parts = [" + ",".join(f"'part-{index}'" for index in range(size)) + "]\n" + analyzer = module._PythonFlowAnalyzer("harmless.py") + + findings = analyzer.analyze(ast.parse(source)) + + assert findings == [] + assert analyzer.sequence_expansion_work <= size * 2 + + +def test_scan_ignores_user_call_that_only_returns_auth_filename( + tmp_path: Path, +) -> None: + module = _load_module() + (tmp_path / "harmless.py").write_text( + 'def provider_label():\n return "auth.json"\n' + "label = provider_label()\n", + encoding="utf-8", + ) + + assert module.scan_repository(tmp_path) == [] + + +@pytest.mark.parametrize( + ("source", "expected_line"), + [ + pytest.param( + 'def target():\n return "auth.json"\n' + "target().read_text()\n", + 3, + id="literal-return", + ), + pytest.param( + "def target(stem, suffix):\n" + ' return f"{stem}.{suffix}"\n' + 'target("auth", "json").read_text()\n', + 3, + id="constructed-return", + ), + ], +) +def test_audit_rejects_io_on_auth_store_returned_by_wrapper( + tmp_path: Path, source: str, expected_line: int +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text(source, encoding="utf-8") + + unclassified, stale = module.audit(tmp_path, _inventory(tmp_path, {})) + + assert [(item.path, item.line, item.kind) for item in unclassified] == [ + ("consumer.py", expected_line, "read_text") + ] + assert stale == [] + + (tmp_path / "consumer.py").unlink() + (tmp_path / "harmless.py").write_text( + source.replace('"auth.json"', '"other.json"').replace('"auth"', '"other"'), + encoding="utf-8", + ) + assert module.scan_repository(tmp_path) == [] + + +def test_direct_wrapper_analysis_memoizes_duplicated_depth_twenty_graph() -> None: + module = _load_module() + depth = 20 + definitions = [] + for index in range(depth): + definitions.append( + f"def hop_{index}(path):\n" + f" hop_{index + 1}(path)\n" + f" hop_{index + 1}(path)\n" + ) + definitions.append( + f"def hop_{depth}(path):\n" + ' return open(path, "rb")\n' + ) + source = "".join(definitions) + 'hop_0("auth.json")\n' + analyzer = module._PythonFlowAnalyzer("consumer.py") + + findings = analyzer.analyze(ast.parse(source)) + + assert [(item.path, item.kind) for item in findings] == [ + ("consumer.py", "open") + ] + assert analyzer.direct_function_work <= depth + 1 + + +def test_direct_wrapper_analysis_rejects_depth_twenty_one_graph() -> None: + module = _load_module() + depth = 21 + definitions = [] + for index in range(depth): + definitions.append( + f"def hop_{index}(path):\n" + f" return hop_{index + 1}(path)\n" + ) + definitions.append( + f"def hop_{depth}(path):\n" + ' return open(path, "rb")\n' + ) + source = "".join(definitions) + 'hop_0("auth.json")\n' + + findings = module._PythonFlowAnalyzer("consumer.py").analyze(ast.parse(source)) + + assert "analysis_overflow" in {item.kind for item in findings} + + +def test_direct_wrapper_analysis_fails_closed_when_work_budget_is_exhausted( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + module = _load_module() + monkeypatch.setattr(module, "_MAX_DIRECT_CALL_WORK", 0) + (tmp_path / "consumer.py").write_text( + 'def consume(path):\n return open(path, "rb")\n' + 'consume("auth.json")\n', + encoding="utf-8", + ) + + findings = module.scan_repository(tmp_path) + + assert [(item.path, item.line, item.kind) for item in findings] == [ + ("consumer.py", 3, "analysis_overflow") + ] + + +def test_direct_wrapper_memoization_includes_environment_state(tmp_path: Path) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text( + 'target_name = "other.json"\n' + "def target():\n" + " return target_name\n" + "target().read_text()\n" + 'target_name = "auth.json"\n' + "target().read_text()\n", + encoding="utf-8", + ) + + findings = module.scan_repository(tmp_path) + + assert [(item.path, item.line, item.kind) for item in findings] == [ + ("consumer.py", 6, "read_text") + ] + + +@pytest.mark.parametrize( + ("source", "expected_line", "expected_kind"), + [ + pytest.param( + "from builtins import open as auth_open\n" + 'auth_open("auth.json", "rb")\n', + 2, + "open", + id="imported-builtin-open-alias", + ), + pytest.param( + "import builtins as builtin_api\n" + 'builtin_api.open("auth.json", "rb")\n', + 2, + "open", + id="builtins-module-alias", + ), + pytest.param( + "from pathlib import Path as AuthPath\n" + 'AuthPath("auth.json").read_text()\n', + 2, + "read_text", + id="imported-path-alias", + ), + pytest.param( + "import pathlib as path_api\n" + 'path_api.Path("auth.json").read_text()\n', + 2, + "read_text", + id="pathlib-module-alias", + ), + pytest.param( + "from pathlib import Path\n" + "def load_auth():\n" + ' auth_store = "auth.json"\n' + " return Path(auth_store).read_text()\n", + 4, + "read_text", + id="function-local-auth-store-binding", + ), + ], +) +def test_audit_rejects_aliased_or_function_local_auth_store_consumers( + tmp_path: Path, source: str, expected_line: int, expected_kind: str +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text(source, encoding="utf-8") + + unclassified, stale = module.audit(tmp_path, _inventory(tmp_path, {})) + + assert [(item.path, item.line, item.kind) for item in unclassified] == [ + ("consumer.py", expected_line, expected_kind) + ] + assert stale == [] + + +@pytest.mark.parametrize( + ("source", "expected_kind"), + [ + pytest.param( + 'auth_open = open\nauth_open("auth.json", "rb")\n', + "open", + id="assigned-builtin-open", + ), + pytest.param( + "import builtins\n" + "auth_open = builtins.open\n" + 'auth_open("auth.json", "rb")\n', + "open", + id="assigned-builtins-open", + ), + pytest.param( + "from pathlib import Path\n" + "AuthPath = Path\n" + 'AuthPath("auth.json").read_text()\n', + "read_text", + id="assigned-path-constructor", + ), + pytest.param( + "import pathlib\n" + "AuthPath = pathlib.Path\n" + 'AuthPath("auth.json").read_text()\n', + "read_text", + id="assigned-pathlib-constructor", + ), + ], +) +def test_audit_rejects_ordinary_assignment_aliases( + tmp_path: Path, source: str, expected_kind: str +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text(source, encoding="utf-8") + + unclassified, stale = module.audit(tmp_path, _inventory(tmp_path, {})) + + assert [(item.path, item.kind) for item in unclassified] == [ + ("consumer.py", expected_kind) + ] + assert stale == [] + + +@pytest.mark.parametrize( + ("source", "expected_line"), + [ + pytest.param( + "from pathlib import Path\n" + 'AUTH = "auth.json"\n' + "def read_auth():\n" + " global AUTH\n" + " value = Path(AUTH).read_text()\n" + " AUTH = input()\n" + " return value\n", + 5, + id="global-static-read-before-rebind", + ), + pytest.param( + "from pathlib import Path\n" + "def outer():\n" + ' auth_store = "auth.json"\n' + " def read_auth():\n" + " nonlocal auth_store\n" + " value = Path(auth_store).read_text()\n" + " auth_store = input()\n" + " return value\n" + " return read_auth\n", + 6, + id="nonlocal-static-read-before-rebind", + ), + pytest.param( + "from pathlib import Path\n" + "def read_auth(enabled):\n" + " if enabled:\n" + ' target = "auth.json"\n' + " else:\n" + " target = input()\n" + " return Path(target).read_text()\n", + 7, + id="control-flow-static-possibility", + ), + pytest.param( + "from pathlib import Path\n" + 'def read_auth(target="auth.json"):\n' + " return Path(target).read_text()\n", + 3, + id="static-default-argument", + ), + pytest.param( + "from pathlib import Path\n" + "class Reader:\n" + " Path = object()\n" + " def read_auth(self):\n" + ' return Path("auth.json").read_text()\n', + 5, + id="method-skips-class-namespace", + ), + pytest.param( + "from pathlib import Path\n" + "class Reader:\n" + ' auth = Path("auth.json").read_text()\n' + " Path = object()\n", + 3, + id="class-body-uses-sequential-bindings", + ), + pytest.param( + "from pathlib import Path\n" + "def read_auth():\n" + " global AUTH\n" + " return Path(AUTH).read_text()\n" + 'AUTH = "auth.json"\n', + 4, + id="global-binding-assigned-after-function-definition", + ), + pytest.param( + "if enabled:\n" + " def read_auth():\n" + " return AUTH.read_text()\n" + 'AUTH = "auth.json"\n', + 3, + id="branch-function-uses-later-module-binding", + ), + pytest.param( + "class Reader:\n" + " def read_auth(self):\n" + " return AUTH.read_text()\n" + 'AUTH = "auth.json"\n', + 3, + id="method-uses-later-module-binding", + ), + pytest.param( + "from pathlib import Path\n" + "def outer():\n" + " def read_auth():\n" + " return Path(AUTH).read_text()\n" + ' AUTH = "auth.json"\n' + " return read_auth\n", + 4, + id="closure-binding-assigned-after-inner-definition", + ), + pytest.param( + "from pathlib import Path\n" + 'consumer = lambda: Path("auth.json").read_text()\n', + 2, + id="lambda-consumer", + ), + pytest.param( + 'AUTH = "other.json"\n' + "consumer = lambda: AUTH.read_text()\n" + 'AUTH = "auth.json"\n', + 2, + id="lambda-late-bound-module-binding", + ), + ], +) +def test_audit_rejects_scope_valid_static_bindings( + tmp_path: Path, source: str, expected_line: int +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text(source, encoding="utf-8") + + unclassified, stale = module.audit(tmp_path, _inventory(tmp_path, {})) + + assert [(item.path, item.line, item.kind) for item in unclassified] == [ + ("consumer.py", expected_line, "read_text") + ] + assert stale == [] + + +@pytest.mark.parametrize( + "expression", + [ + "'/'.join((root, 'auth.json'))", + "Path(root).joinpath('.'.join(('auth', 'json')))", + "root + '/{}{}'.format('auth', '.json')", + ], +) +def test_audit_rejects_joined_and_formatted_auth_store_paths( + tmp_path: Path, expression: str +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text( + f"from pathlib import Path\nroot = input()\nopen({expression}, 'rb')\n", + encoding="utf-8", + ) + + unclassified, stale = module.audit(tmp_path, _inventory(tmp_path, {})) + + assert [(item.path, item.line, item.kind) for item in unclassified] == [ + ("consumer.py", 3, "open") + ] + assert stale == [] + + +@pytest.mark.parametrize( + ("source", "expected_line", "expected_kind"), + [ + ( + "auth_open = open if enabled else helper\n" + 'auth_open("auth.json", "rb")\n', + 2, + "open", + ), + ( + "from pathlib import Path\n" + "AuthPath = Path if enabled else Factory\n" + 'AuthPath("auth.json").read_text()\n', + 3, + "read_text", + ), + ( + "from pathlib import Path\n" + 'target = "auth.json" if enabled else "other.json"\n' + "Path(target).read_text()\n", + 3, + "read_text", + ), + ( + "from pathlib import Path\n" + 'Path("%s.%s" % ("auth", "json")).read_text()\n', + 2, + "read_text", + ), + pytest.param( + "from pathlib import Path\n" + 'Path("%(stem)s.%(suffix)s" % ' + '{"stem": "auth", "suffix": "json"}).read_text()\n', + 2, + "read_text", + id="mapping-percent-format", + ), + pytest.param( + "from pathlib import Path\n" + 'parts = {"stem": "auth", "suffix": "json"}\n' + 'Path("%(stem)s.%(suffix)s" % parts).read_text()\n', + 3, + "read_text", + id="mapping-percent-format-static-binding", + ), + ], +) +def test_audit_rejects_conditional_and_percent_formatted_auth_paths( + tmp_path: Path, source: str, expected_line: int, expected_kind: str +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text(source, encoding="utf-8") + + unclassified, stale = module.audit(tmp_path, _inventory(tmp_path, {})) + + assert [(item.path, item.line, item.kind) for item in unclassified] == [ + ("consumer.py", expected_line, expected_kind) + ] + assert stale == [] + + +def test_scan_ignores_mapping_percent_format_of_other_path(tmp_path: Path) -> None: + module = _load_module() + (tmp_path / "harmless.py").write_text( + "from pathlib import Path\n" + 'parts = {"stem": "other", "suffix": "json"}\n' + 'Path("%(stem)s.%(suffix)s" % parts).read_text()\n', + encoding="utf-8", + ) + + assert module.scan_repository(tmp_path) == [] + + +@pytest.mark.parametrize( + ("source", "expected_line"), + [ + pytest.param( + "class Base:\n" + " def read(self, path):\n" + ' return open(path, "rb")\n' + "class Child(Base):\n" + " pass\n" + 'Child().read("auth.json")\n', + 3, + id="inherited-instance-method", + ), + pytest.param( + "class Consumer:\n" + " def __call__(self, path):\n" + ' return open(path, "rb")\n' + 'Consumer()("auth.json")\n', + 3, + id="callable-instance", + ), + ], +) +def test_audit_rejects_auth_store_through_object_protocol_wrappers( + tmp_path: Path, source: str, expected_line: int +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text(source, encoding="utf-8") + + findings = module.scan_repository(tmp_path) + + assert [(item.path, item.line, item.kind) for item in findings] == [ + ("consumer.py", expected_line, "open") + ] + + (tmp_path / "consumer.py").unlink() + (tmp_path / "harmless.py").write_text( + source.replace('"auth.json"', '"other.json"'), encoding="utf-8" + ) + assert module.scan_repository(tmp_path) == [] + + +@pytest.mark.parametrize( + ("source", "expected_line"), + [ + pytest.param( + "from pathlib import Path\n" + 'target, other = ("auth.json", "other.json")\n' + "Path(target).read_text()\n", + 3, + id="tuple-unpacked-static-path", + ), + pytest.param( + "from pathlib import Path\n" + 'for target in ("auth.json", "other.json"):\n' + " Path(target).read_text()\n", + 3, + id="for-loop-static-path", + ), + pytest.param( + "from pathlib import Path\n" + '(target := "auth.json")\n' + "Path(target).read_text()\n", + 3, + id="walrus-static-path", + ), + pytest.param( + "from pathlib import Path\n" + "if enabled:\n" + ' choice = "auth"\n' + "else:\n" + ' choice = "other"\n' + 'Path("{}.json".format(choice)).read_text()\n', + 6, + id="format-preserves-all-branch-alternatives", + ), + pytest.param( + 'match value:\n case _:\n open("auth.json", "rb")\n', + 3, + id="match-case-consumer", + ), + pytest.param( + "from pathlib import Path\n" + '[Path(target).read_text() for target in ("auth.json",)]\n', + 2, + id="comprehension-static-path", + ), + pytest.param( + "from pathlib import Path\n" + '[(target := "auth.json") for _ in values]\n' + "Path(target).read_text()\n", + 3, + id="comprehension-walrus-binds-containing-scope", + ), + pytest.param( + "from pathlib import Path\n" + "try:\n" + ' target = "auth.json"\n' + " operation()\n" + " target = input()\n" + "except Exception:\n" + " Path(target).read_text()\n", + 7, + id="except-handler-sees-try-prefix-binding", + ), + ], +) +def test_audit_rejects_static_assignment_expression_variants( + tmp_path: Path, source: str, expected_line: int +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text(source, encoding="utf-8") + + unclassified, stale = module.audit(tmp_path, _inventory(tmp_path, {})) + + assert [(item.path, item.line) for item in unclassified] == [ + ("consumer.py", expected_line) + ] + assert stale == [] + + +@pytest.mark.parametrize( + ("alternative_count", "expected_kind"), + [(127, None), (131, "read_text")], +) +def test_mapping_percent_alternative_boundary( + tmp_path: Path, alternative_count: int, expected_kind: str | None +) -> None: + module = _load_module() + source = ["from pathlib import Path\n", "stem = 'safe_0'\n"] + for index in range(1, alternative_count): + source.append(f"if flag_{index}:\n stem = 'safe_{index}'\n") + source.extend( + [ + "parts = {'stem': stem, 'suffix': 'json'}\n", + "Path('%(stem)s.%(suffix)s' % parts).read_text()\n", + ] + ) + (tmp_path / "consumer.py").write_text("".join(source), encoding="utf-8") + + findings = module.scan_repository(tmp_path) + + assert [item.kind for item in findings] == ( + [] if expected_kind is None else [expected_kind] + ) + + +def test_static_alternative_overflow_is_bounded_and_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + module = _load_module() + monkeypatch.setattr(module, "_MAX_FLOW_ALTERNATIVES", 4) + (tmp_path / "consumer.py").write_text( + "from pathlib import Path\n" + "if one:\n left = 'a'\nelse:\n left = 'b'\n" + "if two:\n middle = 'c'\nelse:\n middle = 'd'\n" + "if three:\n right = 'e'\nelse:\n right = 'f'\n" + "Path('{}{}{}'.format(left, middle, right)).read_text()\n", + encoding="utf-8", + ) + + unclassified, stale = module.audit(tmp_path, _inventory(tmp_path, {})) + + assert [(item.path, item.line, item.kind) for item in unclassified] == [ + ("consumer.py", 14, "read_text") + ] + assert stale == [] + + +@pytest.mark.parametrize( + "source", + [ + 'def harmless(open):\n return open("auth.json", "rb")\n', + 'def harmless(Path):\n return Path("auth.json").read_text()\n', + "def installs_alias():\n" + " from builtins import open as auth_open\n" + " return auth_open\n" + "def harmless():\n" + ' return auth_open("auth.json", "rb")\n', + 'def open(path, mode):\n return (path, mode)\nopen("auth.json", "rb")\n', + 'open = object()\nopen("auth.json", "rb")\n', + "from pathlib import Path\n" + "class Reader:\n" + " AuthPath = Path\n" + " def harmless(self):\n" + ' return AuthPath("auth.json").read_text()\n', + 'consumer = lambda open: open("auth.json", "rb")\n', + 'AUTH = "auth.json"\n' + "consumer = lambda: AUTH.read_text()\n" + 'AUTH = "other.json"\n', + '[open("auth.json", "rb") for open in funcs]\n', + "from pathlib import Path\n" + "try:\n" + " operation()\n" + "except Exception as Path:\n" + ' Path("auth.json").read_text()\n', + "def harmless():\n" + ' open("auth.json", "rb")\n' + " try:\n" + " operation()\n" + " except Exception as open:\n" + " pass\n", + 'consumer = lambda: (open("auth.json", "rb"), (open := factory()))\n', + "match value:\n" + " case open:\n" + " pass\n" + 'open("auth.json", "rb")\n', + ], +) +def test_scan_ignores_scope_shadowed_or_unrelated_callables( + tmp_path: Path, source: str +) -> None: + module = _load_module() + (tmp_path / "harmless.py").write_text(source, encoding="utf-8") + + assert module.scan_repository(tmp_path) == [] + + +def test_scan_ignores_deferred_function_from_impossible_sibling_branch( + tmp_path: Path, +) -> None: + module = _load_module() + (tmp_path / "harmless.py").write_text( + "from pathlib import Path\n" + "if enabled:\n" + " def read_auth():\n" + " return Path(AUTH).read_text()\n" + ' AUTH = "other.json"\n' + "else:\n" + ' AUTH = "auth.json"\n', + encoding="utf-8", + ) + + assert module.scan_repository(tmp_path) == [] + + +@pytest.mark.parametrize( + "source", + [ + pytest.param( + "from pathlib import Path\n" + "if enabled:\n" + " def build(path):\n" + " return Path(path)\n" + ' build("other.json")\n' + "else:\n" + ' target = "auth.json"\n', + id="constructed-wrapper", + ), + pytest.param( + "if enabled:\n" + " class Base:\n" + " def read(self, path):\n" + ' return open(path, "rb")\n' + " class Child(Base):\n" + " pass\n" + ' Child().read("other.json")\n' + "else:\n" + ' target = "auth.json"\n', + id="inherited-method", + ), + pytest.param( + "from pathlib import Path\n" + "if enabled:\n" + ' parts = {"stem": "other", "suffix": "json"}\n' + ' Path("%(stem)s.%(suffix)s" % parts).read_text()\n' + "else:\n" + ' parts = {"stem": "auth", "suffix": "json"}\n', + id="mapping-percent-binding", + ), + pytest.param( + "from pathlib import Path\n" + "if enabled:\n" + ' parts = (*(\"other.json\",),)\n' + " Path(*parts)\n" + "else:\n" + ' parts = (*(\"auth.json\",),)\n', + id="nested-sequence-unpacking", + ), + ], +) +def test_scan_ignores_new_wrapper_flows_from_impossible_sibling_branch( + tmp_path: Path, source: str +) -> None: + module = _load_module() + (tmp_path / "harmless.py").write_text(source, encoding="utf-8") + + assert module.scan_repository(tmp_path) == [] + + +@pytest.mark.parametrize( + ("filename", "source", "expected_line"), + [ + ("consumer.mjs", "const p = path.join(root, 'auth' + '.json');\n", 1), + ("consumer.ts", "const p = `auth` + `.json`;\n", 1), + ("consumer.cjs", "const p = 'auth' +\n '.json';\n", 1), + ("consumer.sh", "target=$HERMES_HOME/'auth'\".json\"\n", 1), + ("continued.sh", "target=$HERMES_HOME/'auth'\\\n'.json'\n", 1), + ("consumer.nix", 'target = stateDir + "/auth" + ".json";\n', 1), + ], +) +def test_scan_rejects_split_non_python_auth_store_paths( + tmp_path: Path, filename: str, source: str, expected_line: int +) -> None: + module = _load_module() + (tmp_path / filename).write_text(source, encoding="utf-8") + + findings = module.scan_repository(tmp_path) + + assert [(item.path, item.line, item.kind) for item in findings] == [ + (filename, expected_line, "text_reference") + ] + + +def test_split_non_python_fragment_overflow_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + module = _load_module() + monkeypatch.setattr(module, "_MAX_TEXT_FRAGMENT_CHAIN", 2) + (tmp_path / "consumer.mjs").write_text( + "const target = 'safe' + '' + '' + '.json';\n", + encoding="utf-8", + ) + + findings = module.scan_repository(tmp_path) + + assert [(item.path, item.line, item.kind) for item in findings] == [ + ("consumer.mjs", 1, "text_reference") + ] + + +@pytest.mark.parametrize( + ("filename", "source"), + [ + ("harmless.mjs", "// auth.json is managed by the authority\n"), + ("harmless.mjs", "/* auth.json is managed by the authority */\n"), + ("harmless.sh", "# auth.json is managed by the authority\n"), + ("harmless.nix", "# auth.json is managed by the authority\n"), + ("harmless.sh", "printf '%s %s\\n' 'auth' '.json'\n"), + ("harmless.nix", 'parts = [ "auth" ".json" ];\n'), + ("harmless.sh", "printf '%s %s %s\\n' 'auth' + '.json'\n"), + ], +) +def test_scan_ignores_non_python_comment_only_mentions( + tmp_path: Path, filename: str, source: str +) -> None: + module = _load_module() + (tmp_path / filename).write_text(source, encoding="utf-8") + + assert module.scan_repository(tmp_path) == [] + + +@pytest.mark.parametrize( + ("method", "arguments"), + [ + ("read_text", ""), + ("read_bytes", ""), + ("write_text", '"replacement"'), + ("write_bytes", 'b"replacement"'), + ("open", '"rb"'), + ], +) +def test_audit_rejects_constant_bound_path_io_consumer( + tmp_path: Path, method: str, arguments: str +) -> None: + module = _load_module() + (tmp_path / "consumer.py").write_text( + "from pathlib import Path\n" + 'AUTH_STORE = "auth.json"\n' + f"Path(AUTH_STORE).{method}({arguments})\n", + encoding="utf-8", + ) + + unclassified, stale = module.audit(tmp_path, _inventory(tmp_path, {})) + + assert [(item.path, item.line, item.kind) for item in unclassified] == [ + ("consumer.py", 3, method) + ] + assert stale == [] + + +def test_inventory_rejects_unapproved_category(tmp_path: Path) -> None: + module = _load_module() + inventory = _inventory( + tmp_path, + {"consumer.py": _entry("looks_safe", "canonical_auth_authority")}, + ) + + with pytest.raises(ValueError, match="unapproved category"): + module.load_inventory(inventory) + + +def test_inventory_rejects_unapproved_reason(tmp_path: Path) -> None: + module = _load_module() + inventory = _inventory( + tmp_path, + { + "consumer.py": _entry( + "canonical_authority_owner", "reviewed_by_someone" + ) + }, + ) + + with pytest.raises(ValueError, match="unapproved reason"): + module.load_inventory(inventory) + + +def test_scan_rejects_missing_root(tmp_path: Path) -> None: + module = _load_module() + + with pytest.raises(ValueError, match="scan root"): + module.scan_repository(tmp_path / "missing") + + +def test_inventory_rejects_unknown_top_level_keys(tmp_path: Path) -> None: + module = _load_module() + inventory = tmp_path / "inventory.json" + inventory.write_text( + json.dumps({"version": 2, "consumers": {}, "allow_all": True}), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="exactly version and consumers"): + module.load_inventory(inventory) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 24f1b72de94b..7a726aad1a45 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -9463,25 +9463,19 @@ def is_alive(self): assert session["running"] is False # #71184 upgraded failure delivery from a bare "error" event to a # terminal message.complete frame (status=error, recoverable) so - # failed turns are retained as replayable inflight snapshots. The - # contract this test pins is unchanged: the build failure must reach - # the client VISIBLY — never a silent drop. - failure_frames = [ - e - for e in emitted - if e - and e[0] in ("error", "message.complete") - and e[1] == "sid" - and ( - "No LLM provider configured" in str(e[2].get("message", "")) - or "No LLM provider configured" in str(e[2].get("error", "")) - or "No LLM provider configured" in str(e[2].get("text", "")) - ) + # failed turns are retained as replayable inflight snapshots. Pin the + # stronger terminal-frame contract: the build failure must reach the + # client visibly and exactly once, never as a silent drop. + complete_events = [ + e for e in emitted if e and e[0] == "message.complete" and e[1] == "sid" ] - assert len(failure_frames) == 1, f"expected one visible failure frame, got: {emitted}" - frame = failure_frames[0] - if frame[0] == "message.complete": - assert frame[2].get("status") == "error" + assert len(complete_events) == 1, ( + f"expected one terminal error completion, got: {emitted}" + ) + payload = complete_events[0][2] + assert payload.get("status") == "error" + assert payload.get("recoverable") is True + assert "No LLM provider configured" in payload.get("error", "") finally: server._sessions.pop("sid", None) diff --git a/tests/tools/test_docker_auth_authority.py b/tests/tools/test_docker_auth_authority.py new file mode 100644 index 000000000000..35a72c4e0f82 --- /dev/null +++ b/tests/tools/test_docker_auth_authority.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + + +_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "docker_auth_authority.py" +_SPEC = importlib.util.spec_from_file_location("docker_auth_authority", _SCRIPT) +assert _SPEC and _SPEC.loader +_MOD = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(_MOD) + + +def test_shared_profile_resolves_root_store(tmp_path: Path) -> None: + root = tmp_path / ".hermes" + profile = root / "profiles" / "work" + profile.mkdir(parents=True) + (profile / "config.yaml").write_text("auth:\n authority: shared\n", encoding="utf-8") + + result = _MOD.resolve_auth_authority(str(profile)) + + assert result["authority"] == "shared" + assert result["auth_path"] == str(root / "auth.json") + + +def test_absent_authority_preserves_existing_profile_local_store(tmp_path: Path) -> None: + root = tmp_path / ".hermes" + profile = root / "profiles" / "work" + profile.mkdir(parents=True) + (profile / "auth.json").write_text("{}", encoding="utf-8") + + result = _MOD.resolve_auth_authority(str(profile)) + + assert result["authority"] == "profile" + assert result["legacy_compatibility"] is True + assert result["auth_path"] == str(profile / "auth.json") + + +def test_invalid_authority_fails_closed(tmp_path: Path) -> None: + home = tmp_path / ".hermes" + home.mkdir() + (home / "config.yaml").write_text("auth:\n authority: elsewhere\n", encoding="utf-8") + + with pytest.raises(ValueError, match="Invalid auth.authority"): + _MOD.resolve_auth_authority(str(home)) + + +def test_cli_emits_machine_readable_result(tmp_path: Path, capsys, monkeypatch) -> None: + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setattr("sys.argv", [str(_SCRIPT), str(home)]) + + assert _MOD.main() == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["auth_path"] == str(home / "auth.json") + + +def test_update_uses_canonical_shared_authority(tmp_path: Path) -> None: + root = tmp_path / ".hermes" + profile = root / "profiles" / "worker" + profile.mkdir(parents=True) + (profile / "config.yaml").write_text("auth:\n authority: shared\n", encoding="utf-8") + shared = root / "auth.json" + shared.write_text('{"providers": {"nous": {"status": "expired"}}}', encoding="utf-8") + + def refresh(store: dict) -> tuple[str, dict | None]: + store["providers"]["nous"] = {"status": "valid"} + return "reseeded", store + + assert _MOD.update_auth_store(profile, refresh) == "reseeded" + assert json.loads(shared.read_text(encoding="utf-8"))["providers"]["nous"] == {"status": "valid"} + assert not (profile / "auth.json").exists() + + +def test_update_rejects_target_symlink(tmp_path: Path) -> None: + root = tmp_path / ".hermes" + profile = root / "profiles" / "worker" + profile.mkdir(parents=True) + (profile / "config.yaml").write_text("auth:\n authority: shared\n", encoding="utf-8") + outside = tmp_path / "outside.json" + outside.write_text("{}", encoding="utf-8") + (root / "auth.json").symlink_to(outside) + + with pytest.raises(RuntimeError, match="symlink"): + _MOD.update_auth_store(profile, lambda store: ("unchanged", None)) + + +def test_internal_authority_bridge_must_match_contained_configured_target( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / ".hermes" + profile = root / "profiles" / "worker" + profile.mkdir(parents=True) + (profile / "config.yaml").write_text( + "auth:\n authority: profile\n", encoding="utf-8" + ) + expected = profile / "auth.json" + + monkeypatch.setenv("HERMES_INTERNAL_AUTHORITY_PATH", str(expected)) + assert _MOD.resolve_auth_authority(str(profile))["auth_path"] == str(expected) + + monkeypatch.setenv("HERMES_INTERNAL_AUTHORITY_PATH", str(root / "auth.json")) + with pytest.raises(ValueError, match="does not match"): + _MOD.resolve_auth_authority(str(profile)) + + monkeypatch.setenv( + "HERMES_INTERNAL_AUTHORITY_PATH", str(tmp_path / "outside" / "auth.json") + ) + with pytest.raises(ValueError, match="inside the Hermes root"): + _MOD.resolve_auth_authority(str(profile)) + + +def test_update_aborts_if_authority_cuts_over_while_waiting_for_lock( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / ".hermes" + profile = root / "profiles" / "worker" + profile.mkdir(parents=True) + config = profile / "config.yaml" + config.write_text("auth:\n authority: shared\n", encoding="utf-8") + shared = root / "auth.json" + shared.write_text('{"providers": {}}', encoding="utf-8") + real_flock = _MOD.fcntl.flock + cut_over = False + + def flock_then_cut_over(fd: int, operation: int) -> None: + nonlocal cut_over + real_flock(fd, operation) + if operation == _MOD.fcntl.LOCK_EX and not cut_over: + cut_over = True + config.write_text("auth:\n authority: profile\n", encoding="utf-8") + + monkeypatch.setattr(_MOD.fcntl, "flock", flock_then_cut_over) + + with pytest.raises(RuntimeError, match="changed while waiting"): + _MOD.update_auth_store( + profile, lambda store: ("written", {"providers": {"new": {}}}) + ) + + assert json.loads(shared.read_text(encoding="utf-8")) == {"providers": {}} + assert not (profile / "auth.json").exists() + + +def test_temporary_replace_failure_preserves_complete_previous_store( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = tmp_path / ".hermes" + home.mkdir() + auth = home / "auth.json" + auth.write_text('{"providers": {"current": {}}}', encoding="utf-8") + + def fail_replace(_source, _destination) -> None: + raise OSError("temporary filesystem failure") + + monkeypatch.setattr(_MOD.os, "replace", fail_replace) + with pytest.raises(OSError, match="temporary filesystem failure"): + _MOD.update_auth_store( + home, lambda store: ("written", {"providers": {"replacement": {}}}) + ) + + assert json.loads(auth.read_text(encoding="utf-8")) == { + "providers": {"current": {}} + } + assert not list(home.glob(".auth-update-*")) + + +@pytest.mark.parametrize( + "config_text", + [ + "auth: {authority: profile}\n", + "{auth: {authority: profile}}\n", + ], +) +def test_inline_yaml_authority_matches_block_yaml( + tmp_path: Path, config_text: str +) -> None: + root = tmp_path / ".hermes" + profile = root / "profiles" / "work" + profile.mkdir(parents=True) + (profile / "config.yaml").write_text(config_text, encoding="utf-8") + + result = _MOD.resolve_auth_authority(str(profile)) + + assert result["authority"] == "profile" + assert result["auth_path"] == str(profile / "auth.json") + + +@pytest.mark.parametrize( + "config_text", + [ + "auth: []\n", + "auth: {authority: [profile]}\n", + "auth: {authority: profile\n", + ], +) +def test_malformed_authority_yaml_fails_closed( + tmp_path: Path, config_text: str +) -> None: + root = tmp_path / ".hermes" + profile = root / "profiles" / "work" + profile.mkdir(parents=True) + (profile / "config.yaml").write_text(config_text, encoding="utf-8") + + with pytest.raises(RuntimeError, match="auth authority config"): + _MOD.resolve_auth_authority(str(profile)) diff --git a/tests/tools/test_docker_rebootstrap_nous_session.py b/tests/tools/test_docker_rebootstrap_nous_session.py index abd9468d9b0b..2ddc4cb95b78 100644 --- a/tests/tools/test_docker_rebootstrap_nous_session.py +++ b/tests/tools/test_docker_rebootstrap_nous_session.py @@ -10,6 +10,10 @@ import importlib.util import json +import multiprocessing +import os +import subprocess +import sys from pathlib import Path # Import the stdlib-only boot helper by path (it lives under scripts/, not an @@ -71,6 +75,70 @@ def test_reseeds_terminal_entry(tmp_path): assert "last_auth_error" not in store["providers"]["nous"] +def test_profile_reseed_uses_docker_canonical_shared_authority( + tmp_path, monkeypatch +): + """Rebootstrap must share Docker bootstrap's resolver and lock domain.""" + root = tmp_path / ".hermes" + profile = root / "profiles" / "worker" + profile.mkdir(parents=True) + (profile / "config.yaml").write_text( + "auth:\n authority: shared\n", encoding="utf-8" + ) + shared = root / "auth.json" + shared.write_text( + json.dumps({"version": 1, "providers": {"nous": _terminal_nous_state()}}), + encoding="utf-8", + ) + monkeypatch.syspath_prepend(str(_SCRIPT.parent)) + sys.modules.pop("docker_auth_authority", None) + + assert mod.reseed_profile_if_terminal(profile, _FRESH_SEED) == "reseeded" + assert json.loads(shared.read_text())["providers"]["nous"]["refresh_token"] == "FRESH-rt" + assert not (profile / "auth.json").exists() + + +def test_rebootstrap_serializes_with_regular_auth_writer(tmp_path): + from scripts.docker_auth_authority import update_auth_store + + home = tmp_path / ".hermes" + home.mkdir() + auth = _write_auth(home, {"nous": _terminal_nous_state()}) + context = multiprocessing.get_context("fork") + entered = context.Event() + release = context.Event() + results = context.Queue() + + def regular_writer() -> None: + def update(store): + entered.set() + assert release.wait(timeout=5) + store.setdefault("providers", {})["openai-codex"] = {"account": "peer"} + return "written", store + + update_auth_store(str(home), update) + + writer = context.Process(target=regular_writer) + writer.start() + assert entered.wait(timeout=5) + reseeder = context.Process( + target=lambda: results.put(mod.reseed_if_terminal(auth, _FRESH_SEED)) + ) + reseeder.start() + release.set() + writer.join(timeout=5) + reseeder.join(timeout=5) + + assert not writer.is_alive() + assert not reseeder.is_alive() + assert writer.exitcode == 0 + assert reseeder.exitcode == 0 + assert results.get(timeout=1) == "reseeded" + store = json.loads(Path(auth).read_text(encoding="utf-8")) + assert store["providers"]["openai-codex"] == {"account": "peer"} + assert store["providers"]["nous"]["refresh_token"] == "FRESH-rt" + + def test_does_not_clobber_healthy_entry(tmp_path): """LOAD-BEARING: a healthy (live-token) entry must never be overwritten.""" auth = _write_auth(tmp_path, {"nous": _healthy_nous_state()}) @@ -113,3 +181,62 @@ def test_terminal_entry_missing_marker_is_not_terminal(tmp_path): entry) → not terminal, no re-seed.""" auth = _write_auth(tmp_path, {"nous": {"client_id": "hermes-cli-vps"}}) assert mod.reseed_if_terminal(auth, _FRESH_SEED) == "not_terminal" + + +def test_stage2_bootstrap_restart_and_rebootstrap_script_chain(tmp_path): + """Exercise the repository-owned stage2 auth subprocess chain end to end.""" + scripts = _SCRIPT.parent + authority_script = scripts / "docker_auth_authority.py" + home = tmp_path / ".hermes" + profile = home / "profiles" / "worker" + profile.mkdir(parents=True) + (profile / "config.yaml").write_text( + "auth:\n authority: shared\n", encoding="utf-8" + ) + env = os.environ.copy() + env["HERMES_AUTH_JSON_BOOTSTRAP"] = _FRESH_SEED + + seeded = subprocess.run( + [sys.executable, str(authority_script), str(profile), "seed"], + env=env, + text=True, + capture_output=True, + check=True, + ) + assert seeded.stdout.strip() == "seeded" + shared = home / "auth.json" + assert shared.is_file() + assert not (profile / "auth.json").exists() + + store = json.loads(shared.read_text(encoding="utf-8")) + store["providers"]["openai-codex"] = {"account": "survives-restart"} + shared.write_text(json.dumps(store), encoding="utf-8") + shared.chmod(0o600) + preserved = subprocess.run( + [sys.executable, str(authority_script), str(profile), "seed"], + env=env, + text=True, + capture_output=True, + check=True, + ) + assert preserved.stdout.strip() == "exists" + + store = json.loads(shared.read_text(encoding="utf-8")) + store["providers"]["nous"] = _terminal_nous_state() + shared.write_text(json.dumps(store), encoding="utf-8") + shared.chmod(0o600) + env["HERMES_AUTH_JSON_REBOOTSTRAP"] = _FRESH_SEED + reseeded = subprocess.run( + [sys.executable, str(_SCRIPT), str(shared)], + env=env, + text=True, + capture_output=True, + check=True, + ) + + assert "re-seeded auth.json" in reseeded.stdout + final = json.loads(shared.read_text(encoding="utf-8")) + assert final["providers"]["nous"]["refresh_token"] == "FRESH-rt" + assert final["providers"]["openai-codex"] == {"account": "survives-restart"} + assert shared.stat().st_mode & 0o777 == 0o600 + assert (home / "auth.lock").stat().st_mode & 0o777 == 0o600 diff --git a/tests/tools/test_flux3_video_tool.py b/tests/tools/test_flux3_video_tool.py index 35323842a58c..0eeba960a995 100644 --- a/tests/tools/test_flux3_video_tool.py +++ b/tests/tools/test_flux3_video_tool.py @@ -210,7 +210,7 @@ def test_a_profile_sees_a_credential_held_at_the_global_root(self, tmp_path, mon # The profile's own store is empty, so this passes only via the # global-root fallback — without which the tools would be hidden. - assert flux3.peek_nous_access_token() is None + assert flux3.peek_nous_access_token() == "root-token" assert flux3.check_bfl_requirements() is True def test_the_credential_probe_never_forces_a_token_refresh(self, monkeypatch): diff --git a/tests/tools/test_nix_auth_authority.py b/tests/tools/test_nix_auth_authority.py new file mode 100644 index 000000000000..b120feb61a4a --- /dev/null +++ b/tests/tools/test_nix_auth_authority.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import importlib.util +import json +import multiprocessing +import os +from pathlib import Path + +import pytest + + +_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "nix_auth_authority.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("nix_auth_authority", _SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _seed_worker(home: str, source: str, start, results) -> None: + module = _load_module() + start.wait() + results.put(module.seed_auth(Path(home), Path(source))) + + +def test_seed_resolves_shared_authority_and_preserves_existing_destination( + tmp_path: Path, +) -> None: + module = _load_module() + root = tmp_path / ".hermes" + profile = root / "profiles" / "work" + profile.mkdir(parents=True) + (profile / "config.yaml").write_text("auth:\n authority: shared\n") + destination = root / "auth.json" + destination.write_text(json.dumps({"token": "current"})) + destination.chmod(0o600) + source = tmp_path / "seed.json" + source.write_text(json.dumps({"token": "seed"})) + + result = module.seed_auth(profile, source) + + assert result["status"] == "preserved" + assert json.loads(destination.read_text()) == {"token": "current"} + assert destination.stat().st_mode & 0o777 == 0o600 + assert (root / "auth.lock").stat().st_mode & 0o777 == 0o600 + + +def test_seed_rejects_insecure_existing_destination_without_modifying_it( + tmp_path: Path, +) -> None: + module = _load_module() + home = tmp_path / ".hermes" + home.mkdir() + destination = home / "auth.json" + destination.write_text(json.dumps({"token": "current"})) + destination.chmod(0o644) + source = tmp_path / "seed.json" + source.write_text(json.dumps({"token": "seed"})) + + with pytest.raises(RuntimeError, match="mode 0600"): + module.seed_auth(home, source, uid=os.getuid(), gid=os.getgid()) + + assert json.loads(destination.read_text()) == {"token": "current"} + assert destination.stat().st_mode & 0o777 == 0o644 + + +def test_seed_api_has_no_inert_force_control(tmp_path: Path) -> None: + module = _load_module() + home = tmp_path / ".hermes" + home.mkdir() + destination = home / "auth.json" + destination.write_text("old") + source = tmp_path / "seed.json" + source.write_text("new") + + with pytest.raises(TypeError, match="force"): + module.seed_auth(home, source, force=True) + assert destination.read_text() == "old" + + +def test_seed_cli_has_no_inert_force_control(monkeypatch, tmp_path: Path) -> None: + module = _load_module() + monkeypatch.setattr( + "sys.argv", + [ + "nix_auth_authority.py", + str(tmp_path / ".hermes"), + str(tmp_path / "seed.json"), + "--force", + "true", + ], + ) + + with pytest.raises(SystemExit, match="2"): + module.main() + + +def test_concurrent_non_force_seed_has_one_writer_and_no_partial_json( + tmp_path: Path, +) -> None: + home = tmp_path / ".hermes" + home.mkdir() + sources = [] + for index in range(2): + source = tmp_path / f"seed-{index}.json" + source.write_text(json.dumps({"writer": index, "payload": "x" * 10000})) + sources.append(source) + + context = multiprocessing.get_context("fork") + start = context.Event() + results = context.Queue() + workers = [ + context.Process( + target=_seed_worker, + args=(str(home), str(source), start, results), + ) + for source in sources + ] + for worker in workers: + worker.start() + start.set() + for worker in workers: + worker.join(timeout=10) + assert worker.exitcode == 0 + + statuses = sorted(results.get(timeout=2)["status"] for _ in workers) + assert statuses == ["created", "preserved"] + assert json.loads((home / "auth.json").read_text())["writer"] in {0, 1} + assert (home / "auth.json").stat().st_mode & 0o777 == 0o600 + assert (home / "auth.lock").stat().st_mode & 0o777 == 0o600 + assert not list(home.glob("auth.json.tmp.*")) + + +def test_seed_rejects_source_symlink(tmp_path: Path) -> None: + module = _load_module() + profile = tmp_path / ".hermes" + profile.mkdir() + actual = tmp_path / "seed.json" + actual.write_text("{}", encoding="utf-8") + linked = tmp_path / "linked.json" + linked.symlink_to(actual) + + with pytest.raises(RuntimeError, match="source.*symlink"): + module.seed_auth(profile, linked) + + +def test_seed_rejects_non_object_json(tmp_path: Path) -> None: + module = _load_module() + profile = tmp_path / ".hermes" + seed = tmp_path / "seed.json" + seed.write_text("[]", encoding="utf-8") + + with pytest.raises(RuntimeError, match="JSON object"): + module.seed_auth(profile, seed) + + +@pytest.mark.parametrize( + "config_text", + [ + "auth: {authority: profile}\n", + "{auth: {authority: profile}}\n", + ], +) +def test_inline_yaml_authority_matches_block_yaml( + tmp_path: Path, config_text: str +) -> None: + module = _load_module() + root = tmp_path / ".hermes" + profile = root / "profiles" / "work" + profile.mkdir(parents=True) + (profile / "config.yaml").write_text(config_text, encoding="utf-8") + + result = module.resolve_auth_authority(profile) + + assert result["authority"] == "profile" + assert result["auth_path"] == profile / "auth.json" + + +@pytest.mark.parametrize( + "config_text", + [ + "auth: []\n", + "auth: {authority: [profile]}\n", + "auth: {authority: profile\n", + ], +) +def test_malformed_authority_yaml_fails_closed( + tmp_path: Path, config_text: str +) -> None: + module = _load_module() + root = tmp_path / ".hermes" + profile = root / "profiles" / "work" + profile.mkdir(parents=True) + (profile / "config.yaml").write_text(config_text, encoding="utf-8") + + with pytest.raises(RuntimeError, match="auth authority config"): + module.resolve_auth_authority(profile) diff --git a/tests/tools/test_tool_search_livetest_auth.py b/tests/tools/test_tool_search_livetest_auth.py new file mode 100644 index 000000000000..39e378e6ffc9 --- /dev/null +++ b/tests/tools/test_tool_search_livetest_auth.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import importlib.util +import json +import stat +from pathlib import Path + + +_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "tool_search_livetest.py" +_SPEC = importlib.util.spec_from_file_location("tool_search_livetest_auth", _SCRIPT) +assert _SPEC and _SPEC.loader +_MOD = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(_MOD) + + +def test_isolated_home_refuses_credential_copy_without_explicit_opt_in( + tmp_path: Path, monkeypatch, +) -> None: + source_home = tmp_path / "source" + source_home.mkdir() + source_auth = source_home / "auth.json" + source_auth.write_text(json.dumps({"providers": {"nous": {"token": "secret"}}})) + (source_home / ".env").write_text("SECRET=value\n", encoding="utf-8") + monkeypatch.setattr(_MOD, "ORIGINAL_AUTH", source_auth) + monkeypatch.setattr(Path, "home", lambda: source_home.parent) + monkeypatch.delenv("HERMES_TOOL_SEARCH_LIVETEST_ALLOW_CREDENTIAL_COPY", raising=False) + + isolated = _MOD.setup_isolated_home(enabled=True) + + assert not (isolated / "auth.json").exists() + assert not (isolated / ".env").exists() + config = (isolated / "config.yaml").read_text(encoding="utf-8") + assert "authority: profile" in config + + +def test_isolated_home_opt_in_copies_credentials_privately( + tmp_path: Path, monkeypatch, +) -> None: + real_home = tmp_path / "real" + source_root = real_home / ".hermes" + source_root.mkdir(parents=True) + source_auth = source_root / "auth.json" + source_auth.write_text(json.dumps({"providers": {"nous": {"token": "secret"}}})) + (source_root / ".env").write_text("SECRET=value\n", encoding="utf-8") + monkeypatch.setattr(_MOD, "ORIGINAL_AUTH", source_auth) + monkeypatch.setattr(Path, "home", lambda: real_home) + monkeypatch.setenv("HERMES_TOOL_SEARCH_LIVETEST_ALLOW_CREDENTIAL_COPY", "1") + + isolated = _MOD.setup_isolated_home(enabled=False) + + for copied in (isolated / "auth.json", isolated / ".env"): + assert copied.is_file() + assert stat.S_IMODE(copied.stat().st_mode) == 0o600 diff --git a/tools/managed_tool_gateway.py b/tools/managed_tool_gateway.py index af7f8f69748d..a4f64d4cd298 100644 --- a/tools/managed_tool_gateway.py +++ b/tools/managed_tool_gateway.py @@ -2,7 +2,7 @@ from __future__ import annotations -import json + import logging import os from datetime import datetime, timezone @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) -from hermes_constants import get_hermes_home + from tools.tool_backend_helpers import managed_nous_tools_enabled _DEFAULT_TOOL_GATEWAY_DOMAIN = "nousresearch.com" @@ -29,22 +29,17 @@ class ManagedToolGatewayConfig: def auth_json_path(): - """Return the Hermes auth store path, respecting HERMES_HOME overrides.""" - return get_hermes_home() / "auth.json" + """Return the current canonical Hermes auth authority path.""" + from hermes_cli.auth_authority import get_auth_store_path + + return get_auth_store_path() def _read_nous_provider_state() -> Optional[dict]: try: - path = auth_json_path() - if not path.is_file(): - return None - data = json.loads(path.read_text(encoding="utf-8")) - providers = data.get("providers", {}) - if not isinstance(providers, dict): - return None - nous_provider = providers.get("nous", {}) - if isinstance(nous_provider, dict): - return nous_provider + from hermes_cli.auth import get_provider_auth_state + + return get_provider_auth_state("nous") except Exception: pass return None diff --git a/tools/xai_http.py b/tools/xai_http.py index 8ef0b856302d..41ab7a7aff70 100644 --- a/tools/xai_http.py +++ b/tools/xai_http.py @@ -3,7 +3,7 @@ from __future__ import annotations import datetime -import json + import os import uuid from typing import Any, Dict, Optional @@ -39,12 +39,13 @@ def has_xai_credentials() -> bool: if os.environ.get("XAI_API_KEY", "").strip(): return True try: - from hermes_constants import get_hermes_home + from hermes_cli.auth import _load_auth_store + from hermes_cli.auth_authority import get_auth_store_path - auth_path = get_hermes_home() / "auth.json" + auth_path = get_auth_store_path() if not auth_path.exists(): return False - store = json.loads(auth_path.read_text(encoding="utf-8")) + store = _load_auth_store(auth_path) providers = store.get("providers") if isinstance(store, dict) else None xai_state = providers.get("xai-oauth") if isinstance(providers, dict) else None tokens = xai_state.get("tokens") if isinstance(xai_state, dict) else None diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 775825576516..07102b0d1a99 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -269,7 +269,7 @@ export function statusRuleWidths(cols: number, cwdLabel: string, minLeftContent } // Progressive disclosure for the status rule's lower-priority tail segments. -// As the terminal narrows we shed the least important pieces first (cost → +// As the terminal narrows we shed the least important pieces first (subagents → // bg → voice → compressions → duration → context bar), and below the bar // breakpoint the context read-out collapses to a bare token count. Status and // model are never gated here — they're guaranteed room by `statusRuleWidths`. diff --git a/ui-tui/src/lib/memory.test.ts b/ui-tui/src/lib/memory.test.ts index 92f177c7ef53..c8512d94c7be 100644 --- a/ui-tui/src/lib/memory.test.ts +++ b/ui-tui/src/lib/memory.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { performHeapDump } from './memory.js' +import { isAutoHeapdumpEnabled, performHeapDump } from './memory.js' const ENV_KEYS = ['HERMES_AUTO_HEAPDUMP', 'HERMES_HEAPDUMP_DIR', 'HERMES_HEAPDUMP_MAX_BYTES'] as const @@ -74,24 +74,15 @@ describe('performHeapDump auto opt-in gate (#21767)', () => { expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(true) }) - it('accepts truthy spellings (true|yes|on, case-insensitive) as opt-in', async () => { - for (const value of ['true', 'YES', 'On']) { - process.env.HERMES_AUTO_HEAPDUMP = value - const result = await performHeapDump('auto-high') - - expect(result.success).toBe(true) - expect(result.heapPath).toBeDefined() + it('accepts truthy spellings (1|true|yes|on, case-insensitive) as opt-in', () => { + for (const value of ['1', 'true', 'YES', 'On']) { + expect(isAutoHeapdumpEnabled(value)).toBe(true) } }) - it('treats other values (0, off, garbage) as opt-out for auto triggers', async () => { + it('treats other values (0, off, garbage) as opt-out for auto triggers', () => { for (const value of ['0', 'off', 'nope']) { - process.env.HERMES_AUTO_HEAPDUMP = value - const result = await performHeapDump('auto-high') - - expect(result.success).toBe(true) - expect(result.suppressed).toBe(true) - expect(result.heapPath).toBeUndefined() + expect(isAutoHeapdumpEnabled(value)).toBe(false) } }) diff --git a/ui-tui/src/lib/memory.ts b/ui-tui/src/lib/memory.ts index 664b0560ef50..d9abdd6a9130 100644 --- a/ui-tui/src/lib/memory.ts +++ b/ui-tui/src/lib/memory.ts @@ -57,6 +57,10 @@ export interface HeapDumpResult { success: boolean } +export function isAutoHeapdumpEnabled(raw = process.env.HERMES_AUTO_HEAPDUMP): boolean { + return /^(?:1|true|yes|on)$/i.test((raw ?? '').trim()) +} + export async function captureMemoryDiagnostics(trigger: MemoryTrigger): Promise { const usage = process.memoryUsage() const heapStats = getHeapStatistics() @@ -163,7 +167,7 @@ export async function performHeapDump(trigger: MemoryTrigger = 'manual'): Promis // Auto triggers require explicit opt-in: multi-GiB snapshots written on // every threshold cross can fill the user's disk (issue #21767). const isAuto = trigger === 'auto-critical' || trigger === 'auto-high' - const autoEnabled = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_AUTO_HEAPDUMP ?? '').trim()) + const autoEnabled = isAutoHeapdumpEnabled() if (isAuto && !autoEnabled) { await pruneHeapdumps(dir).catch(() => undefined) diff --git a/website/docs/getting-started/nix-setup.md b/website/docs/getting-started/nix-setup.md index 17d34883c9e6..ae01080046dd 100644 --- a/website/docs/getting-started/nix-setup.md +++ b/website/docs/getting-started/nix-setup.md @@ -405,12 +405,22 @@ For platforms requiring OAuth (e.g., Discord), use `authFile` to seed credential { services.hermes-agent = { authFile = config.sops.secrets."hermes/auth.json".path; - # authFileForceOverwrite = true; # overwrite on every activation }; } ``` -The file is only copied if `auth.json` doesn't already exist (unless `authFileForceOverwrite = true`). Runtime OAuth token refreshes are written to the state directory and preserved across rebuilds. +The file is seeded only when the configured authentication authority has no +`auth.json`. Activation resolves the authority first and performs the seed +under its canonical lock, so runtime OAuth token refreshes are never silently +overwritten by a later rebuild. + +The deprecated `authFileForceOverwrite` option is fail-closed. Remove it from +your Nix configuration. To change authority topology, use `hermes auth +migrate-shared`; to replace credentials from a backup, use the explicit +encrypted restore flow. A rebuild must never overwrite a refresh token that +Hermes rotated at runtime. Activation also verifies the final target and +persistent lock are regular mode-0600 files owned by the configured service +user and group before reporting success. --- @@ -851,7 +861,7 @@ nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves use | `environmentFiles` | `listOf str` | `[]` | Paths to env files with secrets. Merged into `$HERMES_HOME/.env` at activation time | | `environment` | `attrsOf str` | `{}` | Non-secret env vars. **Visible in Nix store** — do not put secrets here | | `authFile` | `null` or `path` | `null` | OAuth credentials seed. Only copied on first deploy | -| `authFileForceOverwrite` | `bool` | `false` | Always overwrite `auth.json` from `authFile` on activation | +| `authFileForceOverwrite` | deprecated | `false` | Rejected when enabled. Remove it and use the reviewed migration or encrypted restore workflow. | ### Documents diff --git a/website/docs/guides/auth-authority.md b/website/docs/guides/auth-authority.md new file mode 100644 index 000000000000..2d83b3d5c136 --- /dev/null +++ b/website/docs/guides/auth-authority.md @@ -0,0 +1,130 @@ +--- +sidebar_position: 8 +title: Authentication store authority +--- + +# Authentication store authority + +Hermes resolves every OAuth and credential-pool read, write, refresh, status check, and lock through one configured authority. The default is a single shared store at `~/.hermes/auth.json`, so named profiles can use the same login without copying tokens. + +## Configuration + +Set the authority in the active profile's `config.yaml`: + +```yaml +auth: + authority: shared # shared | profile +``` + +- `shared` (default): `~/.hermes/auth.json`. Named profiles use the default Hermes root. +- `profile`: `/auth.json`. Use this for deliberate credential isolation. +Invalid authorities fail closed. Hermes does not silently switch to another credential store. + +### Existing profiles + +When `auth.authority` is absent and an existing named profile already has `auth.json`, Hermes temporarily selects that profile-local store in bounded legacy-compatibility mode. A fresh profile with no local store uses `shared`. Configure the authority explicitly or use the migration workflow below to remove ambiguity. + +## Inspect the active authority + +```bash +hermes auth status +hermes doctor +``` + +These commands show the selected mode, canonical store and lock paths, provenance, permissions, legacy-compatibility state, conflicting non-authoritative stores, and the latest migration phase. They list no tokens or complete credential payloads. + +Provider status remains available by naming a provider: + +```bash +hermes auth status nous +``` + +## Migrate profile stores to shared authority + +Migration is dry-run first and requires an explicit profile scope: + +```bash +hermes auth migrate-shared --profile coder --dry-run +hermes auth migrate-shared --all-profiles --dry-run +``` + +The dry-run prints a redacted manifest, `plan_id`, and `plan_digest`. It stores full precondition hashes only in a private mode-0600 artifact. Review the provider topology, then apply the exact plan: + +```bash +hermes auth migrate-shared --profile coder --apply \ + --plan-id --plan-digest --conflict-policy abort +``` + +Conflict policies are: + +- `abort`: stop before committing a divergent provider entry. +- `prefer-shared`: preserve the shared entry. +- `prefer-profile`: replace a divergent shared entry with the selected profile's entry. With `--all-profiles`, profiles are merged by stable profile-name order. + +Apply acquires all relevant auth locks in stable bytewise path order, validates that every source and config is unchanged since dry-run, creates private recovery backups and a journal, writes the shared store atomically, then changes selected profiles to `auth.authority: shared`. Legacy source `auth.json` files are preserved byte-for-byte as recovery material; they become non-authoritative rather than being deleted. + +If a process is interrupted after backup but before commit, inspect the journal with `hermes auth status` and roll it back: + +```bash +hermes auth migrate-recover --plan-id +``` + +Recovery is idempotent. A committed migration is not implicitly rolled back. + +To explicitly undo a committed migration, use `hermes auth migrate-shared --rollback --plan-id `. Rollback is refused if the shared auth store or any migrated profile config changed after commit, so later credential rotations or configuration edits are never overwritten. + +## Backups and restore + +Auth is excluded from normal backups. To include it, encrypt it explicitly: + +```bash +hermes backup --auth-mode include-encrypted --auth-passphrase-file /secure/passphrase +``` + +Restore requires an explicit destination: + +```bash +hermes import backup.zip --auth-action restore-shared --auth-passphrase-file /secure/passphrase +hermes import backup.zip --auth-action restore-profile --auth-passphrase-file /secure/passphrase +``` + +Hermes validates the encrypted envelope, passphrase, topology, and gateway quiescence before extracting ordinary files. Auth and the active `config.yaml` are committed under the canonical auth lock and rolled back together on failure. Every live gateway resolving to the destination authority must be stopped first. + +Quick snapshots record the resolved topology and copy the authoritative store under its canonical lock. Normal quick restore skips credentials. Programmatic restore must pass `include_auth=True` and an explicit `auth_action`; topology mismatches fail before non-auth files are changed. + +## Profile lifecycle + +- `hermes profile create NAME` creates an explicitly `shared` profile. +- `hermes profile create NAME --auth-mode profile` creates an empty profile-local authority. Clone options never copy `auth.json` OAuth or credential-pool state into it; use an independent login or an explicit encrypted profile restore to populate it. Other clone-selected files such as `.env` retain their documented behavior. +- Renaming quiesces gateways and Desktop/backend writers before moving profile-local authority state. +- Deleting a profile-local authority requires `--auth-action archive` or `--auth-action purge`. Archive runs only after all known profile writers stop. Shared credentials are never deleted with one profile. + +## Docker and NixOS + +Docker bootstrap and Nous session rebootstrap both resolve the same canonical authority and mutate it through the same lock-protected helper. Bootstrap is create-only. Rebootstrap replaces only a terminal or provably older Nous entry and never clobbers a healthy newer session. + +On NixOS, `services.hermes-agent.authAuthority` emits the matching `auth.authority` setting. `authFile` is a one-time seed: activation atomically creates a missing target and never overwrites an existing store. If `services.hermes-agent.configFile` is supplied, the module merges `authAuthority` into the installed config so declared topology and seed target cannot diverge. There is intentionally no force-overwrite switch. + +## First-party consumer manifest + +All first-party auth-store consumers must resolve through `hermes_cli.auth_authority` (or the equivalent standalone Docker/Nix helper) and use the lock paired with the resolved data path for writes. + +| Consumer | Module | +| --- | --- | +| CLI login/logout/status and provider setup | `hermes_cli.auth` | +| Setup wizard/provider readiness | `hermes_cli.main` | +| Dynamic model cache invalidation | `hermes_cli.models` | +| Credential pool refresh/account rotation | `agent.credential_pool` | +| Auxiliary models | `agent.auxiliary_client` | +| Gateway startup migration gate | `gateway.run` | +| Diagnostics | `hermes_cli.auth_commands`, `hermes_cli.doctor` | +| Backup and profile lifecycle | `hermes_cli.backup`, `hermes_cli.profiles` | +| Managed tool subprocesses | `tools.managed_tool_gateway` | +| xAI OAuth | `tools.xai_http` | +| Photon OAuth | `plugins.platforms.photon.auth` | +| Docker bootstrap/rebootstrap | `scripts/docker_auth_authority.py`, `scripts/docker_rebootstrap_nous_session.py` | +| NixOS activation seed | `scripts/nix_auth_authority.py` | + +## Safe rollback before apply + +Before applying, no live file is modified. Delete an unwanted private dry-run plan artifact or simply create a new plan. After an interrupted apply, use `migrate-recover`; do not copy token files manually while Hermes processes are running. diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 59d280e26c8b..a0405d03c1f0 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -116,6 +116,8 @@ Common options: | `-s`, `--skills ` | Preload one or more skills for the session (can be repeated or comma-separated). | | `-v`, `--verbose` | Verbose output. | | `-Q`, `--quiet` | Programmatic mode: suppress banner/spinner/tool previews. | +| `--result-meta-file ` | Atomically create a closed-world JSON status sidecar for a single `--query`; classic CLI only. The destination must not exist. | +| `--result-meta-fd ` | Write the same status as one bounded frame to a pre-opened POSIX/WSL anonymous-pipe write descriptor; mutually exclusive with `--result-meta-file`. | | `--image ` | Attach a local image to a single query. | | `--resume ` / `--continue [name]` | Resume a session directly from `chat`. | | `--worktree` | Create an isolated git worktree for this run. | @@ -141,6 +143,80 @@ hermes chat --ignore-user-config --ignore-rules -q "Repro without my personal se hermes chat --safe-mode -q "Is this bug mine or Hermes'?" ``` +### Structured query-result metadata + +Automation that needs a machine-readable turn outcome without inspecting the +model response can opt in to a separate metadata file: + +```bash +hermes chat -Q -q "Check the deployment" \ + --result-meta-file /run/user/1000/hermes-result.json +``` + +This option is valid only for a non-interactive `--query`/`-q` in the classic +CLI. It is rejected for interactive chat and the TUI. It does not change the +normal response on stdout, diagnostics on stderr, model/provider selection, +toolsets, source tag, or max-turn behavior. The file contains status metadata, +not the model response. + +The v1 file is compact, sorted UTF-8 JSON followed by one newline, with exactly +these keys: + +```json +{"api_calls":1,"completed":true,"failed":false,"failure_class":"none","interrupted":false,"partial":false,"schema_version":"hermes-agent-result-meta-v1"} +``` + +`completed`, `failed`, `partial`, and `interrupted` are strict booleans copied +from the trusted turn result. `api_calls` is a non-boolean integer in the range +`0..max_iterations + 1`; the extra call is the conversation loop's bounded +grace call. `failure_class` is one of: + +- `none` +- `content_policy_blocked` +- `provider_api_terminal` +- `max_turns_or_incomplete` +- `interrupted` +- `unknown_failure` + +Classification uses structured internal result fields only. It never parses or +stores response/error display text, messages, prompts, tool output, provider or +model names, session IDs, paths, hashes, or exceptions. Unknown, contradictory, +or malformed internal results fail closed as `unknown_failure`. + +The path must be absolute, contain no lexical `..`, have an existing +unsymlinked parent chain, and name an absent destination. Hermes never creates +parent directories or overwrites any existing regular file, symlink, directory, +FIFO, or device. It writes a random same-directory mode-`0600` temporary file, +fully writes and fsyncs it, atomically publishes without clobbering, and fsyncs +the parent directory where supported. Output is bounded to 1024 bytes. + +Secure publication currently requires POSIX directory-file-descriptor, +`O_NOFOLLOW`, hard-link, and `/proc/self/fd` semantics. Platforms or filesystems +that cannot provide the no-clobber/open-inode guarantee fail closed instead of +falling back to an overwrite-prone rename. A pre-result startup failure can +leave the sidecar absent. A validation/publication failure emits only the fixed +diagnostic `Error: failed to publish result metadata.` and exits nonzero. + +For a producer-bound transport, a parent process can instead create an +anonymous pipe, retain its read endpoint, pass only the blocking write endpoint +to Hermes, and select it with `--result-meta-fd `. This option is mutually +exclusive with `--result-meta-file`, query-only, classic-CLI-only, and available +only on POSIX systems (including WSL). Hermes accepts only a canonical integer +descriptor numbered 3 or higher that is an open FIFO write endpoint, is in +blocking mode, and reports an actual `PC_PIPE_BUF` of at least 1024 bytes. +Unsupported platforms and invalid descriptors fail before config, model, or +agent construction; Hermes does not fall back to file mode. + +Hermes marks an accepted descriptor non-inheritable before starting any +descendants, writes the byte-identical v1 serialization as exactly one bounded +`os.write` frame, requires a full write, and closes its single owned endpoint on +success, startup failure, publication failure, or interruption. `EPIPE`, +`EAGAIN`, a short write, or a close fault produces only the same fixed public +diagnostic and a nonzero exit. No metadata is retried or emitted on stdout or +stderr. + +The security claim is exactly: **producer-bound against ordinary filesystem substitution, not root/arbitrary same-principal ptrace/proc-fd compromise.** + ### `hermes -z ` — scripted one-shot For programmatic callers (shell scripts, CI, cron, parent processes piping in a prompt), `hermes -z` is the purest one-shot entry point: **single prompt in, final response text out, nothing else on stdout or stderr.** No banner, no spinner, no tool previews, no `Session:` line — just the agent's final reply as plain text. @@ -536,8 +612,13 @@ hermes auth add openrouter --api-key sk-or-v1-xxx # Add API key hermes auth add anthropic --type oauth # Add OAuth credential hermes auth remove openrouter 2 # Remove by index hermes auth reset openrouter # Clear cooldowns -hermes auth status anthropic # Show auth status for a provider -hermes auth logout anthropic # Log out and clear stored auth state +hermes auth status # Show canonical auth authority and migration state +hermes auth status anthropic # Show auth status for a provider +hermes auth logout anthropic # Log out and clear stored auth state +hermes auth migrate-shared --profile coder --dry-run +hermes auth migrate-shared --profile coder --apply --plan-id --plan-digest --conflict-policy abort +hermes auth migrate-shared --rollback --plan-id +hermes auth migrate-recover --plan-id hermes auth spotify # Authenticate Hermes with Spotify via PKCE ``` diff --git a/website/docs/reference/profile-commands.md b/website/docs/reference/profile-commands.md index 24a8f6791b55..9fc99edcb9f7 100644 --- a/website/docs/reference/profile-commands.md +++ b/website/docs/reference/profile-commands.md @@ -83,6 +83,7 @@ Creates a new profile. | `--clone` | Copy `config.yaml`, `.env`, `SOUL.md`, and skills from the current profile. | | `--clone-all` | Copy everything (config, memories, skills, cron, plugins) from the current profile. Excludes per-profile history: sessions, `state.db`, backups, state-snapshots, checkpoints. | | `--clone-from ` | Clone config/skills/SOUL from a specific profile instead of the current one. Implies `--clone` unless paired with `--clone-all`. | +| `--auth-mode ` | Select the new profile's credential authority. `shared` is the safe default and does not copy OAuth `auth.json`; `profile` creates an empty isolated local authority. Clone options never import `auth.json` OAuth or credential-pool state into that authority: populate it with an independent login or an explicit encrypted profile restore. Other clone-selected files such as `.env` retain their documented behavior. | | `--no-alias` | Skip wrapper script creation. | | `--description ""` | One- or two-sentence description of what this profile is good at. Used by the kanban orchestrator to route tasks based on role instead of profile name alone. Skip and add later via `hermes profile describe`. Persisted in `/profile.yaml`. | | `--no-skills` | Create an **empty** profile with zero bundled skills enabled. Writes a `.no-bundled-skills` marker into the profile so future `hermes update` runs won't re-seed the bundled set, and refuses to combine with `--clone`, `--clone-from`, or `--clone-all` (which would copy skills in anyway). Useful for narrow orchestrator profiles or sandbox profiles that should not inherit the full skill catalog. To toggle this on an already-created profile (including the default `~/.hermes`), use `hermes skills opt-out` / `hermes skills opt-in`. | @@ -106,8 +107,13 @@ hermes profile create work2 --clone-from work # Clone everything from a specific profile hermes profile create work2-backup --clone-from work --clone-all + +# Create a deliberately isolated profile-local credential authority +hermes profile create regulated --auth-mode profile ``` +Profile creation never merges other profile state with shared auth. Use `hermes auth status` to inspect the selected canonical store, and use `hermes auth migrate-shared` for existing local stores rather than manually moving `auth.json`. + ## `hermes profile describe` ```bash diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index ae38e858f6a1..8d5d055a2a0c 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -47,7 +47,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/compress [here [N] \| focus topic]` | Manually compress conversation context (flush memories + summarize). `/compress here [N]` summarizes everything except the most recent N exchanges (default 2), kept verbatim — pick your own compression boundary. A focus topic narrows what a full summary preserves. | | `/rollback` | List or restore filesystem checkpoints (usage: /rollback [number]) | | `/diff [staged\|all\|session] [--stat] [path...]` | Show git changes in the working directory. Default: unstaged changes plus untracked files. `staged` shows what's staged for commit, `all` everything since HEAD, and `session` the cumulative diff of everything Hermes changed here (from the earliest retained checkpoint baseline — requires checkpoints to be enabled; complements `/rollback diff `). `--stat` prints just the changed-file summary; path arguments restrict the diff. | -| `/snapshot [create\|restore \|prune]` (alias: `/snap`) | Create or restore state snapshots of Hermes config/state. `create [label]` saves a snapshot, `restore ` reverts to it, `prune [N]` removes old snapshots, or list all with no args. | +| `/snapshot [create\|restore \|prune]` (alias: `/snap`) | Create or restore state snapshots. Plain `restore ` always skips auth. Restoring an encrypted auth envelope requires `/snapshot restore --include-auth --auth-action restore-shared\|restore-profile --auth-passphrase-file PATH`; Hermes validates topology, gateway quiescence, and canonical locks before the transactional auth/config commit. | | `/stop` | Kill all running background processes | | `/queue ` (alias: `/q`) | Queue a prompt for the next turn (doesn't interrupt the current agent response). | | `/steer ` | Inject a mid-run note that arrives at the agent **after the next tool call** — no interrupt, no new user turn. The text is appended to the last tool result's content once the current tool completes, giving the agent new context without breaking the current tool-calling loop. Use this to nudge direction mid-task (e.g. "focus on the auth module" while the agent is running tests). | @@ -132,8 +132,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/copy [number]` | Copy the last assistant response to clipboard (or the Nth-from-last with a number). CLI-only. | | `/image ` | Attach a local image file for your next prompt. | | `/debug` | Upload debug report (system info + logs) and get shareable links. Also available in messaging. | -| `/update` | Update Hermes Agent to the latest version. | -| `/profile` | Show active profile name and home directory | +| `/profile` | Show active profile name and home directory. Profile identity does not imply a local OAuth store; use `hermes auth status` to inspect the canonical auth authority. | ### Exit diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 47d63fbe4c8d..a26273fe272a 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -18,7 +18,7 @@ Run `hermes setup --portal` — one OAuth gets you a model provider and all four ~/.hermes/ ├── config.yaml # Settings (model, terminal, TTS, compression, etc.) ├── .env # API keys and secrets -├── auth.json # OAuth provider credentials (Nous Portal, etc.) +├── auth.json # Default shared OAuth and credential-pool authority ├── SOUL.md # Primary agent identity (slot #1 in system prompt) ├── memories/ # Persistent memory (MEMORY.md, USER.md) ├── skills/ # Agent-created skills (managed via skill_manage tool) @@ -63,6 +63,10 @@ Settings are resolved in this order (highest priority first): Secrets (API keys, bot tokens, passwords) go in `.env`. Everything else (model, terminal backend, compression settings, memory limits, toolsets) goes in `config.yaml`. When both are set, `config.yaml` wins for non-secret settings. ::: +### Authentication authority + +OAuth tokens and credential pools remain in `auth.json`, but `auth.authority` controls which store is canonical. The default `shared` authority uses `~/.hermes/auth.json` across named profiles; `profile` isolates credentials to the active profile. See [Authentication store authority](/guides/auth-authority) for migration, conflict, backup, and recovery behavior. + :::tip Org deployments An administrator can pin specific config and secret values that a standard user cannot override, via a system-level managed directory. See diff --git a/website/docs/user-guide/profiles.md b/website/docs/user-guide/profiles.md index 904d3ec3d1ee..60aaddf44484 100644 --- a/website/docs/user-guide/profiles.md +++ b/website/docs/user-guide/profiles.md @@ -4,7 +4,7 @@ sidebar_position: 2 # Profiles: Running Multiple Agents -Run multiple independent Hermes agents on the same machine — each with its own config, API keys, memory, sessions, skills, and gateway state. +Run multiple isolated Hermes agents on the same machine. Config, memory, sessions, skills, cron, gateway state, and platform credentials remain profile-scoped; Hermes OAuth credentials use the shared default-root authority unless you explicitly opt a profile into a local auth store. ## What are profiles? @@ -20,7 +20,19 @@ coder setup # configure API keys and model coder chat # start chatting ``` -That's it. `coder` is now its own Hermes profile with its own config, memory, and state. +That's it. `coder` is now its own Hermes profile with its own config, memory, and state. Its `config.yaml` explicitly selects `auth.authority: shared`, so provider OAuth sign-in is reused without merging any other profile state. + +### Authentication authority + +New profiles use the canonical shared store at `~/.hermes/auth.json`. This shares only Hermes provider credentials; it does not merge profile config, memory, sessions, skills, cron jobs, gateway state, messaging-platform tokens, or project files. + +Use a profile-local credential store only when you need deliberate isolation: + +```bash +hermes profile create regulated --auth-mode profile +``` + +That command writes `auth.authority: profile` and uses `~/.hermes/profiles/regulated/auth.json`. Existing profiles with ambiguous local stores should use the reviewed `hermes auth migrate-shared` workflow rather than copying or deleting `auth.json` manually. See [Authentication Authority](/guides/auth-authority). ## Creating a profile @@ -50,7 +62,7 @@ You can also set or auto-generate the description later with `hermes profile des hermes profile create work --clone ``` -Copies your current profile's `config.yaml`, `.env`, `SOUL.md`, and skills into the new profile. Same API keys, model, and capabilities, but fresh sessions and memory. Edit `~/.hermes/profiles/work/.env` for different API keys, or `~/.hermes/profiles/work/SOUL.md` for a different personality. +Copies your current profile's `config.yaml`, `.env`, `SOUL.md`, and skills into the new profile. Static API keys in `.env`, model settings, and capabilities are copied, but OAuth `auth.json` is not copied in the default shared mode. Sessions and memory start fresh. Edit `~/.hermes/profiles/work/.env` for different static API keys, or `~/.hermes/profiles/work/SOUL.md` for a different personality. ### Clone everything (`--clone-all`) @@ -58,7 +70,7 @@ Copies your current profile's `config.yaml`, `.env`, `SOUL.md`, and skills into hermes profile create backup --clone-all ``` -Copies **everything** — config, API keys, personality, all memories, skills, cron jobs, plugins. A complete working snapshot. Per-profile history is excluded (session history, `state.db`, `backups/`, `state-snapshots/`, `checkpoints/`) — these belong to the source profile and can reach tens of GB. For a full backup including history, use `hermes profile export` or `hermes backup` instead. +Copies config, `.env` API keys, personality, memories, skills, cron jobs, and plugins. The canonical OAuth `auth.json` is still not copied in the default shared mode. Per-profile history is excluded (session history, `state.db`, `backups/`, `state-snapshots/`, `checkpoints/`) — these belong to the source profile and can reach tens of GB. For an authority-aware backup including encrypted auth, use `hermes backup --auth-mode include-encrypted` instead. ### Clone from a specific profile diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/nix-setup.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/nix-setup.md index eb003cd32597..c958a27f1586 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/nix-setup.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/nix-setup.md @@ -385,12 +385,15 @@ hermes-env: | { services.hermes-agent = { authFile = config.sops.secrets."hermes/auth.json".path; - # authFileForceOverwrite = true; # 每次激活时强制覆盖 }; } ``` -仅当 `auth.json` 不存在时才复制该文件(除非 `authFileForceOverwrite = true`)。运行时 OAuth token 刷新会写入状态目录,并在重建后保留。 +仅当配置的身份验证权威位置中不存在 `auth.json` 时,系统才会预置该文件。 +激活过程会先解析权威位置,并在其规范锁保护下完成预置,因此后续重建不会静默覆盖 +运行时刷新的 OAuth token。 + +已弃用的 `authFileForceOverwrite` 选项会以关闭方式失败。请从 Nix 配置中删除它。若要改变权威拓扑,请使用 `hermes auth migrate-shared`;若要从备份替换凭据,请使用显式的加密恢复流程。rebuild 绝不能覆盖 Hermes 在运行时轮换的 refresh token。激活过程只有在确认最终目标和持久锁都是由所配置服务用户及组拥有的普通 mode-0600 文件后才会报告成功。 --- @@ -803,7 +806,7 @@ nix build .#checks.x86_64-linux.config-roundtrip # 合并脚本保留用户 | `environmentFiles` | `listOf str` | `[]` | 包含密钥的 env 文件路径。激活时合并到 `$HERMES_HOME/.env` | | `environment` | `attrsOf str` | `{}` | 非密钥环境变量。**在 Nix store 中可见**——请勿在此放置密钥 | | `authFile` | `null` 或 `path` | `null` | OAuth 凭据预置文件。仅在首次部署时复制 | -| `authFileForceOverwrite` | `bool` | `false` | 每次激活时始终从 `authFile` 覆盖 `auth.json` | +| `authFileForceOverwrite` | 已弃用 | `false` | 启用时会被拒绝。请删除该选项并使用经过检查的迁移或加密恢复流程。 | ### 文档 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/auth-authority.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/auth-authority.md new file mode 100644 index 000000000000..d15efd8c7e3f --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/auth-authority.md @@ -0,0 +1,123 @@ +--- +sidebar_position: 8 +title: 身份验证存储权限 +--- + +# 身份验证存储权限 + +Hermes 通过一个已配置的权限位置处理所有 OAuth 和凭据池的读取、写入、刷新、状态检查及锁定。默认使用 `~/.hermes/auth.json` 这一共享存储,因此命名配置文件无需复制令牌即可使用同一次登录。 + +## 配置 + +在当前配置文件的 `config.yaml` 中设置权限: + +```yaml +auth: + authority: shared # shared | profile +``` + +- `shared`(默认):`~/.hermes/auth.json`。命名配置文件使用默认 Hermes 根目录。 +- `profile`:`/auth.json`。仅在需要有意隔离凭据时使用。 + +无效的权限值会以关闭方式失败。Hermes 不会静默切换到其他凭据存储。 + +### 现有配置文件 + +如果缺少 `auth.authority`,且现有命名配置文件已经包含 `auth.json`,Hermes 会在有限的旧版兼容模式中暂时选择该配置文件本地存储。没有本地存储的新配置文件使用 `shared`。请显式配置权限,或使用下述迁移流程消除歧义。 + +## 检查当前权限 + +```bash +hermes auth status +hermes doctor +``` + +这些命令仅显示所选模式、规范化存储及锁路径、来源、权限、旧版兼容状态、非权威冲突存储和最新迁移阶段;不会列出令牌或完整凭据内容。 + +## 将配置文件存储迁移到共享权限 + +迁移必须先进行 dry-run,并显式指定配置文件范围: + +```bash +hermes auth migrate-shared --profile coder --dry-run +hermes auth migrate-shared --all-profiles --dry-run +``` + +dry-run 输出脱敏清单、`plan_id` 和 `plan_digest`。完整前置条件哈希仅保存在权限为 0600 的私有计划文件中。检查提供商拓扑后,应用完全相同的计划: + +```bash +hermes auth migrate-shared --profile coder --apply \ + --plan-id --plan-digest --conflict-policy abort +``` + +冲突策略: + +- `abort`:在提交不同的提供商条目前停止。 +- `prefer-shared`:保留共享条目。 +- `prefer-profile`:用所选配置文件的条目替换不同的共享条目。使用 `--all-profiles` 时,按稳定的配置文件名称顺序合并。 + +应用过程按稳定的字节路径顺序获取所有相关身份验证锁,确认源文件和配置自 dry-run 后未改变,创建私有恢复备份和日志,原子写入共享存储,然后把所选配置文件改为 `auth.authority: shared`。旧的配置文件本地 `auth.json` 会原样保留为恢复材料,但不再是权威存储。 + +中断后可检查状态并恢复: + +```bash +hermes auth migrate-recover --plan-id +``` + +恢复可重复执行。已提交的迁移不会被隐式回滚。显式回滚使用 `hermes auth migrate-shared --rollback --plan-id `;如果共享存储或迁移后的配置已经发生变化,回滚会拒绝覆盖后续轮换或配置修改。 + +## 备份与恢复 + +普通备份不包含身份验证数据。若需包含,必须显式加密: + +```bash +hermes backup --auth-mode include-encrypted --auth-passphrase-file /secure/passphrase +``` + +恢复必须显式指定目标: + +```bash +hermes import backup.zip --auth-action restore-shared --auth-passphrase-file /secure/passphrase +hermes import backup.zip --auth-action restore-profile --auth-passphrase-file /secure/passphrase +``` + +Hermes 会在提取普通文件前验证加密信封、口令、拓扑和网关静止状态。身份验证数据与当前 `config.yaml` 在规范化身份验证锁下共同提交,失败时共同回滚。所有解析到目标权限的活动网关都必须先停止。 + +快速快照记录解析后的拓扑,并在规范化锁下复制权威存储。普通快速恢复跳过凭据。程序化恢复必须同时传入 `include_auth=True` 和显式 `auth_action`;拓扑不匹配时,在改变非身份验证文件前失败。 + +## 配置文件生命周期 + +- `hermes profile create NAME` 创建显式 `shared` 配置文件。 +- `hermes profile create NAME --auth-mode profile` 创建空的配置文件本地权限。克隆选项永不向其中复制 `auth.json` 的 OAuth 或凭据池状态;请通过独立登录或显式加密的配置文件恢复来填充该存储。其他被克隆选项选中的文件(如 `.env`)仍保持其文档所述行为。 +- 重命名前会停止网关及 Desktop/后端写入进程,再移动配置文件本地权限状态。 +- 删除配置文件本地权限时必须指定 `--auth-action archive` 或 `--auth-action purge`。归档仅在所有已知写入进程停止后执行。删除一个配置文件绝不会删除共享凭据。 + +## Docker 与 NixOS + +Docker 引导和 Nous 会话重新引导解析同一个规范化权限,并通过同一个带锁帮助程序修改它。引导只创建缺失文件。重新引导仅替换终止状态或可证明更旧的 Nous 条目,绝不会覆盖健康且更新的会话。 + +在 NixOS 上,`services.hermes-agent.authAuthority` 会生成对应的 `auth.authority` 设置。`authFile` 仅用于一次性种子:激活时原子创建缺失目标,永不覆盖现有存储。即使提供 `services.hermes-agent.configFile`,模块仍会把 `authAuthority` 合并到安装后的配置中,使声明拓扑与种子目标不会分歧。系统有意不提供强制覆盖开关。 + +## 第一方消费者清单 + +所有第一方身份验证存储消费者都必须通过 `hermes_cli.auth_authority`(或独立 Docker/Nix 等效帮助程序)解析路径;写入时必须使用与解析后数据路径配对的锁。 + +| 消费者 | 模块 | +| --- | --- | +| CLI 登录/登出/状态和提供商设置 | `hermes_cli.auth` | +| 设置向导/提供商就绪检查 | `hermes_cli.main` | +| 动态模型缓存失效 | `hermes_cli.models` | +| 凭据池刷新/账户轮换 | `agent.credential_pool` | +| 辅助模型 | `agent.auxiliary_client` | +| 网关启动迁移门 | `gateway.run` | +| 诊断 | `hermes_cli.auth_commands`, `hermes_cli.doctor` | +| 备份和配置文件生命周期 | `hermes_cli.backup`, `hermes_cli.profiles` | +| 托管工具子进程 | `tools.managed_tool_gateway` | +| xAI OAuth | `tools.xai_http` | +| Photon OAuth | `plugins.platforms.photon.auth` | +| Docker 引导/重新引导 | `scripts/docker_auth_authority.py`, `scripts/docker_rebootstrap_nous_session.py` | +| NixOS 激活种子 | `scripts/nix_auth_authority.py` | + +## 应用前的安全回滚 + +应用前不会修改任何实时文件。可删除不需要的私有 dry-run 计划文件,或创建新计划。应用中断后请使用 `migrate-recover`;Hermes 进程运行时不要手动复制令牌文件。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/profile-commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/profile-commands.md index dad4207daf8c..f17450a2258b 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/profile-commands.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/profile-commands.md @@ -82,6 +82,7 @@ hermes profile create [options] | `--clone` | 从当前 profile 复制 `config.yaml`、`.env`、`SOUL.md` 和 skills。 | | `--clone-all` | 从当前 profile 复制所有内容(config、memories、skills、cron、plugins)。会排除每个 profile 自己的历史数据:sessions、`state.db`、backups、state-snapshots、checkpoints。 | | `--clone-from ` | 从指定 profile 克隆 config/skills/SOUL,而非当前 profile。除非与 `--clone-all` 配合使用,否则会隐含 `--clone`。 | +| `--auth-mode ` | 选择新 profile 的凭据权威存储。`shared` 是安全默认值,不复制 OAuth `auth.json`;`profile` 创建空的隔离本地权威存储。克隆选项绝不会向其中导入 `auth.json` 的 OAuth 或凭据池状态;请通过独立登录或显式加密的 profile 恢复来填充该存储。其他被克隆选项选中的文件(如 `.env`)仍保持其文档所述行为。 | | `--no-alias` | 跳过 wrapper 脚本创建。 | | `--description ""` | 一到两句话描述该 profile 的用途。供 kanban 编排器根据角色而非仅凭 profile 名称来路由任务。可跳过,稍后通过 `hermes profile describe` 添加。持久化保存在 `/profile.yaml` 中。 | | `--no-skills` | 创建一个**空** profile,不启用任何内置 skill。会在 profile 目录中写入 `.no-bundled-skills` 标记,使后续 `hermes update` 不再重新植入内置 skill 集,且拒绝与 `--clone`、`--clone-from` 或 `--clone-all` 组合使用(因为这些选项会复制 skill)。适用于不应继承完整 skill 目录的窄化编排器 profile 或沙箱 profile。 | @@ -105,8 +106,13 @@ hermes profile create work2 --clone-from work # 从指定 profile 克隆所有内容 hermes profile create work2-backup --clone-from work --clone-all + +# 创建刻意隔离的 profile 本地凭据权威存储 +hermes profile create regulated --auth-mode profile ``` +创建 profile 不会把其他 profile 状态与共享认证合并。使用 `hermes auth status` 检查所选规范存储;对于已有本地存储,应使用 `hermes auth migrate-shared`,不要手工移动 `auth.json`。 + ## `hermes profile describe` ```bash diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md index be7e1ca69ac1..5b47be48bc17 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md @@ -45,7 +45,7 @@ Hermes 有两个斜杠命令入口,均由 `hermes_cli/commands.py` 中的中 | `/title` | 为当前会话设置标题(用法:/title My Session Name) | | `/compress [focus topic]` | 手动压缩对话上下文(刷新记忆 + 摘要)。可选的焦点主题可缩小摘要保留的范围。 | | `/rollback` | 列出或恢复文件系统检查点(用法:/rollback [number]) | -| `/snapshot [create\|restore \|prune]`(别名:`/snap`) | 创建或恢复 Hermes 配置/状态的快照。`create [label]` 保存快照,`restore ` 回滚到该快照,`prune [N]` 删除旧快照,不带参数则列出所有快照。 | +| `/snapshot [create\|restore \|prune]`(别名:`/snap`) | 创建或恢复状态快照。普通 `restore ` 始终跳过认证数据。恢复加密认证信封必须使用 `/snapshot restore --include-auth --auth-action restore-shared\|restore-profile --auth-passphrase-file PATH`;Hermes 会在认证/配置事务提交前验证拓扑、gateway 静止状态和规范锁。 | | `/stop` | 终止所有正在运行的后台进程 | | `/queue `(别名:`/q`) | 将 prompt(提示词)加入队列等待下一轮处理(不会中断当前 agent 响应)。 | | `/steer ` | 在**下一次工具调用之后**向 agent 注入一条中途说明——不中断、不产生新的用户轮次。当前工具完成后,该文本会追加到最后一条工具结果的内容中,在不打断当前工具调用循环的情况下为 agent 提供新上下文。可用于在任务进行中调整方向(例如在 agent 运行测试时说"专注于 auth 模块")。 | @@ -114,7 +114,7 @@ Hermes 有两个斜杠命令入口,均由 `hermes_cli/commands.py` 中的中 | `/copy [number]` | 将最后一条助手回复复制到剪贴板(或用数字指定倒数第 N 条)。仅限 CLI。 | | `/image ` | 为下一条 prompt 附加本地图片文件。 | | `/debug` | 上传调试报告(系统信息 + 日志)并获取可分享链接。消息平台中也可用。 | -| `/profile` | 显示活动 profile 名称和主目录 | +| `/profile` | 显示活动 profile 名称和主目录。profile 身份并不表示 OAuth 存储位于本地;使用 `hermes auth status` 检查规范认证权威存储。 | ### 退出 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/profiles.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/profiles.md index 3589d8a086cc..a5f2670f2bb9 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/profiles.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/profiles.md @@ -4,7 +4,7 @@ sidebar_position: 2 # Profiles:运行多个 Agent -在同一台机器上运行多个独立的 Hermes agent——每个 agent 拥有各自的配置、API 密钥、记忆、会话、技能和 gateway 状态。 +在同一台机器上运行多个隔离的 Hermes agent。配置、记忆、会话、技能、cron、gateway 状态和平台凭据仍按 profile 隔离;除非显式选择本地认证存储,否则 Hermes OAuth 凭据使用默认根目录中的共享权威存储。 ## 什么是 profile? @@ -20,7 +20,19 @@ coder setup # 配置 API 密钥和模型 coder chat # 开始对话 ``` -就这些。`coder` 现在是拥有独立配置、记忆和状态的 Hermes profile。 +就这些。`coder` 现在拥有独立的配置、记忆和状态。其 `config.yaml` 显式设置 `auth.authority: shared`,因此可以复用提供商 OAuth 登录,而不会合并任何其他 profile 状态。 + +### 认证权威存储 + +新 profile 使用 `~/.hermes/auth.json` 作为规范共享存储。共享的仅是 Hermes 提供商凭据;profile 配置、记忆、会话、技能、cron 任务、gateway 状态、消息平台 token 和项目文件都不会合并。 + +仅在确实需要凭据隔离时使用 profile 本地存储: + +```bash +hermes profile create regulated --auth-mode profile +``` + +该命令写入 `auth.authority: profile`,并使用 `~/.hermes/profiles/regulated/auth.json`。对于已有且本地存储状态不明确的 profile,应使用经过检查的 `hermes auth migrate-shared` 流程,不要手工复制或删除 `auth.json`。详见[认证权威存储](/guides/auth-authority)。 ## 创建 profile @@ -46,7 +58,7 @@ hermes profile create researcher --description "Reads source code and external d hermes profile create work --clone ``` -将当前 profile 的 `config.yaml`、`.env`、`SOUL.md` 和 skills 复制到新 profile。API 密钥、模型和能力相同,但会话和记忆是全新的。编辑 `~/.hermes/profiles/work/.env` 可使用不同的 API 密钥,编辑 `~/.hermes/profiles/work/SOUL.md` 可设置不同的人格。 +将当前 profile 的 `config.yaml`、`.env`、`SOUL.md` 和 skills 复制到新 profile。`.env` 中的静态 API 密钥、模型设置和能力会被复制,但默认共享模式不会复制 OAuth `auth.json`。会话和记忆从空白开始。编辑 `~/.hermes/profiles/work/.env` 可使用不同的静态 API 密钥,编辑 `~/.hermes/profiles/work/SOUL.md` 可设置不同的人格。 ### 克隆全部内容(`--clone-all`) @@ -54,7 +66,7 @@ hermes profile create work --clone hermes profile create backup --clone-all ``` -复制**所有内容**——配置、API 密钥、人格、记忆、技能、cron 任务、插件。会排除每个 profile 自己的历史数据(会话历史、`state.db`、`backups/`、`state-snapshots/`、`checkpoints/`),这些数据属于源 profile 且可能达到数十 GB。若要包含历史的完整备份,请使用 `hermes profile export` 或 `hermes backup`。 +复制配置、`.env` API 密钥、人格、记忆、技能、cron 任务和插件。默认共享模式仍不会复制规范 OAuth `auth.json`。会排除每个 profile 自己的历史数据(会话历史、`state.db`、`backups/`、`state-snapshots/`、`checkpoints/`),这些数据属于源 profile 且可能达到数十 GB。若需包含加密认证数据并感知权威拓扑的备份,请使用 `hermes backup --auth-mode include-encrypted`。 ### 从指定 profile 克隆 diff --git a/website/package.json b/website/package.json index bb752328ad8c..1554f97ebaa9 100644 --- a/website/package.json +++ b/website/package.json @@ -15,7 +15,8 @@ "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "typecheck": "tsc -p . --noEmit", - "lint:diagrams": "ascii-guard lint --exclude-code-blocks docs" + "lint:diagrams": "node scripts/run-ascii-guard.mjs lint --exclude-code-blocks docs", + "lint:auth-authority-docs": "node scripts/check-auth-authority-docs.mjs" }, "dependencies": { "@docusaurus/core": "3.10.2", diff --git a/website/scripts/check-auth-authority-docs.mjs b/website/scripts/check-auth-authority-docs.mjs new file mode 100644 index 000000000000..ad6a71cacf2a --- /dev/null +++ b/website/scripts/check-auth-authority-docs.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const websiteDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const docs = [ + "docs/getting-started/nix-setup.md", + "i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/nix-setup.md", + "docs/guides/auth-authority.md", + "i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/auth-authority.md", + "docs/user-guide/profiles.md", + "i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/profiles.md", + "docs/reference/profile-commands.md", + "i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/profile-commands.md", + "docs/reference/slash-commands.md", + "i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md", +]; + +const authorityGuides = docs.slice(2, 4); +const requiredContracts = [ + "auth.authority", + "hermes auth migrate-shared --profile coder --dry-run", + "hermes auth migrate-recover --plan-id ", + "--auth-mode include-encrypted", + "--auth-action restore-shared", + "--auth-action restore-profile", + "hermes profile create NAME --auth-mode profile", + "scripts/docker_auth_authority.py", + "scripts/docker_rebootstrap_nous_session.py", + "services.hermes-agent.authAuthority", + "hermes_cli.main", + "hermes_cli.models", + "gateway.run", + "tools.xai_http", +]; + +const surfaceContracts = new Map([ + [docs[4], ["auth.authority: shared", "--auth-mode profile", "hermes auth migrate-shared"]], + [docs[5], ["auth.authority: shared", "--auth-mode profile", "hermes auth migrate-shared"]], + [docs[6], ["--auth-mode ", "hermes auth status", "hermes auth migrate-shared"]], + [docs[7], ["--auth-mode ", "hermes auth status", "hermes auth migrate-shared"]], + [docs[8], ["Plain `restore ` always skips auth", "--include-auth", "--auth-action restore-shared\\|restore-profile"]], + [docs[9], ["普通 `restore ` 始终跳过认证数据", "--include-auth", "--auth-action restore-shared\\|restore-profile"]], +]); + +const forbidden = [ + { + pattern: /authFileForceOverwrite\s*=\s*true/g, + reason: "docs must not recommend repeated OAuth credential overwrite", + }, + { + pattern: /(?:\/home\/[^/\s]+|[A-Za-z]:\\Users\\[^\\\s]+)[/\\][^\s`]*auth\.json/g, + reason: "docs must show normalized, operator-independent auth paths", + }, +]; + +const errors = []; +for (const relativePath of docs) { + const text = readFileSync(resolve(websiteDir, relativePath), "utf8"); + for (const rule of forbidden) { + for (const match of text.matchAll(rule.pattern)) { + const line = text.slice(0, match.index).split("\n").length; + errors.push(`${relativePath}:${line}: ${rule.reason}: ${match[0]}`); + } + } +} + +for (const relativePath of authorityGuides) { + const text = readFileSync(resolve(websiteDir, relativePath), "utf8"); + for (const contract of requiredContracts) { + if (!text.includes(contract)) { + errors.push(`${relativePath}: missing authority contract: ${contract}`); + } + } +} + +for (const [relativePath, contracts] of surfaceContracts) { + const text = readFileSync(resolve(websiteDir, relativePath), "utf8"); + for (const contract of contracts) { + if (!text.includes(contract)) { + errors.push(`${relativePath}: missing surface contract: ${contract}`); + } + } +} + +if (errors.length > 0) { + console.error(errors.join("\n")); + process.exit(1); +} + +console.log("Authentication-authority documentation checks passed."); diff --git a/website/scripts/run-ascii-guard.mjs b/website/scripts/run-ascii-guard.mjs new file mode 100644 index 000000000000..bd6d82770edc --- /dev/null +++ b/website/scripts/run-ascii-guard.mjs @@ -0,0 +1,45 @@ +import { spawnSync } from 'node:child_process'; + +const ASCII_GUARD_SPEC = 'ascii-guard==2.3.0'; +const PY_YAML_SPEC = 'pyyaml==6.0.3'; +const args = process.argv.slice(2); + +function run(command, commandArgs) { + return spawnSync(command, commandArgs, { + encoding: 'utf8', + stdio: 'pipe', + shell: false, + }); +} + +function flush(result) { + if (result.stdout) { + process.stdout.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } +} + +function commandMissing(result) { + return result.error?.code === 'ENOENT'; +} + +for (const candidate of [ + ['ascii-guard', args], + ['uvx', ['--from', ASCII_GUARD_SPEC, '--with', PY_YAML_SPEC, 'ascii-guard', ...args]], + ['uv', ['tool', 'run', '--from', ASCII_GUARD_SPEC, '--with', PY_YAML_SPEC, 'ascii-guard', ...args]], +]) { + const [command, commandArgs] = candidate; + const result = run(command, commandArgs); + if (commandMissing(result)) { + continue; + } + flush(result); + process.exit(result.status ?? 1); +} + +console.error( + 'Unable to run ascii-guard. Install python3 with ascii-guard==2.3.0, or install uv/uvx so the pinned fallback can bootstrap it.' +); +process.exit(1); diff --git a/website/src/components/AutomationBlueprintsCatalog/index.tsx b/website/src/components/AutomationBlueprintsCatalog/index.tsx index 7edeca2c705f..f77eaf1bc51f 100644 --- a/website/src/components/AutomationBlueprintsCatalog/index.tsx +++ b/website/src/components/AutomationBlueprintsCatalog/index.tsx @@ -25,7 +25,7 @@ interface Blueprint { const INDEX_URL = "/docs/api/automation-blueprints-index.json"; -function CopyButton({ text }: { text: string }): JSX.Element { +function CopyButton({ text }: { text: string }): React.JSX.Element { const [copied, setCopied] = useState(false); return (