Commit Graph
9 Commits
Author SHA1 Message Date
Tatlatat cd247ed107 Skills: delete owner-scoped skills with owner
The DELETE /api/skills/{skill_id} handler resolves the caller, loads the
skill with skills_manager.load(owner=user), and verifies ownership with
_verify_owner(match, user) — but then calls
skills_manager.delete_skill(match.get("name")) without the owner.

SkillsManager.delete_skill filters candidates with
`(sk.owner or "") != (owner or "")`, so when owner is None an owner-scoped
skill is skipped and the method returns False. The route then raises a
spurious 404 "Skill not found" — meaning a logged-in user can never delete
their own skills through the API.

Pass the resolved owner through to delete_skill so the skill is matched and
removed.

tests/test_skills_delete_owner.py drops a real owner-scoped SKILL.md on disk
and (1) checks the manager directly: delete_skill without owner returns
False (regression lock) while delete_skill(owner="alice") returns True and
removes the dir; (2) drives the real DELETE route handler and asserts it
returns {"ok": True} and deletes the file. The route test fails before this
change (404). Real SkillsManager + real filesystem, no mocking.
2026-06-02 20:28:36 +09:00
Tatlatat 9389cabed0 API keys: skip undecryptable entries on load
APIKeyManager.load() decrypts every stored key with a dict comprehension
and no error handling. If the .key file no longer matches the ciphertext in
api_keys.json — key rotated, a partial/!mismatched data restore, or a
corrupted .key — Fernet.decrypt raises cryptography.fernet.InvalidToken.

app_initializer.py calls api_key_manager.load() during startup, so a single
undecryptable entry takes down the whole app at boot, and the user can't
reach the UI to fix it.

Decrypt each key in a loop and, on InvalidToken/ValueError, log a warning
and skip that one entry while still returning every key that decrypts
cleanly. One bad/stale key no longer blocks startup.

tests/test_api_key_manager_resilience.py saves a valid key, then injects an
entry encrypted under a different Fernet key (InvalidToken) and a malformed
token (ValueError), and asserts load() returns the good key and skips the
bad ones without raising. Fails before this change.
2026-06-02 20:28:26 +09:00
Tatlatat da3876c168 Webhook: block IPv6 SSRF bypasses
The webhook URL guard's _ip_is_private() only checks a hardcoded
_PRIVATE_NETWORKS list, which misses several addresses that route
internally. validate_webhook_url() therefore ALLOWED:

- http://[::]/                      (IPv6 unspecified, reaches localhost)
- http://[::ffff:127.0.0.1]/        (IPv4-mapped IPv6 loopback = 127.0.0.1)
- http://[::ffff:169.254.169.254]/  (IPv4-mapped cloud metadata endpoint)

The last one is the dangerous case: a webhook pointed at the mapped
169.254.169.254 can pull cloud instance credentials (SSRF -> credential
theft).

Harden _ip_is_private(): first unwrap IPv4-mapped IPv6 to its embedded IPv4
(addr.ipv4_mapped), then reject via the stdlib address properties
(is_private, is_loopback, is_link_local, is_reserved, is_multicast,
is_unspecified) in addition to the existing network list. Public addresses
still pass.

tests/test_webhook_ssrf_resilience.py asserts validate_webhook_url raises
for the three IPv6 bypasses plus 127.0.0.1 and 0.0.0.0, and still accepts a
public IP literal. The IPv6 cases fail before this change.
2026-06-02 20:28:12 +09:00
Tatlatat acfdcf346c fix(agent): map native google_search and surface empty rounds
Models (notably Gemini) emit a native 'google_search' function call, but the
agent loop had no mapping for it, so the call failed to convert, the round
produced 0 chars and 0 tool blocks, and generation died silently — the web
client hung on 'waiting for first token' with no error (also #443).

- Map google_search / google_search_retrieval / google_search_grounding to the
  web_search tool, and read Gemini's 'queries' array (falling back to 'query').
- In stream_agent_loop, when a round yields no response text and no tool
  events, emit a visible fallback message instead of leaving the user hanging.
- Give the unknown-tool execution branch an explicit exit_code=1 so the failure
  is logged as an error rather than 'n/a'.

Unknown/unconvertible tool names still return None (unchanged) so they are
dropped safely rather than executed. Added tests covering the google_search
mapping, the queries array, and unknown/invalid-JSON returning None.
2026-06-02 12:57:45 +09:00
Tatlatat ffb77d7ff2 fix(auth): honor AUTH_ENABLED=false on owner-scoped endpoints (no /login loop) (#880)
When the operator sets AUTH_ENABLED=false, three owner-scoped endpoints still
returned 401 (api/models, api/research/*, api/email/*), so the front-end
redirected the browser to /login and the app was unusable despite auth being
turned off. require_user() in src/auth_helpers.py already documents and honors
this contract (issue #622) via 'if _auth_disabled(): return ""', but these
endpoints did their own get_current_user/is_configured check without it.

Make _require_user (research), the /api/models anti-leak guard, and
email_helpers._require_auth consult _auth_disabled() and let anonymous through
(owner='') only when the operator explicitly disabled auth. The 401 protection
is fully intact when AUTH_ENABLED=true. Verified end-to-end: with
AUTH_ENABLED=false the SPA now loads instead of bouncing to /login.
2026-06-02 12:26:26 +09:00
Tatlatat 9a1893760d fix(cookbook): skip pip --user fallback inside virtualenvs (#388) (#889)
The dependency-install fallback chain unconditionally ran
'pip install --user', which fails inside a virtualenv (and as root in
LXC/containers) with 'Can not perform a --user install. User site-packages
are not visible in this virtualenv.' — even though the function's docstring
already noted --user is invalid in venvs.

Guard the --user fallback with a venv check so it only runs outside a venv
(where --user is actually valid for PEP-668 system Pythons). Derive the venv
probe interpreter from the install command (python for 'pip', python3 for
'pip3'/'python3 -m pip') so the check runs in pip's own environment. System
PEP-668 installs keep the --user fallback; venv/LXC-root installs no longer
hit the --user error. Updated the unit test for the new chain.

Closes #388
2026-06-02 12:23:20 +09:00
Tatlatat 2d6b777799 fix(cookbook): diagnose 'no GGUF file' serve failures clearly (#811) (#866)
When serving with the llama.cpp backend and no .gguf file exists on the host,
the GGUF launcher prelude exits with 'ERROR: No GGUF found on this host', but
_diagnose_serve_output had no matching pattern, so the UI showed a generic
crash instead of explaining the cause. Add a diagnosis pattern for the
no-GGUF case so users are told a .gguf is required and pointed at downloading
a GGUF build, instead of an opaque crash.

Closes #811
2026-06-02 11:36:53 +09:00
Tatlatat 63a947d246 fix(cookbook): mark zero-file HF downloads as failed instead of completed (#839) (#865)
A Cookbook download whose repo/quant selector matched no files (e.g. a
':Q4_K_M' tag that does not exist) printed 'Fetching 0 files' and was still
reported as a successful '✓ Downloaded' / completed task. Detect the
zero-file signature in the download snapshot and mark the task as an error
with a clear diagnosis (no matching files — check the repo or quant/filename
pattern) so users know nothing was actually downloaded. Normal multi-file
and fully-cached downloads (which print 'Fetching N files', N>0) are
unaffected.

Closes #839
2026-06-02 11:24:34 +09:00
Tatlatat aba15e7b6d fix(cookbook): sort by Fit when the Fit header is clicked (#842) (#860)
The Cookbook Scan/Download (hwfit) table gave the Fit column key:'score', so
clicking the Fit header sorted by score instead of by fit. Give the Fit column
its own 'fit' sort key, add a matching option to the #hwfit-sort select, and
rank fit_level (perfect > good > marginal > too_tight > no_fit) in the
client-side sort. Default puts the best fit first; clicking again reverses it.
Score still sorts by score.

Closes #842
2026-06-02 11:09:18 +09:00