Skip to content

fix(cookbook): use explicit SSH identity so remote servers work under PUID=0#5669

Closed
catagris wants to merge 1925 commits into
odysseus-dev:devfrom
catagris:fix/cookbook-ssh-identity
Closed

fix(cookbook): use explicit SSH identity so remote servers work under PUID=0#5669
catagris wants to merge 1925 commits into
odysseus-dev:devfrom
catagris:fix/cookbook-ssh-identity

Conversation

@catagris

@catagris catagris commented Jul 21, 2026

Copy link
Copy Markdown

Summary

Cookbook generates its SSH keypair in /app/.ssh (the persisted bind mount), but every ssh/scp invocation relied on the client's default $HOME/.ssh lookup. In the container the runtime user's home is only /app when the entrypoint creates the user — with PUID=0 it resolves to root (/root), so ssh never offers the generated key and silently falls back to password auth, which fails headless. Symptom: Settings → Servers → Check, downloads, and serves against a remote server all fail with "Permission denied" even though the public key is correctly installed on the target (its sshd log shows repeated Failed password from the Odysseus host and zero publickey attempts). This PR makes the key explicit on every Cookbook ssh/scp invocation (-i <key> -o IdentitiesOnly=yes, plus -o UserKnownHostsFile pointing at the persisted known_hosts when containerized — which also makes _repair_cookbook_known_host edit the file ssh actually reads), symlinks $HOME/.ssh/app/.ssh in the entrypoint so frontend-composed ssh commands going through /api/shell/exec are covered too, and adds -o PubkeyAuthentication=no to the manual key-install command so a loaded ssh-agent can't burn through the server's MaxAuthTries before the password prompt ("Too many authentication failures").

Target branch

  • This PR targets dev, not main.

Linked Issue

Fixes #5672

Type of Change

  • Bug fix (non-breaking — fixes a confirmed issue)

Checklist

  • I searched open issues and open PRs — this is not a duplicate.
  • This PR targets dev
  • My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in.
  • I actually ran the app and verified the change works end-to-end: deployed an image built from this branch on the affected PUID=0 Docker deployment — Servers → Check now succeeds, the target's sshd log shows Accepted publickey for the odysseus-cookbook key, $HOME/.ssh inside the container is the expected symlink to /app/.ssh, and downloads/serves on the remote server launch over key auth. Full test suite also passes (4678) including new hermetic tests for the identity flags.

How to Test

  1. Run the Docker image with PUID=0 — or any PUID that resolves to an existing user whose home is not /app (setting PUID=0 to match root-owned bind mounts is common on NAS hosts). Add a remote server under Cookbook → Settings → Servers and install the key on it via the Key button command.
  2. Without this fix: Check fails with "Permission denied"; on the target, journalctl -u sshd shows repeated Failed password from the Odysseus host and no publickey attempts.
  3. With this fix: Check succeeds and the target's sshd log shows Accepted publickey; docker exec odysseus ls -l /root/.ssh shows the symlink to /app/.ssh; model downloads and serves on that server launch without password prompts. On a bare-metal install (no /app), behaviour is unchanged unless ~/.ssh/id_ed25519 exists, in which case it is now passed explicitly.

Visual / UI changes — REQUIRED if you touched anything that renders

None — the only static/js change edits a generated shell-command string (the manual key-install command gains -o PubkeyAuthentication=no); nothing that renders is touched.

🤖 Generated with Claude Code

pewdiepie-archdaemon and others added 30 commits June 27, 2026 21:07
Keep an unhealthy MemoryVectorStore instance available for health reporting instead of discarding it as disabled. This lets health checks report a degraded/down vector-store state while preserving focused regression coverage for initializer behavior.
eyeofastarte and others added 24 commits July 11, 2026 17:06
…(#4411) (#5160)

* docs: update static/js/MODULE_SUMMARY.md to reflect current ES6 frontend

Rewrite the stale module summary to match the current no-build,
ES6-module frontend architecture. Adds coverage of app.js orchestration,
the chat/SSE pipeline (chat.js, chatStream.js, chatRenderer.js,
streamingRenderer.js), new subsystems (research/, compare/, document
streaming, cookbook*, skills.js), and removes the obsolete <script> load
order assumptions.

* cleanup: remove dead MEMORY_DOC / memory_doc paths (closes #4411)

Removes the unused MEMORY_DOC constant and the matching DataConfig
memory_doc field / set_data_paths entry. No runtime code imports or
references these paths, so this is a no-behavior-change dead-code
cleanup under the storage-architecture tracker #4377.
* fix(db): restrict data/app.db to 0600

app.db holds bearer-token hashes, bcrypt password hashes, and encrypted
provider keys but was created under the default umask (0644 -> world-readable),
unlike .app_key/vault/integrations which are already 0600 via safe_chmod.

init_db() now chmods the SQLite file to 0600 right after create_all (POSIX
only; no-op on Windows, skipped for Postgres / in-memory). Unconditional and
idempotent, so it also re-locks already-deployed 0644 installs on next
startup. The transient rollback journal inherits 0600 from the parent file at
creation - no sidecar handling needed; -wal/-shm don't exist until WAL is
enabled (#4409 C4) and inherit the same mode then.

Satisfies Rule B, unblocking #4413 and the vault/integration secret moves.
Mirrors src/secret_storage.py:43-45.

Verified: security + DB-permission suites pass; 6 pre-existing visual_report
failures (missing markdown/nh3 deps) are unrelated.

Closes #4407

* fix(db): harden SQLite path parsing and re-lock sidecars

Address review feedback on #4420.

P2: derive the file to chmod from engine.url (SQLAlchemy's parsed URL)
via _sqlite_db_path(), instead of DATABASE_URL.replace("sqlite:///", "").
A driver-qualified URL (sqlite+pysqlite://) or one carrying query args
(?cache=shared) previously slipped past the prefix check / string slice
and left the DB world-readable; the parsed path resolves correctly and
drops the query.

P3: re-lock stale -wal/-shm/-journal sidecars to 0o600 at startup. The
main file is chmod'd first, so any sidecar SQLite creates afterward
inherits 0o600, but a -wal/-shm left world-readable by an older 0o644
install (once WAL was enabled) could still expose DB pages. Absent
sidecars are the normal case, not an error.

Tests: unit-test _sqlite_db_path across driver/query/memory/postgres URL
forms, and a subprocess test asserting stale 0o644 -wal/-shm are
re-locked on startup.

* fix(db): handle sqlite file URI app db permissions

* fix(db): close remaining SQLite permission bypasses

---------

Co-authored-by: Ethan <23321960+0xLeathery@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
chat_stream() references `_explicit_web_intent` in three places
(disabled-tools gating, global-disabled web allowance, and the
per-turn tool filter) but the assignment was dropped during a
branch merge. Every chat request raised

    NameError: name '_explicit_web_intent' is not defined

at routes/chat_routes.py, surfacing to the client as a bare
"Internal Server Error" before any LLM call was made — chat was
fully broken on dev and main.

Restore the original definition, computed from the already-derived
tool intent, immediately before its first use:

    _explicit_web_intent = bool(_tool_intent and _tool_intent.category == "web")

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 2d81770)
…mporter (#5261)

* Harden skill importer against SSRF: block private targets + revalidate redirects per hop

The skill importer validated only the initial URL with the lenient SSRF guard
(block_private=False) and then fetched with follow_redirects=True, so a 3xx to
an internal/metadata address (169.254.169.254, 127.0.0.1, RFC-1918) was still
connected to — inconsistent with the hardened services/search/content.py
:_get_public_url path.

Add a _get_checked() helper that follows redirects manually and re-runs the
SSRF guard with block_private=True on every hop, and route all three fetch
sites (skills.sh unwrap, _fetch_bytes, _list_github_dir) through it. GitHub's
own redirects and the final-host _assert_github_url checks are preserved.

Adds hermetic regression tests (IP-literal hosts, faked HTTP layer) and updates
the existing mock signature for the new block_private kwarg.

Defense-in-depth: the endpoint is admin-gated (require_admin) and admins are
trusted per THREAT_MODEL.md, so this is not a cross-boundary vulnerability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: enforce follow_redirects=False invariant in mock client

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Add Arch-specific package installation and NVIDIA runtime configuration
to the Docker setup guide. Cover passthrough verification, the NVIDIA
Compose overlay, and the distinction between GPU passthrough and
CUDA-backed model serving

Refs #831
chore: sync upstream changes from odysseus-dev/odysseus
fix(docker): bump Docker CLI to a patched release
fix(docker): bump Docker CLI to a patched release
* feat(models): define capability schema and readers

* fix(models): harden Google catalog probing

Restrict native catalog probing to the Gemini host, keep provider keys out of request URLs, filter non-chat model resources, and preserve the manual refresh default in the built-in Google add flow.
…(#5474)

* security(url-safety): reject RFC 6598 shared address space in strict mode

Strict mode (block_private=True) is a full SSRF lockdown, but it only
rejected is_private and is_loopback targets. CPython does not classify RFC
6598 shared/CGNAT space (100.64.0.0/10) as is_private (it is "shared", not
"private"), so a public redirect into 100.64.0.1 passed the per-hop guard
and still issued the request to a potentially internal CGNAT service.

not is_global would also exclude it, but only on CPython 3.11.10+/3.12.4+/
3.13+; the CI matrix runs 3.11/3.12, so reject the range explicitly to stay
correct across patch levels and the 3.14 runtime image. Default local-first
mode is unchanged. Adds strict-mode coverage for shared, non-global, and
public targets.

* docs(url-safety): correct CGNAT is_global rationale in strict-mode comment

The prior comment claimed `not is_global` catches 100.64.0.0/10 only on
CPython 3.11.10+/3.12.4+/3.13+. That is inaccurate for CGNAT: is_global
is False for 100.64.0.1 on every supported version (verified 3.10-3.14).
The version-fragility applies to other ranges gh-113171 touched, not CGNAT.
The explicit range reject is still the right choice; restate the reason as
is_private not covering shared space, and not coupling strict mode to
is_global's broader, cross-version definition. No behavior change.
…… (#5491)

* fix(llm): enhance fallback logic to handle empty completions and improve metadata handling

* fix(llm): stream tool call deltas immediately
Slice 2f of the route-domain reorganization (#4082/#4071, per
specs/architecture-runtime-inventory.md §6.3). Moves note_routes.py into
routes/note/, leaving a backward-compat sys.modules shim at the old path.
Pure file reorganization, no behavior change.

The shim uses sys.modules replacement (same pattern as the merged gallery
#4903, research #4975, memory #5007, history #5090, and contacts #5227
slices) so that `import routes.note_routes`, `from routes.note_routes import
X`, `importlib.import_module(...)`, and the `import ... as note_routes` +
`monkeypatch.setattr(note_routes, "SessionLocal", ...)` pattern used by
test_note_reminder_fire_scope.py / test_notes_fail_closed_auth.py all
operate on the same module object the application uses.

The canonical module does NOT depend on the shim — routes/note/note_routes.py
imports only from core/, src/, and stdlib. The outbound email cross-domain
imports (routes.email_routes._get_email_config, routes.email_helpers.
_send_smtp_message) are function-local lazy imports that keep resolving
through the email module's own path (email is not yet migrated).

One source-introspection test site repointed to the new canonical path:
- test_model_helper_owner_scope.py (shared with history; history entry
  already repointed in #5090, note entry repointed here)

Adds tests/test_note_routes_shim.py to pin the sys.modules shim contract
(legacy and canonical paths resolve to the same module object; monkeypatch
via legacy alias reaches the canonical module).

Verified: compileall clean; full suite 4487 passed, 3 skipped.
… PUID=0

Cookbook generates its SSH keypair in /app/.ssh (the persisted bind mount)
but every ssh/scp invocation relied on the client's default $HOME/.ssh
lookup. In the container the runtime user's HOME is not /app when PUID
resolves to an existing user (e.g. the stack default PUID=0 -> root ->
/root), so ssh never offered the key and silently fell back to password
auth, which fails headless: Check/download/serve against a remote server
died with "Permission denied" even though the public key was installed
correctly on the target.

- core/platform_compat: new cookbook_ssh_dir()/cookbook_ssh_identity_opts()/
  cookbook_ssh_identity_flags() helpers; _ssh_exec_argv now always passes
  -i <cookbook key> -o IdentitiesOnly=yes (when the key exists) plus
  -o UserKnownHostsFile pointing at the persisted known_hosts when
  containerized, so _repair_cookbook_known_host edits the file ssh
  actually reads. IdentitiesOnly also stops agent-loaded keys from
  exhausting the server's MaxAuthTries.
- routes/cookbook_routes, cookbook_helpers, codex_routes, src/tools/cookbook,
  src/cookbook_serve_lifecycle: thread the identity flags through every
  string-built ssh/scp command (runner launch, setup/detect, status polls,
  kill/stop, cache probes, health checks).
- docker/entrypoint: symlink $HOME/.ssh -> /app/.ssh when they differ, which
  also covers frontend-composed ssh commands running through /api/shell/exec.
  /root is ephemeral so the link is recreated on every start.
- cookbook-hwfit.js: the manual key-install command now passes
  -o PubkeyAuthentication=no so a loaded ssh-agent can't trigger
  "Too many authentication failures" before the password prompt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the needs work PR description incomplete — please update before review label Jul 21, 2026
@github-actions github-actions Bot added ready for review Description complete — ready for maintainer review and removed needs work PR description incomplete — please update before review labels Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready for review Description complete — ready for maintainer review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cookbook remote servers fail with "Permission denied" in Docker under PUID=0 — generated SSH key in /app/.ssh is never offered