mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-15 17:25:26 -04:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 543e42d376 | |||
| 20cf94f53d | |||
| 3b3c0d6254 | |||
| f5c1eb4b9d | |||
| 93825a505c | |||
| 204b0bf0ca | |||
| 15b58d681f | |||
| c0cc0f954c | |||
| 2a4bba2b9e | |||
| a79c0bd369 | |||
| 3e65326c3f | |||
| 01fbee021b | |||
| 620fdd0859 | |||
| 95c54ac3cb | |||
| 263d41c58a | |||
| f941db29d3 | |||
| bfac1d55d6 | |||
| cc8ba04ea8 | |||
| 4fa4d0100a | |||
| c500bcb47d | |||
| f7a3605b16 | |||
| 1a2bcfcae4 | |||
| 65d9603c8c | |||
| a7b03398b6 | |||
| 4f48cfa9ae | |||
| af61b2d4e6 | |||
| 0b0656df11 | |||
| 9f47c5ff87 | |||
| dd2d375c7b | |||
| 73823c878e | |||
| 50fedff2f2 | |||
| 66c25cbc2f | |||
| 09ec880c06 | |||
| 5e16126bde | |||
| c01034f9cb | |||
| 8adca3a924 | |||
| d5603ee575 | |||
| 9c00da6d1c | |||
| d1a5a7d680 | |||
| 59fc6604be | |||
| e98567c2b9 | |||
| f34ae6b965 | |||
| 1ef50279fb | |||
| c0d8c4de3e | |||
| 5deea5664e |
@@ -0,0 +1,8 @@
|
||||
# Code owners.
|
||||
#
|
||||
# Every file is owned by the maintainer, so that when branch protection has
|
||||
# "Require review from Code Owners" turned on, no pull request can be merged
|
||||
# without the maintainer's review. This is the human gate that backs up the
|
||||
# automated security checks. See docs/security-ci.md for how to turn it on.
|
||||
|
||||
* @pewdiepie-archdaemon
|
||||
@@ -0,0 +1,48 @@
|
||||
# Dependabot keeps dependencies and pinned action versions current.
|
||||
#
|
||||
# Why this matters for security: every workflow in this repo pins its GitHub
|
||||
# Actions to an exact commit (a SHA), which is safe but freezes them in time.
|
||||
# Dependabot opens a small, reviewable pull request whenever a newer version
|
||||
# exists -- for Python packages, npm packages, the Docker base image, and the
|
||||
# pinned Actions themselves -- so staying patched does not require manual work.
|
||||
# Updates are grouped so a week's bumps arrive as one PR per ecosystem, not a
|
||||
# flood of separate ones.
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Python dependencies (requirements.txt + requirements-optional.txt).
|
||||
- package-ecosystem: pip
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
python:
|
||||
patterns: ["*"]
|
||||
|
||||
# Frontend / tooling npm packages (package.json).
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
npm:
|
||||
patterns: ["*"]
|
||||
|
||||
# The pinned action SHAs used across .github/workflows.
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
actions:
|
||||
patterns: ["*"]
|
||||
|
||||
# The Docker base image in the Dockerfile.
|
||||
- package-ecosystem: docker
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
@@ -0,0 +1,61 @@
|
||||
# CodeQL code scanning
|
||||
#
|
||||
# Purpose: GitHub's own static analysis engine reads the application source
|
||||
# (Python backend + the JavaScript frontend) and looks for real
|
||||
# vulnerabilities -- SQL/command injection, path traversal, auth mistakes,
|
||||
# unsafe deserialization. Findings appear in the repo's Security tab. This is
|
||||
# the deepest check in the suite and the most valuable for a high-profile
|
||||
# target.
|
||||
#
|
||||
# It runs on every push to main and on a weekly schedule (to catch newly
|
||||
# disclosed query patterns against unchanged code). It deliberately does NOT
|
||||
# run on pull requests: most PRs here come from forks, whose read-only token
|
||||
# cannot publish results, which would produce confusing failures. To scan pull
|
||||
# requests too, a maintainer can instead enable CodeQL "default setup" in
|
||||
# Settings -> Security -> Code scanning (one toggle, no file needed) -- see
|
||||
# docs/security-ci.md.
|
||||
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
# Weekly, Monday 06:00 UTC.
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # publish results to the Security tab
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Both are interpreted, so CodeQL needs no build step (build-mode none).
|
||||
language: [python, javascript-typescript]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL analysis
|
||||
uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -0,0 +1,52 @@
|
||||
# Container security: Dockerfile lint
|
||||
#
|
||||
# Purpose: the Docker image is how most people run Odysseus, so it is part of
|
||||
# the attack surface. hadolint lints the Dockerfile for mistakes and insecure
|
||||
# patterns (running as root longer than needed, unpinned base image, bad apt
|
||||
# usage). Blocking.
|
||||
#
|
||||
# The image vulnerability scan (Trivy, advisory) lives in its own file,
|
||||
# container-trivy.yml. Keeping it separate lets that advisory scan be
|
||||
# path-filtered and held to a read-only token on pull requests without
|
||||
# weakening this blocking gate, which must always report so a required check
|
||||
# never hangs.
|
||||
#
|
||||
# Note: a separate open PR (#120) proposes a local `scripts/scan_image.py`.
|
||||
# This job is complementary -- it is a CI gate, not a script a contributor has
|
||||
# to remember to run.
|
||||
|
||||
name: Container scan
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: container-scan-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
hadolint:
|
||||
name: hadolint (Dockerfile lint)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Lint Dockerfile
|
||||
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
|
||||
with:
|
||||
dockerfile: Dockerfile
|
||||
# DL3008: pinning apt package versions is impractical on a -slim base
|
||||
# image. Debian purges old package versions from its repos, so a
|
||||
# pinned version breaks future rebuilds. The base image itself is
|
||||
# what should be pinned (tracked by Dependabot's docker ecosystem).
|
||||
ignore: DL3008
|
||||
@@ -0,0 +1,125 @@
|
||||
# Container image vulnerability scan (advisory)
|
||||
#
|
||||
# Trivy builds the application image and scans it for known-vulnerable OS and
|
||||
# Python packages. Advisory only -- it reports findings to the repo's Security
|
||||
# tab without blocking a merge, because the image inevitably contains
|
||||
# already-known CVEs in upstream packages that are not this project's bug.
|
||||
#
|
||||
# Split from the Dockerfile lint (container-scan.yml) for two reasons:
|
||||
#
|
||||
# - Least privilege. The image build runs Dockerfile instructions, which on a
|
||||
# pull request are attacker-influenceable. That path (the `scan` job) is
|
||||
# held to a read-only token and never publishes results. Only `publish`,
|
||||
# which runs on push to main (curated, fast-forwarded from reviewed dev),
|
||||
# gets security-events:write to upload SARIF.
|
||||
# - Cost. Docs-only changes do not rebuild the image (paths-ignore below),
|
||||
# matching docker-publish.yml. hadolint stays on the broad trigger in
|
||||
# container-scan.yml so the blocking gate always reports.
|
||||
|
||||
name: Container scan (Trivy)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: container-trivy-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Pull requests and manual runs: build and scan under a read-only token.
|
||||
# The build executes PR-supplied Dockerfile instructions, so this job must
|
||||
# not hold any write scope, and it does not upload to the Security tab.
|
||||
scan:
|
||||
name: Trivy (image scan, advisory)
|
||||
if: github.event_name != 'push'
|
||||
runs-on: ubuntu-latest
|
||||
# Advisory: a CVE in an upstream package must not block a PR.
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
# Build without pushing so a broken Dockerfile is caught here, and the
|
||||
# exact image we ship is what gets scanned.
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: odysseus:ci
|
||||
|
||||
- name: Scan image with Trivy
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: odysseus:ci
|
||||
format: table
|
||||
ignore-unfixed: true
|
||||
env:
|
||||
# Pin the vuln DB source to GHCR to avoid rate-limited Docker Hub
|
||||
# mirrors that flake on shared runners.
|
||||
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
||||
|
||||
# Push to main only: build, scan, and publish SARIF to the Security tab.
|
||||
# This is the only path that runs trusted code, so it is the only one granted
|
||||
# security-events:write.
|
||||
publish:
|
||||
name: Trivy (image scan + SARIF upload)
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # upload SARIF to the Security tab
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: odysseus:ci
|
||||
|
||||
- name: Scan image with Trivy
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: odysseus:ci
|
||||
format: sarif
|
||||
output: trivy-results.sarif
|
||||
ignore-unfixed: true
|
||||
env:
|
||||
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
||||
|
||||
- name: Upload Trivy results
|
||||
uses: github/codeql-action/upload-sarif@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
@@ -0,0 +1,71 @@
|
||||
# Supply-chain review
|
||||
#
|
||||
# Purpose: defend against "side-chain" / supply-chain attacks -- a pull request
|
||||
# that adds (or bumps) a dependency to a version with a known vulnerability or a
|
||||
# disallowed license. Two layers:
|
||||
#
|
||||
# - dependency-review: runs ONLY on pull requests. It compares the
|
||||
# dependencies before and after the PR and blocks the merge if the change
|
||||
# pulls in a package with a known security advisory. This is the gate.
|
||||
# - pip-audit: scans the project's current Python requirements against the
|
||||
# advisory database. Advisory only (it never blocks a merge), because it can
|
||||
# flag a pre-existing issue in an already-shipped dependency.
|
||||
|
||||
name: Dependency review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# Default-deny token; jobs grant only read access.
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: dependency-review-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
name: dependency-review (PR gate)
|
||||
# Only meaningful on a pull request -- it needs a base..head diff to review.
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Review dependency changes
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
with:
|
||||
# Fail the PR on any newly introduced moderate-or-worse advisory.
|
||||
fail-on-severity: moderate
|
||||
|
||||
pip-audit:
|
||||
name: pip-audit (advisory)
|
||||
runs-on: ubuntu-latest
|
||||
# Advisory: report known-vulnerable Python deps without blocking the merge.
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Run pip-audit on requirements
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pip install pip-audit==2.10.0
|
||||
pip-audit -r requirements.txt -r requirements-optional.txt --strict
|
||||
@@ -0,0 +1,60 @@
|
||||
# Secret scanning
|
||||
#
|
||||
# Purpose: stop credentials (API keys, tokens, passwords, private keys) from
|
||||
# ever living in the Git history. Odysseus deliberately keeps real secrets in
|
||||
# files that are gitignored (.env, data/), but a slip in a future commit -- or a
|
||||
# malicious pull request that sneaks one in -- would otherwise go unnoticed.
|
||||
# This job reads the repository and the full commit history and fails if it
|
||||
# finds anything that looks like a secret.
|
||||
#
|
||||
# It runs the official gitleaks BINARY directly (pinned to an exact version and
|
||||
# verified against the project's published SHA-256 checksum) rather than the
|
||||
# gitleaks GitHub Action, because the Action asks for a paid license on
|
||||
# organization-owned repos. The binary is free and behaves identically.
|
||||
|
||||
name: Secret scan
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# Start with zero permissions; the single job opts back in to read-only.
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: secret-scan-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
name: gitleaks
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Full history so a secret committed in an earlier commit (and later
|
||||
# deleted) is still caught -- deletion does not remove it from Git.
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
# Pinned version + checksum so a tampered release binary cannot run here.
|
||||
# Bump VERSION/SHA256 together; the checksum comes from the matching
|
||||
# gitleaks_<version>_checksums.txt on the GitHub release.
|
||||
- name: Run gitleaks (pinned, checksum-verified)
|
||||
env:
|
||||
GITLEAKS_VERSION: 8.30.1
|
||||
GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARBALL="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
|
||||
curl -fsSL -o "${TARBALL}" \
|
||||
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${TARBALL}"
|
||||
echo "${GITLEAKS_SHA256} ${TARBALL}" | sha256sum -c -
|
||||
tar -xzf "${TARBALL}" gitleaks
|
||||
# Scan the whole history. Findings print to the log and fail the job.
|
||||
./gitleaks git --no-banner --redact --verbose .
|
||||
@@ -0,0 +1,80 @@
|
||||
# Workflow security (CI that audits the CI)
|
||||
#
|
||||
# Purpose: the GitHub Actions workflows themselves are an attack surface. A
|
||||
# poorly written workflow can leak the repository token, run attacker-supplied
|
||||
# code from a pull request, or pull in a tampered third-party action. These two
|
||||
# tools check every workflow file in this repo for those mistakes:
|
||||
#
|
||||
# - actionlint: catches workflow syntax errors and shell-script bugs inside
|
||||
# `run:` steps before they reach main.
|
||||
# - zizmor: a security linter for Actions. Flags template-injection holes,
|
||||
# unpinned actions, credential persistence, and over-broad token
|
||||
# permissions -- exactly the patterns the rest of this CI is built to avoid.
|
||||
#
|
||||
# Add this early: it then audits every workflow added after it.
|
||||
|
||||
name: Workflow security
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# Default-deny token; each job grants only read access to the code.
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: workflow-security-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
name: actionlint
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Pinned version + checksum so a tampered binary cannot run here.
|
||||
- name: Run actionlint (pinned, checksum-verified)
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARBALL="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
||||
curl -fsSL -o "${TARBALL}" \
|
||||
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${TARBALL}"
|
||||
echo "${ACTIONLINT_SHA256} ${TARBALL}" | sha256sum -c -
|
||||
tar -xzf "${TARBALL}" actionlint
|
||||
./actionlint -color
|
||||
|
||||
zizmor:
|
||||
name: zizmor (Actions SAST)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
# Pinned zizmor release. --offline keeps the audit hermetic (no network
|
||||
# calls about the actions it inspects); --min-severity=low surfaces
|
||||
# everything so nothing slips through under the gate.
|
||||
- name: Run zizmor
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pip install zizmor==1.25.2
|
||||
zizmor --offline --min-severity=low .github/workflows/
|
||||
@@ -218,7 +218,7 @@ docker compose exec odysseus sh -lc 'test -e /dev/kfd && test -d /dev/dri && ls
|
||||
> the CUDA Toolkit at runtime. If Cookbook logs show `Unable to find cudart
|
||||
> library`, `Could NOT find CUDAToolkit`, `CUDA Toolkit not found`, or
|
||||
> tensors/layers assigned to CPU, that is a Cookbook/llama.cpp build issue —
|
||||
> not a Docker passthrough failure. Re-install the serve engine via
|
||||
> not a Docker passthrough failure. Reinstall the serve engine via
|
||||
> **Cookbook → Dependencies** to get a CUDA-enabled build.
|
||||
>
|
||||
> The same split applies to AMD/ROCm: seeing `/dev/kfd` and `/dev/dri` inside
|
||||
|
||||
@@ -498,11 +498,13 @@ app.state.session_manager = session_manager
|
||||
memory_manager = components["memory_manager"]
|
||||
memory_vector = components.get("memory_vector")
|
||||
upload_handler = components["upload_handler"]
|
||||
app.state.upload_handler = upload_handler
|
||||
personal_docs_mgr = components["personal_docs_manager"]
|
||||
api_key_manager = components["api_key_manager"]
|
||||
preset_manager = components["preset_manager"]
|
||||
chat_processor = components["chat_processor"]
|
||||
research_handler = components["research_handler"]
|
||||
app.state.research_handler = research_handler
|
||||
chat_handler = components["chat_handler"]
|
||||
model_discovery = components["model_discovery"]
|
||||
skills_manager = components["skills_manager"]
|
||||
@@ -674,6 +676,9 @@ app.include_router(setup_shell_routes())
|
||||
from routes.cookbook_routes import setup_cookbook_routes
|
||||
app.include_router(setup_cookbook_routes())
|
||||
|
||||
from routes.workspace_routes import setup_workspace_routes
|
||||
app.include_router(setup_workspace_routes())
|
||||
|
||||
# Hardware model fitting (cookbook "What Fits?" tab)
|
||||
from routes.hwfit_routes import setup_hwfit_routes
|
||||
app.include_router(setup_hwfit_routes())
|
||||
|
||||
@@ -191,6 +191,8 @@ def _windows_bash_fallbacks() -> List[str]:
|
||||
base = os.environ.get(env_name)
|
||||
if base:
|
||||
roots.append(ntpath.join(base, "Git"))
|
||||
if env_name == "LocalAppData":
|
||||
roots.append(ntpath.join(base, "Programs", "Git"))
|
||||
roots.extend(_WINDOWS_BASH_DEFAULT_ROOTS)
|
||||
|
||||
paths: List[str] = []
|
||||
@@ -298,7 +300,7 @@ def is_wsl() -> bool:
|
||||
import sys
|
||||
if sys.platform.startswith("linux") or os.name == "posix":
|
||||
try:
|
||||
with open("/proc/version", "r") as f:
|
||||
with open("/proc/version", "r", encoding="utf-8", errors="ignore") as f:
|
||||
if "microsoft" in f.read().lower():
|
||||
return True
|
||||
except Exception:
|
||||
@@ -366,6 +368,10 @@ def _ssh_exec_argv(
|
||||
strict_host_key_checking: bool | None = None,
|
||||
) -> list[str]:
|
||||
"""Build a consistent ssh argv for remote command execution."""
|
||||
remote_value = str(remote or "").strip()
|
||||
remote_host = remote_value.rsplit("@", 1)[-1]
|
||||
if not remote_value or remote_value.startswith("-") or not remote_host or remote_host.startswith("-"):
|
||||
raise ValueError("Invalid SSH remote host")
|
||||
argv = ["ssh"]
|
||||
if connect_timeout is not None:
|
||||
argv.extend(["-o", f"ConnectTimeout={int(connect_timeout)}"])
|
||||
|
||||
+10
-3
@@ -25,9 +25,16 @@
|
||||
--radius: 8px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; scroll-snap-type: y proximity; scroll-padding-top: 60px; }
|
||||
/* Each section is a full-viewport "page" with its content centered, so only
|
||||
one shows at a time and the snap is obvious. */
|
||||
html { scroll-behavior: smooth; scroll-padding-top: 60px; }
|
||||
/* REMOVED: "scroll-snap-type: y proximity"
|
||||
The idea was: >>Each section is a full-viewport "page" with its content centered,
|
||||
so only one shows at a time and the snap is obvious.<<
|
||||
|
||||
PROBLEM: sections easily grow taller than 100vh IRL
|
||||
This cause forced jumps mid-read. It's intrusive UX.
|
||||
The landing-page is not a PowerPoint presentation!
|
||||
|
||||
Preserved: CSS snap-points to avoid destroying code meta-data*/
|
||||
.hero, section {
|
||||
scroll-snap-align: start; min-height: 100vh;
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Security CI guide
|
||||
|
||||
This project runs a set of automated security checks on every pull request and
|
||||
on every push to `main`. This page explains what each one does, whether it can
|
||||
block a merge, and the few one-time settings you should turn on to get the full
|
||||
benefit.
|
||||
|
||||
## What runs, and why
|
||||
|
||||
Each check lives in its own file under `.github/workflows/`. They run
|
||||
automatically; you do not start them.
|
||||
|
||||
| Check | What it protects against | Blocks a merge? |
|
||||
|---|---|---|
|
||||
| **Secret scan** (gitleaks) | An API key, token, or password being committed by mistake or on purpose | Yes |
|
||||
| **Workflow security** (actionlint + zizmor) | A broken or insecure automation file that could leak the repo's access token | Yes |
|
||||
| **Dependency review** | A pull request that adds a software library with a known security hole | Yes |
|
||||
| **pip-audit** | Known security holes in the Python libraries already used | No (advisory) |
|
||||
| **Container scan: hadolint** | Mistakes and insecure patterns in the `Dockerfile` | Yes |
|
||||
| **Container scan: Trivy** | Known security holes in the Docker image | No (advisory) |
|
||||
| **CodeQL** | Real bugs in the app's own code: injection, auth mistakes, path traversal | No (advisory) |
|
||||
|
||||
"Blocks a merge" means a red X appears on the pull request and, once you enable
|
||||
the setting below, the **Merge** button is disabled until it is fixed.
|
||||
|
||||
"Advisory" means it reports problems into the repository's **Security** tab so
|
||||
you can review them on your own schedule, but it never stops a merge. These are
|
||||
advisory on purpose: they often flag long-standing issues in other people's
|
||||
libraries, not something a given pull request introduced.
|
||||
|
||||
## Where results appear
|
||||
|
||||
- **Checks tab of a pull request**: the pass/fail of each check. A green tick is
|
||||
good; a red X needs attention.
|
||||
- **Security tab of the repository**: detailed findings from the advisory
|
||||
scanners (Trivy and CodeQL). This is your dashboard.
|
||||
|
||||
## If a check fails
|
||||
|
||||
- **Secret scan failed**: a real credential may have been committed. Treat it as
|
||||
leaked: rotate (regenerate) that key or token immediately, then remove it from
|
||||
the file. Do not just delete the commit; assume it was seen.
|
||||
- **Dependency review failed**: the pull request adds a library with a known
|
||||
vulnerability. Ask the contributor to use a patched version, or decline the
|
||||
change.
|
||||
- **hadolint / workflow security failed**: the contributor changed the
|
||||
`Dockerfile` or an automation file in a way the linter rejects. Ask them to
|
||||
address the message shown in the failed check.
|
||||
|
||||
## One-time settings to turn on
|
||||
|
||||
These two settings unlock the full value. You only do them once.
|
||||
|
||||
### 1. Require the blocking checks before merging
|
||||
|
||||
This makes the **Merge** button refuse to work until the gating checks pass.
|
||||
|
||||
1. Go to the repository on GitHub.
|
||||
2. Click **Settings** (top right of the repo).
|
||||
3. In the left sidebar, click **Branches**.
|
||||
4. Under **Branch protection rules**, click **Add branch ruleset** (or **Add
|
||||
rule**), and set the branch name pattern to `dev` (this is the branch all
|
||||
pull requests target; `main` is fast-forwarded at releases).
|
||||
5. Enable **Require status checks to pass before merging**.
|
||||
6. In the search box that appears, add these checks by name:
|
||||
- `Python syntax (compileall)`
|
||||
- `JS syntax (node --check)`
|
||||
- `gitleaks`
|
||||
- `actionlint`
|
||||
- `zizmor (Actions SAST)`
|
||||
- `hadolint (Dockerfile lint)`
|
||||
- `dependency-review (PR gate)`
|
||||
|
||||
The first two come from the correctness CI (`ci.yml`); the rest are this
|
||||
security suite. Leave pytest, pip-audit, Trivy, and CodeQL unchecked so they
|
||||
stay advisory.
|
||||
7. Also enable **Require a pull request before merging** and **Require review
|
||||
from Code Owners** (this uses the `.github/CODEOWNERS` file so every change
|
||||
needs your sign-off).
|
||||
8. Click **Create** / **Save changes**.
|
||||
|
||||
Note: a check name only appears in the list after it has run at least once, so
|
||||
let the workflows run on one pull request first, then add them here.
|
||||
|
||||
### 2. Turn on the Security tab features
|
||||
|
||||
1. **Settings -> Code security** (or **Code security and analysis**).
|
||||
2. Turn on **Dependency graph** (usually on by default for public repos) -- this
|
||||
powers Dependency review and Dependabot.
|
||||
3. Turn on **Dependabot alerts** and **Dependabot security updates**.
|
||||
4. Under **Code scanning**, you have two ways to scan the app code with CodeQL:
|
||||
- The included `codeql.yml` workflow already scans `main` and runs weekly.
|
||||
- To also scan **pull requests** (recommended, since most contributions come
|
||||
from forks), click **Set up -> Default** under Code scanning. GitHub then
|
||||
runs CodeQL on pull requests for you, with no token limitations.
|
||||
|
||||
## Keeping it current
|
||||
|
||||
`.github/dependabot.yml` opens small weekly pull requests to update Python and
|
||||
npm packages, the Docker base image, and the pinned automation actions
|
||||
themselves. Review and merge those like any other pull request; they keep the
|
||||
project patched without manual tracking.
|
||||
+14
-2
@@ -30,14 +30,26 @@ function Fail($msg) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Test-WindowsBashStub($path) {
|
||||
if (-not $path) { return $false }
|
||||
$lowered = $path.ToLowerInvariant()
|
||||
foreach ($stub in @("system32\bash.exe", "sysnative\bash.exe", "windowsapps\bash.exe")) {
|
||||
if ($lowered.Contains($stub)) { return $true }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Find-GitBash {
|
||||
$cmd = Get-Command bash -ErrorAction SilentlyContinue
|
||||
if ($cmd) { return $cmd.Source }
|
||||
if ($cmd -and -not (Test-WindowsBashStub $cmd.Source)) { return $cmd.Source }
|
||||
|
||||
$roots = @()
|
||||
foreach ($name in @("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)", "LocalAppData")) {
|
||||
$base = [Environment]::GetEnvironmentVariable($name)
|
||||
if ($base) { $roots += (Join-Path $base "Git") }
|
||||
if ($base) {
|
||||
$roots += (Join-Path $base "Git")
|
||||
if ($name -eq "LocalAppData") { $roots += (Join-Path $base "Programs\Git") }
|
||||
}
|
||||
}
|
||||
$roots += @("C:\Program Files\Git", "C:\Program Files (x86)\Git")
|
||||
|
||||
|
||||
@@ -43,3 +43,7 @@ qrcode[pil]
|
||||
croniter
|
||||
pytest
|
||||
pytest-asyncio
|
||||
# starlette.testclient prefers httpx2 since Starlette 1.2.0 and warns on every
|
||||
# TestClient import when only classic httpx is present. Runtime code keeps
|
||||
# using `httpx` above; this is test-client only.
|
||||
httpx2
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import re
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
_REMOTE_HOST_RE = re.compile(
|
||||
r"^(?:[A-Za-z0-9][A-Za-z0-9._-]*@)?[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
)
|
||||
_SSH_PORT_RE = re.compile(r"^\d{1,5}$")
|
||||
|
||||
|
||||
def validate_remote_host(v: str | None) -> str | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if not _REMOTE_HOST_RE.match(v):
|
||||
raise HTTPException(
|
||||
400,
|
||||
"Invalid remote_host — must be host or user@host, no SSH option syntax",
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
def validate_ssh_port(v: str | None) -> str | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if not _SSH_PORT_RE.fullmatch(str(v)):
|
||||
raise HTTPException(400, "Invalid ssh_port")
|
||||
port = int(v)
|
||||
if port < 1 or port > 65535:
|
||||
raise HTTPException(400, "Invalid ssh_port")
|
||||
return str(port)
|
||||
@@ -154,6 +154,7 @@ def setup_api_token_routes() -> APIRouter:
|
||||
@router.patch("/tokens/{token_id}")
|
||||
async def update_token(request: Request, token_id: str):
|
||||
require_admin(request)
|
||||
current_user = get_current_user(request)
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
@@ -162,6 +163,8 @@ def setup_api_token_routes() -> APIRouter:
|
||||
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
|
||||
if not token:
|
||||
raise HTTPException(404, "Token not found")
|
||||
if current_user and token.owner != current_user:
|
||||
raise HTTPException(403, "Not your token")
|
||||
if isinstance(payload.get("name"), str) and payload["name"].strip():
|
||||
token.name = payload["name"].strip()[:MAX_NAME_LEN]
|
||||
# Only touch scopes when the caller actually sent them. A partial
|
||||
@@ -189,10 +192,14 @@ def setup_api_token_routes() -> APIRouter:
|
||||
@router.delete("/tokens/{token_id}")
|
||||
def delete_token(request: Request, token_id: str):
|
||||
require_admin(request)
|
||||
current_user = get_current_user(request)
|
||||
with get_db_session() as db:
|
||||
deleted = db.query(ApiToken).filter(ApiToken.id == token_id).delete()
|
||||
if not deleted:
|
||||
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
|
||||
if not token:
|
||||
raise HTTPException(404, "Token not found")
|
||||
if current_user and token.owner != current_user:
|
||||
raise HTTPException(403, "Not your token")
|
||||
db.delete(token)
|
||||
_invalidate_cache(request)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@@ -367,6 +367,20 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to rename user prefs %s -> %s: %s", old_username, new_username, e)
|
||||
|
||||
# In-flight deep-research tasks live in the process-local
|
||||
# ResearchHandler registry. They are not covered by the persisted JSON
|
||||
# migration above, but the research routes filter and cancel by this
|
||||
# owner field while the job is running. Do this before sweeping
|
||||
# completed JSON files so a job that finishes during the rename saves
|
||||
# with the new owner or is caught by the disk sweep below.
|
||||
try:
|
||||
rh = getattr(request.app.state, "research_handler", None)
|
||||
rename_owner = getattr(rh, "rename_owner", None)
|
||||
if callable(rename_owner):
|
||||
rename_owner(old_username, new_username)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to rename active research tasks %s -> %s: %s", old_username, new_username, e)
|
||||
|
||||
# deep_research: each completed report is a standalone JSON file with
|
||||
# an `owner` field. research_routes filters by d.get("owner") == user,
|
||||
# so a stale owner makes every report invisible to the renamed user.
|
||||
@@ -402,6 +416,17 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to rename memory.json owner references %s -> %s: %s", old_username, new_username, e)
|
||||
|
||||
# uploads.json: upload rows use owner metadata for access checks and
|
||||
# owner-prefixed index keys for dedupe. Rename both so attachments keep
|
||||
# resolving after the account username changes.
|
||||
try:
|
||||
upload_handler = getattr(request.app.state, "upload_handler", None)
|
||||
rename_owner = getattr(upload_handler, "rename_owner", None)
|
||||
if callable(rename_owner):
|
||||
rename_owner(old_username, new_username)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to rename upload owner references %s -> %s: %s", old_username, new_username, e)
|
||||
|
||||
# skills: SKILL.md frontmatter carries owner: <username>; the usage
|
||||
# sidecar (_usage.json) keys entries as owner::skill-name. Both must
|
||||
# be updated or the renamed user's Skills panel goes empty.
|
||||
|
||||
+50
-4
@@ -62,6 +62,33 @@ def _stream_set(session_id: str, **fields) -> None:
|
||||
rec.update(fields)
|
||||
|
||||
|
||||
def _resolve_request_workspace(request, raw_value) -> tuple:
|
||||
"""Resolve the posted workspace for this request: (workspace, rejected).
|
||||
|
||||
Privilege is checked BEFORE the path ever touches the filesystem. Only
|
||||
admin/single-user callers can use the workspace-backed file/shell tools,
|
||||
so only they get vet_workspace() and the workspace_rejected signal. For
|
||||
any other caller the submitted value is dropped uniformly, with no vetting
|
||||
and no event: otherwise the presence/absence of workspace_rejected would
|
||||
let a non-admin chat caller probe which host paths exist.
|
||||
|
||||
vet_workspace rejects non-directories, sensitive roots (.ssh, .gnupg,
|
||||
...), and filesystem roots; on rejection there is no confinement and the
|
||||
default tool-path allowlist applies. The rejected value is surfaced so the
|
||||
stream can tell an admin client (which believes a workspace is active)
|
||||
that it was dropped.
|
||||
"""
|
||||
requested = (raw_value or "").strip()
|
||||
if not requested:
|
||||
return "", ""
|
||||
from src.tool_security import owner_is_admin_or_single_user
|
||||
if not owner_is_admin_or_single_user(get_current_user(request)):
|
||||
return "", ""
|
||||
from src.tool_execution import vet_workspace
|
||||
workspace = vet_workspace(requested) or ""
|
||||
return workspace, (requested if not workspace else "")
|
||||
|
||||
|
||||
def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool:
|
||||
if not session_url or not endpoint_base:
|
||||
return False
|
||||
@@ -447,8 +474,11 @@ def setup_chat_routes(
|
||||
use_research = form_data.get("use_research")
|
||||
time_filter = form_data.get("time_filter")
|
||||
preset_id = form_data.get("preset_id")
|
||||
allow_bash = form_data.get("allow_bash")
|
||||
allow_web_search = form_data.get("allow_web_search")
|
||||
# Issue #3229: API callers send JSON, not FormData. Read from the
|
||||
# JSON body as fallback so callers who send {"allow_bash": true}
|
||||
# actually get bash enabled.
|
||||
allow_bash = form_data.get("allow_bash") or (body or {}).get("allow_bash")
|
||||
allow_web_search = form_data.get("allow_web_search") or (body or {}).get("allow_web_search")
|
||||
use_rag = form_data.get("use_rag")
|
||||
search_context = form_data.get("search_context") # pre-fetched web search results (compare mode)
|
||||
compare_mode = str(form_data.get("compare_mode", "")).lower() == "true"
|
||||
@@ -457,6 +487,10 @@ def setup_chat_routes(
|
||||
# manual form posts that still send plan_mode=true.
|
||||
plan_mode = False
|
||||
chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent'
|
||||
# Workspace: confine the agent's file/shell tools to this folder.
|
||||
workspace, workspace_rejected = _resolve_request_workspace(
|
||||
request, form_data.get("workspace")
|
||||
)
|
||||
# Plan mode is a modifier on agent mode — it only makes sense with tools.
|
||||
if plan_mode:
|
||||
chat_mode = "agent"
|
||||
@@ -656,9 +690,13 @@ def setup_chat_routes(
|
||||
|
||||
# Build disabled-tools set from frontend toggles + user privileges
|
||||
disabled_tools = set()
|
||||
if str(allow_bash).lower() != "true":
|
||||
# Only disable bash/web_search when the caller *explicitly* set them
|
||||
# to a falsy value. When unset (None), defer to per-user privilege
|
||||
# checks below — this lets admins with can_use_bash=True use bash
|
||||
# by default without having to send allow_bash in every request.
|
||||
if allow_bash is not None and str(allow_bash).lower() != "true":
|
||||
disabled_tools.add("bash")
|
||||
if str(allow_web_search).lower() != "true":
|
||||
if allow_web_search is not None and str(allow_web_search).lower() != "true":
|
||||
disabled_tools.add("web_search")
|
||||
disabled_tools.add("web_fetch")
|
||||
|
||||
@@ -761,6 +799,13 @@ def setup_chat_routes(
|
||||
# Register active stream for partial-save safety net
|
||||
_active_streams[session] = {"status": "streaming", "partial": "", "query": message, "is_research": effective_do_research, "mode": _effective_mode}
|
||||
|
||||
# The client sent a workspace the server refused to bind (deleted
|
||||
# folder, file path, sensitive dir, filesystem root). Tell it up
|
||||
# front so the UI can clear the pill instead of displaying a
|
||||
# confinement that is not actually in effect.
|
||||
if workspace_rejected:
|
||||
yield f"data: {json.dumps({'type': 'workspace_rejected', 'data': {'path': workspace_rejected}})}\n\n"
|
||||
|
||||
if ctx.preprocessed.attachment_meta:
|
||||
yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n"
|
||||
|
||||
@@ -1138,6 +1183,7 @@ def setup_chat_routes(
|
||||
fallbacks=_fallback_candidates,
|
||||
plan_mode=plan_mode,
|
||||
approved_plan=approved_plan or None,
|
||||
workspace=workspace or None,
|
||||
):
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
try:
|
||||
|
||||
+21
-23
@@ -1,16 +1,19 @@
|
||||
"""cookbook_helpers.py — validators + small helpers shared by the cookbook routes.
|
||||
Extracted from cookbook_routes.py; the routes module imports the symbols it needs."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import ntpath
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from routes._validators import validate_remote_host, validate_ssh_port
|
||||
from core.platform_compat import _ssh_exec_argv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,16 +33,12 @@ _LOCAL_MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
_OLLAMA_MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,200}$")
|
||||
# Include pattern is a glob: allow typical safe glyphs only.
|
||||
_INCLUDE_RE = re.compile(r"^[A-Za-z0-9._\-*?/\[\]]+$")
|
||||
# Remote host: either `user@host` or plain `host` (alias is allowed), where host
|
||||
# is a safe DNS-like token or a short SSH config alias.
|
||||
_REMOTE_HOST_RE = re.compile(r"^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$")
|
||||
# HF tokens and API tokens are url-safe base64-like.
|
||||
_TOKEN_RE = re.compile(r"^[A-Za-z0-9._~+/=-]+$")
|
||||
# Session IDs we mint look like "cookbook-deadbeef" or "serve-deadbeef".
|
||||
# Anything beyond plain alphanumerics + dash + underscore could break out
|
||||
# of the shell/PowerShell contexts the value lands in.
|
||||
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||
_SSH_PORT_RE = re.compile(r"^\d{1,5}$")
|
||||
_GPU_LIST_RE = re.compile(r"^\d+(?:,\d+)*$")
|
||||
# A download target directory. Absolute or ~-relative path; safe path glyphs
|
||||
# only (no quotes or shell metacharacters). Spaces are allowed because command
|
||||
@@ -85,14 +84,6 @@ def _validate_include(v: str | None) -> str | None:
|
||||
return v
|
||||
|
||||
|
||||
def _validate_remote_host(v: str | None) -> str | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if not _REMOTE_HOST_RE.match(v):
|
||||
raise HTTPException(400, "Invalid remote_host — must be host or user@host, no SSH option syntax")
|
||||
return v
|
||||
|
||||
|
||||
def _validate_token(v: str | None) -> str | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
@@ -101,6 +92,24 @@ def _validate_token(v: str | None) -> str | None:
|
||||
return v
|
||||
|
||||
|
||||
def load_stored_hf_token(*, state_path: Path | str | None = None) -> str:
|
||||
"""Return the decrypted HF token from cookbook_state.json, else env fallback."""
|
||||
path = Path(state_path) if state_path else Path(os.environ.get("DATA_DIR", "data")) / "cookbook_state.json"
|
||||
token = ""
|
||||
if path.exists():
|
||||
try:
|
||||
state = json.loads(path.read_text(encoding="utf-8"))
|
||||
env = state.get("env") if isinstance(state, dict) else {}
|
||||
if isinstance(env, dict) and env.get("hfToken"):
|
||||
from src.secret_storage import decrypt
|
||||
token = decrypt(env.get("hfToken") or "")
|
||||
except Exception:
|
||||
token = ""
|
||||
if not token:
|
||||
token = (os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or "").strip()
|
||||
return token
|
||||
|
||||
|
||||
def _validate_local_dir(v: str | None) -> str | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
@@ -120,17 +129,6 @@ def _validate_local_dir(v: str | None) -> str | None:
|
||||
return v
|
||||
|
||||
|
||||
def _validate_ssh_port(v: str | None) -> str | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if not _SSH_PORT_RE.fullmatch(str(v)):
|
||||
raise HTTPException(400, "Invalid ssh_port")
|
||||
port = int(v)
|
||||
if port < 1 or port > 65535:
|
||||
raise HTTPException(400, "Invalid ssh_port")
|
||||
return str(port)
|
||||
|
||||
|
||||
def _validate_gpus(v: str | None) -> str | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Pure helpers for shaping cookbook task output for the status response.
|
||||
|
||||
Kept dependency-free (no FastAPI / SQLAlchemy imports) so the behavior can be
|
||||
unit-tested without standing up the whole app.
|
||||
"""
|
||||
|
||||
|
||||
def error_aware_output_tail(full_snapshot: str, status: str) -> str:
|
||||
"""Return the trailing slice of a task log for the status response.
|
||||
|
||||
Failed tasks return the last 50 lines so the "Copy last 50 lines" action
|
||||
surfaces the actual error context (stack traces, build output). Running and
|
||||
other non-error tasks keep the cheaper 12-line tail to limit the payload on
|
||||
the 10s polling interval.
|
||||
"""
|
||||
if not full_snapshot:
|
||||
return ""
|
||||
tail_lines = 50 if status == "error" else 12
|
||||
return "\n".join(full_snapshot.splitlines()[-tail_lines:])
|
||||
+52
-40
@@ -19,6 +19,7 @@ from src.constants import COOKBOOK_STATE_FILE
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.middleware import require_admin
|
||||
from routes._validators import validate_remote_host, validate_ssh_port
|
||||
from core.platform_compat import (
|
||||
IS_WINDOWS,
|
||||
detached_popen_kwargs,
|
||||
@@ -29,16 +30,20 @@ from core.platform_compat import (
|
||||
which_tool,
|
||||
)
|
||||
from routes.shell_routes import TMUX_LOG_DIR
|
||||
from routes.cookbook_output import error_aware_output_tail
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from routes.cookbook_helpers import (
|
||||
_SSH_PORT_RE, _REMOTE_HOST_RE, _SESSION_ID_RE,
|
||||
_validate_repo_id, _validate_serve_model_id, _validate_include, _validate_remote_host, _validate_token,
|
||||
_validate_local_dir, _validate_ssh_port, _validate_gpus, _shell_path,
|
||||
_SESSION_ID_RE, _validate_repo_id, _validate_serve_model_id, _validate_include, _validate_token,
|
||||
_validate_local_dir, _validate_gpus, _shell_path,
|
||||
_ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase,
|
||||
_safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
|
||||
_append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script,
|
||||
load_stored_hf_token,
|
||||
_append_vllm_linux_preflight_lines, _ollama_bind_from_cmd, _pip_install_fallback_chain,
|
||||
_pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
||||
_diagnose_serve_output, run_ssh_command_async,
|
||||
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
|
||||
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
||||
ModelDownloadRequest, ServeRequest,
|
||||
@@ -233,14 +238,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
return state
|
||||
|
||||
def _load_stored_hf_token() -> str:
|
||||
if not _cookbook_state_path.exists():
|
||||
return ""
|
||||
try:
|
||||
state = json.loads(_cookbook_state_path.read_text(encoding="utf-8"))
|
||||
env = state.get("env") if isinstance(state, dict) else {}
|
||||
return _decrypt_secret(env.get("hfToken") if isinstance(env, dict) else "")
|
||||
except Exception:
|
||||
return ""
|
||||
return load_stored_hf_token(state_path=_cookbook_state_path)
|
||||
|
||||
def _cookbook_ssh_dir() -> Path:
|
||||
# The Docker image keeps cookbook keys under /app/.ssh; that path only
|
||||
@@ -407,8 +405,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
else:
|
||||
_validate_repo_id(req.repo_id)
|
||||
_validate_include(req.include)
|
||||
_validate_remote_host(req.remote_host)
|
||||
req.ssh_port = _validate_ssh_port(req.ssh_port)
|
||||
validate_remote_host(req.remote_host)
|
||||
req.ssh_port = validate_ssh_port(req.ssh_port)
|
||||
req.local_dir = _validate_local_dir(req.local_dir)
|
||||
req.hf_token = "" if is_ollama_download else (req.hf_token or _load_stored_hf_token())
|
||||
_validate_token(req.hf_token)
|
||||
@@ -739,9 +737,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# Validate shell-bound inputs, matching the sibling list_gpus endpoint —
|
||||
# `host`/`ssh_port` are interpolated into an ssh command below, so an
|
||||
# unvalidated value (e.g. "x'; rm -rf ~ #") would be command injection.
|
||||
host = _validate_remote_host(host)
|
||||
if ssh_port is not None and ssh_port != "" and not _SSH_PORT_RE.fullmatch(ssh_port):
|
||||
raise HTTPException(400, "Invalid ssh_port")
|
||||
host = validate_remote_host(host)
|
||||
ssh_port = validate_ssh_port(ssh_port)
|
||||
TMUX_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
model_dirs = []
|
||||
@@ -890,11 +887,16 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# listening" check without requiring ss/netstat/nmap.
|
||||
ssh_base = ["ssh", "-o", "ConnectTimeout=4", "-o", "StrictHostKeyChecking=no"]
|
||||
if ssh_port and str(ssh_port) != "22":
|
||||
if not _SSH_PORT_RE.match(str(ssh_port)):
|
||||
try:
|
||||
ssh_port = validate_ssh_port(ssh_port)
|
||||
except HTTPException:
|
||||
return None
|
||||
ssh_base.extend(["-p", str(ssh_port)])
|
||||
host_arg = remote
|
||||
if not _REMOTE_HOST_RE.match(host_arg):
|
||||
try:
|
||||
host_arg = validate_remote_host(remote)
|
||||
except HTTPException:
|
||||
return None
|
||||
if not host_arg:
|
||||
return None
|
||||
probe_ports = " ".join(str(start_port + i) for i in range(max_offset + 1))
|
||||
script = (
|
||||
@@ -1197,8 +1199,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
"""
|
||||
require_admin(request)
|
||||
# Defence-in-depth: reject values that could break out of shell contexts.
|
||||
_validate_remote_host(req.remote_host)
|
||||
req.ssh_port = _validate_ssh_port(req.ssh_port)
|
||||
validate_remote_host(req.remote_host)
|
||||
req.ssh_port = validate_ssh_port(req.ssh_port)
|
||||
req.gpus = _validate_gpus(req.gpus)
|
||||
req.hf_token = req.hf_token or _load_stored_hf_token()
|
||||
_validate_token(req.hf_token)
|
||||
@@ -1638,12 +1640,11 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
async def server_setup(request: Request, req: SetupRequest):
|
||||
"""Install required dependencies on a remote server via SSH."""
|
||||
require_admin(request)
|
||||
host = _validate_remote_host(req.host)
|
||||
host = validate_remote_host(req.host)
|
||||
if not host:
|
||||
raise HTTPException(400, "host is required")
|
||||
port = req.ssh_port
|
||||
if port is not None and port != "" and not re.fullmatch(r"\d{1,5}", port):
|
||||
raise HTTPException(400, "Invalid ssh_port")
|
||||
port = validate_ssh_port(port)
|
||||
pf = f"-p {port} " if port and port != "22" else ""
|
||||
|
||||
# Detect platform: Windows first (echo %OS% → Windows_NT), then Termux, then Linux
|
||||
@@ -1887,9 +1888,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
`busy` is True when free_mb/total_mb < 0.5.
|
||||
"""
|
||||
require_admin(request)
|
||||
host = _validate_remote_host(host)
|
||||
if ssh_port is not None and ssh_port != "" and not _SSH_PORT_RE.fullmatch(ssh_port):
|
||||
raise HTTPException(400, "Invalid ssh_port")
|
||||
host = validate_remote_host(host)
|
||||
ssh_port = validate_ssh_port(ssh_port)
|
||||
gpu_query = "nvidia-smi --query-gpu=index,name,memory.free,memory.total,memory.used,utilization.gpu,uuid --format=csv,noheader,nounits"
|
||||
nvidia_error = None
|
||||
try:
|
||||
@@ -2046,9 +2046,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
sig = (req.signal or "TERM").upper()
|
||||
if sig not in ("TERM", "KILL", "INT"):
|
||||
raise HTTPException(400, "signal must be TERM, KILL, or INT")
|
||||
host = _validate_remote_host(req.host)
|
||||
if req.ssh_port and not _SSH_PORT_RE.fullmatch(req.ssh_port):
|
||||
raise HTTPException(400, "Invalid ssh_port")
|
||||
host = validate_remote_host(req.host)
|
||||
req.ssh_port = validate_ssh_port(req.ssh_port)
|
||||
kill_cmd = f"kill -{sig} {req.pid}"
|
||||
try:
|
||||
if host:
|
||||
@@ -2382,14 +2381,19 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
host = (srv.get("host") or "").strip()
|
||||
if not host:
|
||||
continue # local-only entry; the /proc scan handles it
|
||||
if not _REMOTE_HOST_RE.match(host):
|
||||
try:
|
||||
host = validate_remote_host(host)
|
||||
except HTTPException:
|
||||
continue
|
||||
sport = str(srv.get("port") or "").strip()
|
||||
ssh_base = ["ssh", "-o", "ConnectTimeout=4", "-o", "StrictHostKeyChecking=no"]
|
||||
if sport and sport != "22":
|
||||
if not _SSH_PORT_RE.match(sport):
|
||||
try:
|
||||
sport = validate_ssh_port(sport)
|
||||
except HTTPException:
|
||||
continue
|
||||
ssh_base.extend(["-p", sport])
|
||||
if sport != "22":
|
||||
ssh_base.extend(["-p", sport])
|
||||
|
||||
try:
|
||||
ls = subprocess.run(
|
||||
@@ -2743,12 +2747,18 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
if not _SESSION_ID_RE.match(session_id):
|
||||
logger.warning(f"Skipping task with unsafe session_id: {session_id!r}")
|
||||
continue
|
||||
if remote and not _REMOTE_HOST_RE.match(remote):
|
||||
logger.warning(f"Skipping task with unsafe remoteHost: {remote!r}")
|
||||
continue
|
||||
if _tport and not _SSH_PORT_RE.match(str(_tport)):
|
||||
logger.warning(f"Skipping task with unsafe sshPort: {_tport!r}")
|
||||
continue
|
||||
if remote:
|
||||
try:
|
||||
remote = validate_remote_host(remote)
|
||||
except HTTPException:
|
||||
logger.warning(f"Skipping task with unsafe remoteHost: {remote!r}")
|
||||
continue
|
||||
if _tport:
|
||||
try:
|
||||
_tport = validate_ssh_port(str(_tport))
|
||||
except HTTPException:
|
||||
logger.warning(f"Skipping task with unsafe sshPort: {_tport!r}")
|
||||
continue
|
||||
if task_platform == "windows" and remote:
|
||||
# Windows: check PID file + Get-Process, read log tail
|
||||
sd = "$env:TEMP\\odysseus-sessions"
|
||||
@@ -2861,6 +2871,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# snapshot to classify (DOWNLOAD_OK / exit marker) — evaluate it even
|
||||
# when the PID is gone instead of blindly reporting "stopped".
|
||||
download_zero_files = False
|
||||
exit_code = None
|
||||
status = "unknown"
|
||||
download_has_ok = task_type == "download" and "DOWNLOAD_OK" in full_snapshot
|
||||
download_has_failed = task_type == "download" and "DOWNLOAD_FAILED" in full_snapshot
|
||||
@@ -2934,7 +2945,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
status = "error"
|
||||
if download_zero_files:
|
||||
diagnosis = {"message": "No matching files were downloaded. The model repo or filename/quant pattern may be wrong (for example a ':Q4_K_M' tag that does not exist in the repo). Check the repo and the include/quant pattern."}
|
||||
output_tail = "\n".join(full_snapshot.splitlines()[-12:]) if full_snapshot else ""
|
||||
output_tail = error_aware_output_tail(full_snapshot, status)
|
||||
|
||||
results.append({
|
||||
"session_id": session_id,
|
||||
@@ -2945,6 +2956,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
"phase": serve_phase,
|
||||
"diagnosis": diagnosis,
|
||||
"output_tail": output_tail,
|
||||
"exit_code": exit_code,
|
||||
"cmd": _payload.get("_cmd") or "",
|
||||
"tps": phase_info.get("tps"),
|
||||
"reqs": phase_info.get("reqs"),
|
||||
|
||||
+54
-14
@@ -304,6 +304,7 @@ OWNER_SCOPED_EMAIL_CACHE_TABLES = {
|
||||
"email_ai_replies",
|
||||
"email_calendar_extractions",
|
||||
"email_urgency_alerts",
|
||||
"sender_signatures",
|
||||
}
|
||||
|
||||
|
||||
@@ -341,6 +342,55 @@ def _ensure_owner_scoped_email_cache_table(conn, table: str, create_sql: str, co
|
||||
_lg.getLogger(__name__).warning(f"{table} owner-migration skipped: {_mig_e}")
|
||||
|
||||
|
||||
def _ensure_sender_signatures_table(conn):
|
||||
"""Create/migrate learned sender signatures to an owner-scoped cache."""
|
||||
create_sql = """
|
||||
CREATE TABLE IF NOT EXISTS sender_signatures (
|
||||
from_address TEXT,
|
||||
owner TEXT DEFAULT '',
|
||||
signature_text TEXT,
|
||||
sample_count INTEGER,
|
||||
last_built_at TEXT NOT NULL,
|
||||
model_used TEXT,
|
||||
source TEXT,
|
||||
PRIMARY KEY (from_address, owner)
|
||||
)
|
||||
"""
|
||||
conn.execute(create_sql)
|
||||
try:
|
||||
info = conn.execute("PRAGMA table_info(sender_signatures)").fetchall()
|
||||
cols = [r[1] for r in info]
|
||||
pk_cols = [r[1] for r in sorted((r for r in info if r[5]), key=lambda r: r[5])]
|
||||
if "owner" in cols and pk_cols == ["from_address", "owner"]:
|
||||
return
|
||||
|
||||
conn.execute("ALTER TABLE sender_signatures RENAME TO sender_signatures__old")
|
||||
conn.execute(create_sql)
|
||||
old_cols = [r[1] for r in conn.execute("PRAGMA table_info(sender_signatures__old)").fetchall()]
|
||||
copy_cols = [
|
||||
c for c in (
|
||||
"from_address",
|
||||
"signature_text",
|
||||
"sample_count",
|
||||
"last_built_at",
|
||||
"model_used",
|
||||
"source",
|
||||
)
|
||||
if c in old_cols
|
||||
]
|
||||
source_owner = "COALESCE(owner, '')" if "owner" in old_cols else "''"
|
||||
conn.execute(
|
||||
f"INSERT OR IGNORE INTO sender_signatures "
|
||||
f"({', '.join([*copy_cols, 'owner'])}) "
|
||||
f"SELECT {', '.join([*copy_cols, source_owner])} "
|
||||
f"FROM sender_signatures__old"
|
||||
)
|
||||
conn.execute("DROP TABLE sender_signatures__old")
|
||||
except Exception as _mig_e:
|
||||
import logging as _lg
|
||||
_lg.getLogger(__name__).warning(f"sender_signatures owner-migration skipped: {_mig_e}")
|
||||
|
||||
|
||||
def attachment_extract_dir(folder: str, uid: str) -> Path:
|
||||
"""Containment-safe extraction directory for an attachment.
|
||||
|
||||
@@ -559,20 +609,10 @@ def _init_scheduled_db():
|
||||
conn.execute("ALTER TABLE email_boundaries ADD COLUMN turns_json TEXT")
|
||||
except Exception:
|
||||
pass
|
||||
# Per-sender signature cache. Populated by `learn_sender_signatures`
|
||||
# action: the LLM extracts the common trailing block across N emails
|
||||
# from each sender; the renderer folds it consistently for every
|
||||
# future email from that address.
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sender_signatures (
|
||||
from_address TEXT PRIMARY KEY,
|
||||
signature_text TEXT,
|
||||
sample_count INTEGER,
|
||||
last_built_at TEXT NOT NULL,
|
||||
model_used TEXT,
|
||||
source TEXT
|
||||
)
|
||||
""")
|
||||
# Per-sender signature cache. Populated by `learn_sender_signatures`.
|
||||
# Message sender addresses are global, so signatures must be scoped to the
|
||||
# mailbox owner before `/read` returns them to the renderer.
|
||||
_ensure_sender_signatures_table(conn)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
+52
-24
@@ -249,6 +249,41 @@ def _uid_from_fetch_meta(meta_b: bytes) -> str:
|
||||
return m.group(1).decode() if m else ""
|
||||
|
||||
|
||||
_FETCH_SEQ_RE = re.compile(rb"^(\d+)\s+\(")
|
||||
|
||||
|
||||
def _group_uid_fetch_records(msg_data) -> list:
|
||||
"""Group an imaplib UID FETCH response into per-message (meta, payload).
|
||||
|
||||
imaplib yields an interleaved list: ``(meta, literal)`` tuples for
|
||||
attributes that carry a literal (``RFC822.HEADER {n}`` etc.) plus bare
|
||||
``bytes`` elements for everything the server sends outside a literal.
|
||||
Where each attribute lands is server-specific: Dovecot sends FLAGS
|
||||
*before* the header literal (so it ends up inside the tuple meta), while
|
||||
Gmail sends FLAGS *after* it, arriving as a bare ``b' FLAGS (\\Seen))'``
|
||||
element. Dropping bare elements therefore silently loses FLAGS on Gmail
|
||||
and every message renders as unread/unflagged.
|
||||
|
||||
A tuple whose meta starts with a sequence number opens a new record;
|
||||
every other part — continuation tuple or bare bytes — is folded into the
|
||||
current record's meta so attribute regexes see the full meta text.
|
||||
Plain ``b')'`` terminators get folded in too, which is harmless.
|
||||
"""
|
||||
grouped: list = [] # list of (meta_bytes, payload_bytes_or_None)
|
||||
for part in (msg_data or []):
|
||||
if isinstance(part, tuple):
|
||||
meta_b = part[0] if isinstance(part[0], (bytes, bytearray)) else str(part[0]).encode()
|
||||
if _FETCH_SEQ_RE.match(meta_b):
|
||||
grouped.append((meta_b, part[1]))
|
||||
elif grouped:
|
||||
cur_meta, cur_payload = grouped[-1]
|
||||
grouped[-1] = (cur_meta + b" " + meta_b, cur_payload or part[1])
|
||||
elif isinstance(part, (bytes, bytearray)) and grouped:
|
||||
cur_meta, cur_payload = grouped[-1]
|
||||
grouped[-1] = (cur_meta + b" " + bytes(part), cur_payload)
|
||||
return grouped
|
||||
|
||||
|
||||
def _smtp_ready(cfg: dict) -> bool:
|
||||
return bool(cfg.get("smtp_host") and cfg.get("smtp_user") and cfg.get("smtp_password"))
|
||||
|
||||
@@ -799,20 +834,11 @@ def setup_email_routes():
|
||||
except Exception as e:
|
||||
logger.warning(f"Batch fetch failed, falling back to per-UID: {e}")
|
||||
status, msg_data = "NO", []
|
||||
# imaplib batch responses interleave (meta, payload) tuples and
|
||||
# `b')'` terminators. Group by message: each tuple where the
|
||||
# meta begins with a seq number starts a new message record.
|
||||
seq_re = re.compile(rb'^(\d+)\s+\(')
|
||||
grouped = [] # list of (meta_str, payload_bytes)
|
||||
for part in (msg_data or []):
|
||||
if isinstance(part, tuple):
|
||||
meta_b = part[0] if isinstance(part[0], (bytes, bytearray)) else str(part[0]).encode()
|
||||
if seq_re.match(meta_b):
|
||||
grouped.append((meta_b, part[1]))
|
||||
elif grouped:
|
||||
# continuation of previous message — concatenate meta info if any
|
||||
cur_meta, cur_payload = grouped[-1]
|
||||
grouped[-1] = (cur_meta + b" " + meta_b, cur_payload or part[1])
|
||||
# Group the batched response into per-message (meta, payload)
|
||||
# records. Bare bytes parts must be kept: Gmail returns FLAGS
|
||||
# after the header literal as a bare element, and dropping it
|
||||
# rendered every Gmail message as unread/unflagged.
|
||||
grouped = _group_uid_fetch_records(msg_data)
|
||||
|
||||
if status != "OK" and not grouped:
|
||||
conn.logout()
|
||||
@@ -1098,14 +1124,15 @@ def setup_email_routes():
|
||||
continue
|
||||
raw_header = None
|
||||
flags = ""
|
||||
for part in msg_data:
|
||||
if isinstance(part, tuple):
|
||||
meta = part[0].decode() if isinstance(part[0], bytes) else str(part[0])
|
||||
if b"RFC822.HEADER" in part[0] if isinstance(part[0], bytes) else "RFC822.HEADER" in meta:
|
||||
raw_header = part[1]
|
||||
flag_match = re.search(r'FLAGS \(([^)]*)\)', meta)
|
||||
if flag_match:
|
||||
flags = flag_match.group(1)
|
||||
# Same Gmail caveat as the list route: FLAGS may
|
||||
# arrive after the header literal, so group bare
|
||||
# parts back into the message meta before scanning.
|
||||
for meta_b, payload in _group_uid_fetch_records(msg_data):
|
||||
if payload and b"RFC822.HEADER" in meta_b:
|
||||
raw_header = payload
|
||||
flag_match = re.search(rb'FLAGS \(([^)]*)\)', meta_b)
|
||||
if flag_match:
|
||||
flags = flag_match.group(1).decode(errors="replace")
|
||||
if not raw_header:
|
||||
continue
|
||||
msg = email_mod.message_from_bytes(raw_header)
|
||||
@@ -1247,8 +1274,9 @@ def setup_email_routes():
|
||||
try:
|
||||
if sender_addr:
|
||||
_rs = _c.execute(
|
||||
"SELECT signature_text FROM sender_signatures WHERE from_address = ?",
|
||||
(sender_addr.lower().strip(),),
|
||||
f"SELECT signature_text FROM sender_signatures "
|
||||
f"WHERE from_address = ? AND {owner_clause}",
|
||||
(sender_addr.lower().strip(), *owner_params),
|
||||
).fetchone()
|
||||
if _rs and _rs[0]:
|
||||
cached_sender_sig = _rs[0]
|
||||
|
||||
+23
-3
@@ -1,7 +1,9 @@
|
||||
import re
|
||||
from copy import deepcopy
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from routes._validators import validate_remote_host, validate_ssh_port
|
||||
|
||||
|
||||
# Backends the manual hardware simulator accepts. Must stay a subset of what
|
||||
@@ -11,6 +13,14 @@ from fastapi import APIRouter
|
||||
_MANUAL_BACKENDS = {"cuda", "rocm", "metal", "cpu_x86", "cpu_arm"}
|
||||
|
||||
|
||||
def _validate_detection_target(host: str = "", ssh_port: str = "") -> tuple[str, str]:
|
||||
host_value = validate_remote_host(host) or ""
|
||||
port_value = validate_ssh_port(ssh_port) or ""
|
||||
if port_value and not host_value:
|
||||
raise HTTPException(400, "ssh_port requires host")
|
||||
return host_value, port_value
|
||||
|
||||
|
||||
def _apply_manual_hardware(system, manual_mode="", manual_gpu_count="", manual_vram_gb="", manual_ram_gb="", manual_backend=""):
|
||||
"""Manual hardware is a "what if I had this setup" simulator —
|
||||
REPLACES the detected hardware entirely instead of adding to it.
|
||||
@@ -105,6 +115,7 @@ def setup_hwfit_routes():
|
||||
"""Detect and return current system hardware info. Pass host=user@server for remote.
|
||||
fresh=true bypasses the per-host cache (the Rescan button)."""
|
||||
from services.hwfit.hardware import detect_system
|
||||
host, ssh_port = _validate_detection_target(host, ssh_port)
|
||||
return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)
|
||||
|
||||
@router.get("/models")
|
||||
@@ -118,6 +129,7 @@ def setup_hwfit_routes():
|
||||
from services.hwfit.hardware import detect_system
|
||||
from services.hwfit.fit import rank_models
|
||||
from services.hwfit.models import get_models, model_catalog_path
|
||||
host, ssh_port = _validate_detection_target(host, ssh_port)
|
||||
system = deepcopy(detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh))
|
||||
if system.get("error"):
|
||||
return {"system": system, "models": [], "error": system["error"]}
|
||||
@@ -165,8 +177,14 @@ def setup_hwfit_routes():
|
||||
system["gpu_name"] = g["name"]
|
||||
system["active_group"] = {**g, "use_count": n}
|
||||
|
||||
if gpu_count != "":
|
||||
n = int(gpu_count)
|
||||
# Parse the optional count defensively (matches the gpu_group guard
|
||||
# above): a non-numeric query param previously raised ValueError ->
|
||||
# HTTP 500. A malformed value is ignored, same as omitting it.
|
||||
try:
|
||||
n = int(gpu_count) if gpu_count != "" else None
|
||||
except ValueError:
|
||||
n = None
|
||||
if n is not None:
|
||||
if n == 0:
|
||||
# RAM-only mode: rank against system memory, offload allowed.
|
||||
system["has_gpu"] = False
|
||||
@@ -229,6 +247,7 @@ def setup_hwfit_routes():
|
||||
from services.hwfit.hardware import detect_system
|
||||
from services.hwfit.models import get_models
|
||||
from services.hwfit.profiles import compute_serve_profiles
|
||||
host, ssh_port = _validate_detection_target(host, ssh_port)
|
||||
system = detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)
|
||||
if system.get("error"):
|
||||
return {"system": system, "profiles": [], "error": system["error"]}
|
||||
@@ -279,6 +298,7 @@ def setup_hwfit_routes():
|
||||
"""Rank image generation models against detected hardware."""
|
||||
from services.hwfit.hardware import detect_system
|
||||
from services.hwfit.image_models import rank_image_models
|
||||
host, ssh_port = _validate_detection_target(host, ssh_port)
|
||||
system = deepcopy(detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh))
|
||||
if system.get("error"):
|
||||
return {"system": system, "models": [], "error": system["error"]}
|
||||
|
||||
+18
-2
@@ -105,6 +105,13 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
if memory_manager.find_duplicates(text, user_mem):
|
||||
return {"ok": True, "count": len(user_mem), "message": "Memory already exists"}
|
||||
|
||||
if memory_data.session_id:
|
||||
try:
|
||||
session_obj = session_manager.get_session(memory_data.session_id)
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
_assert_session_owner(session_obj, user)
|
||||
|
||||
new_entry = memory_manager.add_entry(text, memory_data.source, memory_data.category, owner=user)
|
||||
if memory_data.session_id:
|
||||
new_entry["session_id"] = memory_data.session_id
|
||||
@@ -163,8 +170,17 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
|
||||
session_id = memory.get("session_id")
|
||||
if session_id and session_id in session_manager.sessions:
|
||||
session = session_manager.get_session(session_id)
|
||||
memory["session_name"] = session.name if session else f"Session {session_id[:6]}"
|
||||
try:
|
||||
session = session_manager.get_session(session_id)
|
||||
if session:
|
||||
_assert_session_owner(session, user)
|
||||
memory["session_name"] = session.name if session else f"Session {session_id[:6]}"
|
||||
except KeyError:
|
||||
memory["session_name"] = "Unknown"
|
||||
except HTTPException as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
memory["session_name"] = "Unknown"
|
||||
else:
|
||||
memory["session_name"] = "Unknown"
|
||||
|
||||
|
||||
+27
-5
@@ -123,6 +123,21 @@ def _clear_user_pref_endpoint_refs(all_prefs: dict, ep_id: str) -> int:
|
||||
return cleared_users
|
||||
|
||||
|
||||
def _default_endpoint_needs_assignment(current_default_id: str, enabled_endpoint_ids) -> bool:
|
||||
"""Whether the global default chat endpoint should be (re)assigned.
|
||||
|
||||
True when nothing is configured yet, or the configured default no longer
|
||||
resolves to an enabled endpoint (e.g. the user disabled it). Without the
|
||||
second case, adding a new endpoint after disabling the previous default
|
||||
leaves `default_endpoint_id` pointing at the disabled endpoint, so features
|
||||
that read the raw setting (Memory → Tidy) fail with "No default model
|
||||
configured" even though an enabled endpoint exists. See #3586.
|
||||
"""
|
||||
if not current_default_id:
|
||||
return True
|
||||
return current_default_id not in enabled_endpoint_ids
|
||||
|
||||
|
||||
# Loopback hosts a user might type for a local model server (LM Studio,
|
||||
# llama.cpp, vLLM, …). Inside Docker these point at the *container*, not the
|
||||
# host the server actually runs on.
|
||||
@@ -1727,12 +1742,19 @@ def setup_model_routes(model_discovery):
|
||||
)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
# Auto-set as default chat endpoint if none configured yet. Seed
|
||||
# the first CHAT model (not raw model_ids[0]) so we don't pin the
|
||||
# global default to an embedding/tts/etc. entry a provider happens
|
||||
# to list first.
|
||||
# Auto-set as default chat endpoint when none is usable yet — either
|
||||
# nothing is configured, or the configured default points at an
|
||||
# endpoint that is now missing/disabled (#3586). Seed the first CHAT
|
||||
# model (not raw model_ids[0]) so we don't pin the global default to
|
||||
# an embedding/tts/etc. entry a provider happens to list first.
|
||||
settings = _load_settings()
|
||||
if not settings.get("default_endpoint_id"):
|
||||
enabled_ids = {
|
||||
e.id
|
||||
for e in db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.is_enabled == True # noqa: E712
|
||||
).all()
|
||||
}
|
||||
if _default_endpoint_needs_assignment(settings.get("default_endpoint_id") or "", enabled_ids):
|
||||
from src.endpoint_resolver import _first_chat_model
|
||||
settings["default_endpoint_id"] = ep.id
|
||||
settings["default_model"] = _first_chat_model(model_ids) or ""
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Workspace API - browse server directories to pick a tool workspace folder."""
|
||||
import os
|
||||
from fastapi import APIRouter, Request, HTTPException, Query
|
||||
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.tool_security import owner_is_admin_or_single_user
|
||||
|
||||
# Cap entries returned per directory (mirrors filesystem_tools._CODENAV_MAX_HITS).
|
||||
# A huge directory shouldn't dump thousands of rows into the picker; the user can
|
||||
# type/paste a path to jump straight in instead.
|
||||
_MAX_BROWSE_DIRS = 500
|
||||
|
||||
|
||||
def setup_workspace_routes():
|
||||
router = APIRouter(prefix="/api/workspace", tags=["workspace"])
|
||||
|
||||
@router.get("/browse")
|
||||
def browse(request: Request, path: str = Query(default="")):
|
||||
"""List subdirectories of `path` (default: home) so the UI can navigate
|
||||
the server filesystem and pick a workspace folder. Directories only.
|
||||
|
||||
ADMIN-ONLY: this enumerates the server filesystem, so it is gated the
|
||||
same way the file/shell tools are (read_file/write_file/bash are in
|
||||
NON_ADMIN_BLOCKED_TOOLS). A non-admin who can't use those tools must not
|
||||
be able to map the host's directory tree either.
|
||||
"""
|
||||
owner = get_current_user(request)
|
||||
if not owner_is_admin_or_single_user(owner):
|
||||
raise HTTPException(status_code=403, detail="Workspace browsing is admin-only")
|
||||
|
||||
# Resolve symlinks so the reported path is canonical and the UI navigates
|
||||
# real directories (defends against symlink games in displayed paths).
|
||||
target = os.path.realpath(os.path.expanduser(path.strip() or "~"))
|
||||
if not os.path.isdir(target):
|
||||
target = os.path.realpath(os.path.expanduser("~"))
|
||||
|
||||
dirs = []
|
||||
try:
|
||||
with os.scandir(target) as it:
|
||||
for entry in it:
|
||||
try:
|
||||
# Don't follow symlinks when classifying - a symlinked
|
||||
# dir is skipped rather than letting the browser wander
|
||||
# off via a link. Hidden entries are omitted.
|
||||
if entry.is_dir(follow_symlinks=False) and not entry.name.startswith("."):
|
||||
# Build the child path server-side with os.path.join
|
||||
# so it's correct on Windows (backslashes) and Linux.
|
||||
dirs.append({"name": entry.name, "path": os.path.join(target, entry.name)})
|
||||
except OSError:
|
||||
continue
|
||||
except (PermissionError, OSError):
|
||||
dirs = []
|
||||
|
||||
dirs_sorted = sorted(dirs, key=lambda d: d["name"].lower())
|
||||
truncated = len(dirs_sorted) > _MAX_BROWSE_DIRS
|
||||
parent = os.path.dirname(target)
|
||||
from src.tool_execution import vet_workspace
|
||||
return {
|
||||
"path": target,
|
||||
"parent": parent if parent and parent != target else None,
|
||||
"dirs": dirs_sorted[:_MAX_BROWSE_DIRS],
|
||||
"truncated": truncated,
|
||||
# Whether this directory may be bound as a workspace (filesystem
|
||||
# roots and sensitive dirs may be browsed through but not chosen).
|
||||
"selectable": vet_workspace(target) is not None,
|
||||
}
|
||||
|
||||
@router.get("/vet")
|
||||
def vet(request: Request, path: str = Query(default="")):
|
||||
"""Validate a workspace path without binding it.
|
||||
|
||||
The UI calls this before persisting a manually typed path (/workspace
|
||||
set) so a typo, file path, deleted folder, sensitive dir, or filesystem
|
||||
root is rejected up front with the canonical path returned on success,
|
||||
instead of being stored client-side and silently dropped at chat time.
|
||||
Admin-gated like /browse: it confirms path existence on the host.
|
||||
"""
|
||||
owner = get_current_user(request)
|
||||
if not owner_is_admin_or_single_user(owner):
|
||||
raise HTTPException(status_code=403, detail="Workspace selection is admin-only")
|
||||
from src.tool_execution import vet_workspace
|
||||
resolved = vet_workspace(path)
|
||||
return {"ok": resolved is not None, "path": resolved}
|
||||
|
||||
return router
|
||||
@@ -299,6 +299,40 @@ def fetch_webpage_content(url: str, timeout: int = 5, retry_attempt: int = 0) ->
|
||||
_cache_result(cache_file, cache_key, result, url)
|
||||
return result
|
||||
|
||||
# Plain-text / Markdown / JSON handling. Sources like
|
||||
# raw.githubusercontent.com serve Markdown as `text/plain`, JSON APIs and
|
||||
# raw config files serve `application/json`, and a lot of code and tool
|
||||
# docs live in `.md` / `.txt`. These have no HTML structure, so the HTML
|
||||
# branch below would extract nothing and report "no readable text content".
|
||||
# Return the body verbatim instead. The `is_html` guard keeps real HTML
|
||||
# (including `application/xhtml+xml`) on the parsing path; the `json` check
|
||||
# covers `application/json` and `+json` suffixes; the URL-suffix fallback
|
||||
# catches servers that mislabel text files as `application/octet-stream`.
|
||||
is_html = "html" in content_type
|
||||
is_json = "json" in content_type
|
||||
url_path = url.lower().split("?", 1)[0].split("#", 1)[0]
|
||||
looks_like_text_file = url_path.endswith(
|
||||
(".md", ".markdown", ".txt", ".text", ".json", ".jsonl")
|
||||
)
|
||||
if not is_html and (content_type.startswith("text/") or is_json or looks_like_text_file):
|
||||
text_body = (response.text or "").strip()
|
||||
result = {
|
||||
"url": url,
|
||||
"title": os.path.basename(url_path) or url,
|
||||
"content": text_body,
|
||||
"lists": [],
|
||||
"tables": [],
|
||||
"code_blocks": [],
|
||||
"meta_description": "",
|
||||
"meta_keywords": "",
|
||||
"js_rendered": False,
|
||||
"js_message": "",
|
||||
"success": bool(text_body),
|
||||
"error": "" if text_body else "Empty response body",
|
||||
}
|
||||
_cache_result(cache_file, cache_key, result, url)
|
||||
return result
|
||||
|
||||
# HTML handling
|
||||
try:
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
+29
-9
@@ -21,7 +21,7 @@ from src.settings import get_setting
|
||||
from src.prompt_security import untrusted_context_message
|
||||
from src.tool_security import blocked_tools_for_owner, plan_mode_disabled_tools
|
||||
from src.tool_policy import GUIDE_ONLY_DIRECTIVE, ToolPolicy
|
||||
from src.tool_utils import get_mcp_manager
|
||||
from src.tool_utils import _truncate, get_mcp_manager
|
||||
from src.agent_tools import (
|
||||
parse_tool_blocks,
|
||||
strip_tool_blocks,
|
||||
@@ -272,7 +272,7 @@ _DOMAIN_TOOL_MAP = {
|
||||
"notes_calendar_tasks": {"manage_notes", "manage_calendar", "manage_tasks"},
|
||||
"ui": {"ui_control"},
|
||||
"sessions": {"create_session", "list_sessions", "manage_session", "send_to_session", "search_chats"},
|
||||
"files": {"bash", "python", "read_file", "write_file", "edit_file", "grep", "glob", "ls"},
|
||||
"files": {"bash", "python", "read_file", "write_file", "edit_file", "grep", "glob", "ls", "get_workspace"},
|
||||
"settings": {"manage_settings", "manage_endpoints", "manage_mcp", "manage_webhooks", "manage_tokens", "app_api"},
|
||||
}
|
||||
|
||||
@@ -309,6 +309,7 @@ NEVER pipe multi-line Python through `python -c "..."` — shell quoting eats re
|
||||
<python code>
|
||||
```
|
||||
Execute Python code. Use for computation, data processing, scripting. NOT for writing code for the user (use create_document for that). Same sandbox limits as bash — no TTY, no GUI, no `input()`; for anything the user should interact with, generate a single HTML file with inline JS instead.
|
||||
Prefer a dedicated tool whenever one fits the job (reading, searching, or writing files); use python only for computation/processing no dedicated tool covers - not for reading or writing files.
|
||||
Do NOT use Python/requests for web lookup/search/latest/current requests when `web_search` or `web_fetch` is available.""",
|
||||
|
||||
"web_search": """\
|
||||
@@ -347,6 +348,11 @@ Write content to a file. First line is the path, rest is the content.""",
|
||||
```
|
||||
Edit an EXISTING file by exact string replacement. PREFER this over bash (sed/echo/redirects) for changing files — it shows a before/after diff. `old_string` must match the file exactly and be unique unless `replace_all` is true. Use write_file to create a new file.""",
|
||||
|
||||
"get_workspace": """\
|
||||
```get_workspace
|
||||
```
|
||||
Return the absolute path of the active workspace folder. File tools are CONFINED to it (paths can be RELATIVE to it); the shell starts there (cwd) but is NOT sandboxed. Call this first when the user says "the project"/"the code"/"this folder" without a path, instead of asking them. No arguments.""",
|
||||
|
||||
"create_document": """\
|
||||
```create_document
|
||||
<title>
|
||||
@@ -1726,6 +1732,7 @@ async def stream_agent_loop(
|
||||
plan_mode: bool = False,
|
||||
approved_plan: Optional[str] = None,
|
||||
tool_policy: Optional[ToolPolicy] = None,
|
||||
workspace: Optional[str] = None,
|
||||
_is_teacher_run: bool = False,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Streaming agent loop generator.
|
||||
@@ -1795,7 +1802,17 @@ async def stream_agent_loop(
|
||||
if not guide_only and not _relevant_tools and bool(_intent.get("low_signal")):
|
||||
from src.tool_index import ALWAYS_AVAILABLE
|
||||
_relevant_tools = set(ALWAYS_AVAILABLE)
|
||||
logger.info("[tool-rag] Low-signal agent message; skipping retrieval and using always-available tools only")
|
||||
if workspace:
|
||||
# An active workspace IS the file-work signal: a vague "look at the
|
||||
# project" means explore this folder. Surface only the READ-ONLY file
|
||||
# tools (intersection with the plan-mode read-only allowlist) so the
|
||||
# agent can investigate; write/shell tools stay out until the request
|
||||
# actually calls for them (RAG retrieval adds those on a real ask).
|
||||
from src.tool_security import PLAN_MODE_READONLY_TOOLS
|
||||
_relevant_tools |= (_DOMAIN_TOOL_MAP["files"] & PLAN_MODE_READONLY_TOOLS)
|
||||
logger.info("[tool-rag] Low-signal but workspace active; including read-only file tools")
|
||||
else:
|
||||
logger.info("[tool-rag] Low-signal agent message; skipping retrieval and using always-available tools only")
|
||||
if not guide_only and not _relevant_tools:
|
||||
try:
|
||||
from src.tool_index import get_tool_index, ALWAYS_AVAILABLE
|
||||
@@ -2644,6 +2661,7 @@ async def stream_agent_loop(
|
||||
tool_policy=tool_policy,
|
||||
owner=owner,
|
||||
progress_cb=_push_progress,
|
||||
workspace=workspace,
|
||||
)
|
||||
finally:
|
||||
# Sentinel so the drainer knows to stop.
|
||||
@@ -2751,18 +2769,20 @@ async def stream_agent_loop(
|
||||
# On a bash/python timeout the result carries error + (often
|
||||
# empty) stdout/stderr; fall back to the error so the "timed
|
||||
# out" reason reaches the UI instead of a blank result.
|
||||
output_text = (result["stdout"] or result["stderr"] or result.get("error", ""))[:2000]
|
||||
raw = result["stdout"] or result["stderr"] or result.get("error", "")
|
||||
output_text = _truncate(raw)
|
||||
elif "output" in result:
|
||||
# bash / python canonical result: {"output": ..., "exit_code": ...}
|
||||
output_text = (result["output"] or "")[:2000]
|
||||
raw = result["output"] or ""
|
||||
output_text = _truncate(raw)
|
||||
elif "response" in result:
|
||||
# AI interaction tools (chat_with_model, send_to_session)
|
||||
label = result.get("model", result.get("session_name", "AI"))
|
||||
output_text = f"{label}: {result['response']}"[:4000]
|
||||
output_text = _truncate(f"{label}: {result['response']}")
|
||||
elif "content" in result:
|
||||
output_text = result["content"][:2000]
|
||||
output_text = _truncate(result["content"])
|
||||
elif "results" in result:
|
||||
output_text = result["results"][:4000]
|
||||
output_text = _truncate(result["results"])
|
||||
elif "session_id" in result and "name" in result:
|
||||
output_text = f"Session created: {result['name']} (id: {result['session_id']})"
|
||||
elif "success" in result:
|
||||
@@ -2772,7 +2792,7 @@ async def stream_agent_loop(
|
||||
else f"Error: {result.get('error', '')}"
|
||||
)
|
||||
elif "error" in result:
|
||||
output_text = result["error"][:2000]
|
||||
output_text = _truncate(result["error"])
|
||||
|
||||
# Emit tool_output (include ui_event data if present)
|
||||
tool_output_data = {"type": "tool_output", "tool": block.tool_type, "command": cmd_display, "output": output_text, "exit_code": result.get("exit_code")}
|
||||
|
||||
@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
from .subprocess_tools import BashTool, PythonTool
|
||||
from .web_tools import WebSearchTool, WebFetchTool
|
||||
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, LsTool, GlobTool, GrepTool
|
||||
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
|
||||
from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocumentTool, SuggestDocumentTool, ManageDocumentTool
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
@@ -39,6 +39,7 @@ TOOL_HANDLERS = {
|
||||
"edit_document": EditDocumentTool().execute,
|
||||
"suggest_document": SuggestDocumentTool().execute,
|
||||
"manage_documents": ManageDocumentTool().execute,
|
||||
"get_workspace": GetWorkspaceTool().execute,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -51,7 +52,7 @@ PYTHON_TIMEOUT = 30
|
||||
|
||||
# Tool types that trigger execution
|
||||
TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_file", "edit_file",
|
||||
"grep", "glob", "ls",
|
||||
"grep", "glob", "ls", "get_workspace",
|
||||
"create_document", "update_document", "edit_document",
|
||||
"search_chats",
|
||||
"chat_with_model", "create_session", "list_sessions",
|
||||
|
||||
@@ -46,13 +46,7 @@ def _unified_diff(old: str, new: str, path: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
class EditFileTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import (
|
||||
_resolve_tool_path,
|
||||
_resolve_tool_path_in_workspace,
|
||||
_resolve_search_root,
|
||||
_truncate
|
||||
)
|
||||
workspace = ctx.get("workspace")
|
||||
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
|
||||
try:
|
||||
args = json.loads(content) if content.strip().startswith("{") else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
@@ -64,8 +58,7 @@ class EditFileTool:
|
||||
if not raw_path:
|
||||
return {"error": "edit_file: path required", "exit_code": 1}
|
||||
try:
|
||||
path = (_resolve_tool_path_in_workspace(workspace, raw_path)
|
||||
if workspace else _resolve_tool_path(raw_path))
|
||||
path = _resolve_tool_path(raw_path)
|
||||
except ValueError as e:
|
||||
return {"error": f"edit_file: {e}", "exit_code": 1}
|
||||
if old == "":
|
||||
@@ -113,13 +106,7 @@ class EditFileTool:
|
||||
|
||||
class ReadFileTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import (
|
||||
_resolve_tool_path,
|
||||
_resolve_tool_path_in_workspace,
|
||||
_resolve_search_root,
|
||||
_truncate
|
||||
)
|
||||
workspace = ctx.get("workspace")
|
||||
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
|
||||
raw_path, offset, limit = content.split("\n", 1)[0].strip(), 0, 0
|
||||
_stripped = content.strip()
|
||||
if _stripped.startswith("{"):
|
||||
@@ -131,8 +118,7 @@ class ReadFileTool:
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
path = (_resolve_tool_path_in_workspace(workspace, raw_path)
|
||||
if workspace else _resolve_tool_path(raw_path))
|
||||
path = _resolve_tool_path(raw_path)
|
||||
except ValueError as e:
|
||||
return {"error": f"read_file: {e}", "exit_code": 1}
|
||||
try:
|
||||
@@ -170,19 +156,12 @@ class ReadFileTool:
|
||||
|
||||
class WriteFileTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import (
|
||||
_resolve_tool_path,
|
||||
_resolve_tool_path_in_workspace,
|
||||
_resolve_search_root,
|
||||
_truncate
|
||||
)
|
||||
workspace = ctx.get("workspace")
|
||||
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
|
||||
lines = content.split("\n", 1)
|
||||
raw_path = lines[0].strip()
|
||||
body = lines[1] if len(lines) > 1 else ""
|
||||
try:
|
||||
path = (_resolve_tool_path_in_workspace(workspace, raw_path)
|
||||
if workspace else _resolve_tool_path(raw_path))
|
||||
path = _resolve_tool_path(raw_path)
|
||||
except ValueError as e:
|
||||
return {"error": f"write_file: {e}", "exit_code": 1}
|
||||
try:
|
||||
@@ -212,13 +191,7 @@ class WriteFileTool:
|
||||
|
||||
class LsTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import (
|
||||
_resolve_tool_path,
|
||||
_resolve_tool_path_in_workspace,
|
||||
_resolve_search_root,
|
||||
_truncate
|
||||
)
|
||||
workspace = ctx.get("workspace")
|
||||
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
|
||||
raw_path = ""
|
||||
_s = (content or "").strip()
|
||||
if _s.startswith("{"):
|
||||
@@ -267,13 +240,7 @@ class LsTool:
|
||||
|
||||
class GlobTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import (
|
||||
_resolve_tool_path,
|
||||
_resolve_tool_path_in_workspace,
|
||||
_resolve_search_root,
|
||||
_truncate
|
||||
)
|
||||
workspace = ctx.get("workspace")
|
||||
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
|
||||
args = {}
|
||||
_s = (content or "").strip()
|
||||
if _s.startswith("{"):
|
||||
@@ -325,13 +292,7 @@ class GlobTool:
|
||||
|
||||
class GrepTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import (
|
||||
_resolve_tool_path,
|
||||
_resolve_tool_path_in_workspace,
|
||||
_resolve_search_root,
|
||||
_truncate
|
||||
)
|
||||
workspace = ctx.get("workspace")
|
||||
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
|
||||
args: Dict[str, Any] = {}
|
||||
_s = (content or "").strip()
|
||||
if _s.startswith("{"):
|
||||
@@ -417,3 +378,21 @@ class GrepTool:
|
||||
if len(lines) >= max_hits:
|
||||
out += f"\n... [capped at {max_hits} matches]"
|
||||
return {"output": _truncate(out), "exit_code": 0}
|
||||
|
||||
class GetWorkspaceTool:
|
||||
"""Report the active workspace folder (no args). File tools are confined to
|
||||
it; the shell starts there (cwd) but is NOT sandboxed."""
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import get_active_workspace
|
||||
ws = get_active_workspace()
|
||||
if ws:
|
||||
return {
|
||||
"output": f"{ws}\n(File tools are confined to this folder; the shell starts "
|
||||
f"here but is not sandboxed and can reach outside it.)",
|
||||
"exit_code": 0,
|
||||
}
|
||||
return {
|
||||
"output": "No workspace is set. File tools use the default allowed roots; "
|
||||
"resolve paths from the user or use absolute paths.",
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
@@ -102,16 +102,15 @@ async def _run_subprocess_streaming(
|
||||
|
||||
class BashTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import _AGENT_WORKDIR, _truncate
|
||||
from src.tool_execution import agent_cwd, _truncate
|
||||
progress_cb = ctx.get("progress_cb")
|
||||
workspace = ctx.get("workspace")
|
||||
_subproc_env = ctx.get("subproc_env")
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
content,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_subproc_env,
|
||||
cwd=workspace or _AGENT_WORKDIR,
|
||||
cwd=agent_cwd(),
|
||||
)
|
||||
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
|
||||
proc,
|
||||
@@ -129,16 +128,15 @@ class BashTool:
|
||||
|
||||
class PythonTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import _AGENT_WORKDIR, _truncate
|
||||
from src.tool_execution import agent_cwd, _truncate
|
||||
progress_cb = ctx.get("progress_cb")
|
||||
workspace = ctx.get("workspace")
|
||||
_subproc_env = ctx.get("subproc_env")
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
(sys.executable or "python"), "-I", "-c", content,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_subproc_env,
|
||||
cwd=workspace or _AGENT_WORKDIR,
|
||||
cwd=agent_cwd(),
|
||||
)
|
||||
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
|
||||
proc,
|
||||
|
||||
@@ -57,7 +57,12 @@ class APIKeyManager:
|
||||
# Legacy/wrong shape (e.g. a list) — .items() would raise. Ignore it.
|
||||
logger.warning("API keys file has unexpected shape (%s); ignoring", type(encrypted_keys).__name__)
|
||||
return {}
|
||||
return encrypted_keys
|
||||
|
||||
return {
|
||||
str(provider): key
|
||||
for provider, key in encrypted_keys.items()
|
||||
if isinstance(key, str)
|
||||
}
|
||||
|
||||
def save(self, provider: str, api_key: str):
|
||||
"""Save encrypted API key to file.
|
||||
@@ -82,4 +87,3 @@ class APIKeyManager:
|
||||
except (InvalidToken, ValueError) as e:
|
||||
logger.warning("Failed to decrypt API key for %s: %s", provider, e)
|
||||
return decrypted
|
||||
|
||||
|
||||
+10
-7
@@ -809,14 +809,14 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
|
||||
import email as _email_mod
|
||||
import asyncio as _aio
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
from routes.email_helpers import _imap_connect, SCHEDULED_DB
|
||||
from routes.email_helpers import _email_cache_owner_clause, _imap_connect, SCHEDULED_DB
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.llm_core import llm_call_async
|
||||
|
||||
# 1. Pull recent UIDs + From headers cheaply (header-only fetch).
|
||||
def _pull_headers():
|
||||
results = []
|
||||
conn = _imap_connect(None)
|
||||
conn = _imap_connect(None, owner=owner)
|
||||
try:
|
||||
conn.select("INBOX", readonly=True)
|
||||
status, data = conn.search(None, "ALL")
|
||||
@@ -868,9 +868,11 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
|
||||
# 3. Eligibility: ≥3 emails AND (no cache OR cache > 30 days old).
|
||||
try:
|
||||
conn = _sql3.connect(SCHEDULED_DB)
|
||||
owner_clause, owner_params = _email_cache_owner_clause(owner)
|
||||
cached = {
|
||||
r[0]: r[1] for r in conn.execute(
|
||||
"SELECT from_address, last_built_at FROM sender_signatures"
|
||||
f"SELECT from_address, last_built_at FROM sender_signatures WHERE {owner_clause}",
|
||||
owner_params,
|
||||
).fetchall()
|
||||
}
|
||||
conn.close()
|
||||
@@ -901,7 +903,7 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
|
||||
|
||||
def _fetch_bodies(_msgs):
|
||||
bodies = []
|
||||
conn2 = _imap_connect(None)
|
||||
conn2 = _imap_connect(None, owner=owner)
|
||||
try:
|
||||
conn2.select("INBOX", readonly=True)
|
||||
for mm in _msgs:
|
||||
@@ -978,11 +980,12 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
|
||||
|
||||
try:
|
||||
conn = _sql3.connect(SCHEDULED_DB)
|
||||
owner_value = (owner or "").strip()
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sender_signatures "
|
||||
"(from_address, signature_text, sample_count, last_built_at, model_used, source) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(addr, cached_sig, len(bodies), _dt.utcnow().isoformat(), model, "llm"),
|
||||
"(from_address, owner, signature_text, sample_count, last_built_at, model_used, source) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(addr, owner_value, cached_sig, len(bodies), _dt.utcnow().isoformat(), model, "llm"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
+41
-7
@@ -457,15 +457,25 @@ def _detect_provider(url: str) -> str:
|
||||
|
||||
def _is_self_hosted_openai_compatible(url: str) -> bool:
|
||||
"""True for custom/local OpenAI-compatible servers (llama.cpp, LM Studio,
|
||||
vLLM, text-generation-webui, etc.) as opposed to api.openai.com itself.
|
||||
vLLM, text-generation-webui, etc.) as opposed to cloud APIs.
|
||||
|
||||
Used to gate llama.cpp-server-specific payload extras (``session_id``,
|
||||
``cache_prompt``) — sending unrecognized top-level fields to OpenAI's
|
||||
actual API returns a 400 ("Unrecognized request argument"), but
|
||||
self-hosted servers generally ignore unknown fields and many (notably
|
||||
llama.cpp's server) use them for KV-cache slot affinity (issue #2927).
|
||||
``cache_prompt``) used for KV-cache slot affinity (issue #2927). Strict
|
||||
cloud providers reject unrecognized top-level fields (api.openai.com
|
||||
returns 400, Mistral returns 422 "extra_forbidden", issue #3793), and any
|
||||
unknown OpenAI-compatible host used to be treated as self-hosted, so those
|
||||
fields leaked to every strict provider added as a custom endpoint.
|
||||
|
||||
A server only counts as self-hosted when it also resolves as local:
|
||||
loopback/private/tailscale host, or the endpoint explicitly configured
|
||||
with kind "local". A self-hosted server exposed via a public hostname
|
||||
loses the affinity hint unless its endpoint kind is set to "local" -
|
||||
a lost perf hint, versus a hard 4xx on every request the other way.
|
||||
"""
|
||||
return _detect_provider(url) == "openai" and not _host_match(url, "openai.com")
|
||||
if _detect_provider(url) != "openai" or _host_match(url, "openai.com"):
|
||||
return False
|
||||
from src.model_context import is_local_endpoint
|
||||
return is_local_endpoint(url)
|
||||
|
||||
|
||||
def _apply_local_cache_affinity(payload: Dict, url: str, session_id: Optional[str]) -> None:
|
||||
@@ -681,6 +691,27 @@ def _restricts_temperature(model: str) -> bool:
|
||||
m = model.lower()
|
||||
return any(m.startswith(p) or f"/{p}" in m for p in _FIXED_TEMPERATURE_MODELS)
|
||||
|
||||
# Anthropic removed the sampling parameters (temperature, top_p, top_k) starting
|
||||
# with Claude Opus 4.7. On Opus 4.7 and later, sending `temperature` at all —
|
||||
# even 0.0 — returns HTTP 400. Earlier Claude models (Opus 4.6 and below, every
|
||||
# Sonnet/Haiku) still accept temperature in [0.0, 1.0], so the omission must be
|
||||
# version-gated rather than applied to all `claude-*` models.
|
||||
def _anthropic_rejects_temperature(model: str) -> bool:
|
||||
"""Check if a native-Anthropic model rejects the temperature field (Opus 4.7+)."""
|
||||
if not isinstance(model, str) or not model:
|
||||
return False
|
||||
# `(?<![a-z])` anchors "opus" to a word boundary so a substring match like
|
||||
# `oct-opus`/`octopus-4-8` can't be read as Opus (it would otherwise strip
|
||||
# temperature). Cap the minor at 1-2 digits and forbid a trailing digit so a
|
||||
# dated id like `claude-opus-4-20250514` (Opus 4.0) parses as major-only (no
|
||||
# minor match, kept) instead of reading the date `20250514` as a giant minor
|
||||
# that would falsely test >= 4.7. Dated 4.7+ snapshots (`claude-opus-4-7-
|
||||
# 20260201`) keep their explicit minor and are still matched.
|
||||
match = re.search(r"(?<![a-z])opus[-_]?(\d+)[-_.](\d{1,2})(?!\d)", model.lower())
|
||||
if not match:
|
||||
return False
|
||||
return (int(match.group(1)), int(match.group(2))) >= (4, 7)
|
||||
|
||||
# Models that support structured thinking — may output </think> without opening tag
|
||||
_THINKING_MODEL_PATTERNS = ("qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "minimax", "m2-reap", "gemma")
|
||||
|
||||
@@ -784,8 +815,11 @@ def _build_anthropic_payload(model, messages, temperature, max_tokens, stream=Fa
|
||||
"model": model,
|
||||
"messages": chat_messages,
|
||||
"max_tokens": max_tokens if max_tokens and max_tokens > 0 else 4096,
|
||||
"temperature": temperature,
|
||||
}
|
||||
# Opus 4.7+ removed the sampling parameters — sending `temperature` (even 0.0)
|
||||
# returns HTTP 400. Omit it for those models; older Claude models still take it.
|
||||
if not _anthropic_rejects_temperature(model):
|
||||
payload["temperature"] = temperature
|
||||
if system_parts:
|
||||
system_text = "\n\n".join(system_parts)
|
||||
# Send `system` as a structured text block so we can attach a prompt-cache
|
||||
|
||||
+20
-6
@@ -5,6 +5,7 @@ Query and cache model context window sizes from OpenAI-compatible APIs.
|
||||
Provides token estimation for context usage tracking.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import sys
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
@@ -19,7 +20,20 @@ _LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.interna
|
||||
_PRIVATE_PREFIXES = ("10.", "172.16.", "172.17.", "172.18.", "172.19.",
|
||||
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
|
||||
"172.25.", "172.26.", "172.27.", "172.28.", "172.29.",
|
||||
"172.30.", "172.31.", "192.168.", "100.")
|
||||
"172.30.", "172.31.", "192.168.")
|
||||
|
||||
# Tailscale uses the CGNAT range 100.64.0.0/10, NOT all of 100.0.0.0/8.
|
||||
# A bare "100." prefix would classify public addresses (e.g. AWS ranges
|
||||
# under 100.x outside the CGNAT block) as local; routes/model_routes.py
|
||||
# already narrows this the same way for endpoint classification.
|
||||
_TAILSCALE_CGNAT = ipaddress.ip_network("100.64.0.0/10")
|
||||
|
||||
|
||||
def _in_tailscale_range(host: str) -> bool:
|
||||
try:
|
||||
return ipaddress.ip_address(host) in _TAILSCALE_CGNAT
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_base_for_compare(url: str) -> str:
|
||||
@@ -64,7 +78,7 @@ def _configured_endpoint_kind(url: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _is_local_endpoint(url: str) -> bool:
|
||||
def is_local_endpoint(url: str) -> bool:
|
||||
"""Check if URL points to a local/private/tailscale address."""
|
||||
kind = _configured_endpoint_kind(url)
|
||||
if kind in ("api", "proxy"):
|
||||
@@ -73,7 +87,7 @@ def _is_local_endpoint(url: str) -> bool:
|
||||
return True
|
||||
try:
|
||||
host = urlparse(url).hostname or ""
|
||||
return host in _LOCAL_HOSTS or host.startswith(_PRIVATE_PREFIXES)
|
||||
return host in _LOCAL_HOSTS or host.startswith(_PRIVATE_PREFIXES) or _in_tailscale_range(host)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -219,7 +233,7 @@ def get_context_length(endpoint_url: str, model: str) -> int:
|
||||
Falls back to DEFAULT_CONTEXT if unavailable.
|
||||
"""
|
||||
configured_kind = _configured_endpoint_kind(endpoint_url)
|
||||
is_local = _is_local_endpoint(endpoint_url)
|
||||
is_local = is_local_endpoint(endpoint_url)
|
||||
# Key on (endpoint_url, model): the same model id can be served by two
|
||||
# different remote endpoints with different real context windows (e.g. a
|
||||
# capped proxy vs. the full provider), so caching by model id alone would
|
||||
@@ -273,7 +287,7 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
|
||||
return DEFAULT_CONTEXT
|
||||
|
||||
# Try llama.cpp /slots endpoint first — reports actual serving context
|
||||
if _is_local_endpoint(endpoint_url):
|
||||
if is_local_endpoint(endpoint_url):
|
||||
try:
|
||||
base = endpoint_url.split("/v1")[0] if "/v1" in endpoint_url else endpoint_url.rsplit("/", 1)[0]
|
||||
r = httpx.get(f"{base}/slots", timeout=REQUEST_TIMEOUT)
|
||||
@@ -337,7 +351,7 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
|
||||
# For local/self-hosted endpoints, trust the API value (user set --max-model-len)
|
||||
# For cloud APIs, use the larger value (API can report low defaults)
|
||||
if api_ctx and known:
|
||||
_is_local = _is_local_endpoint(endpoint_url)
|
||||
_is_local = is_local_endpoint(endpoint_url)
|
||||
if _is_local and api_ctx < known:
|
||||
logger.info(f"Local endpoint reports {api_ctx} for {model} (known max: {known}) — using API value")
|
||||
return api_ctx
|
||||
|
||||
@@ -221,6 +221,22 @@ class ResearchHandler:
|
||||
# Task registry — background research with persistence
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def rename_owner(self, old_owner: str, new_owner: str) -> int:
|
||||
"""Move in-flight research tasks from one owner key to another."""
|
||||
old_key = str(old_owner or "").strip().lower()
|
||||
new_key = str(new_owner or "").strip().lower()
|
||||
if not old_key or not new_key:
|
||||
return 0
|
||||
|
||||
changed = 0
|
||||
for entry in list(self._active_tasks.values()):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if str(entry.get("owner", "")).strip().lower() == old_key:
|
||||
entry["owner"] = new_key
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
def start_research(
|
||||
self,
|
||||
session_id: str,
|
||||
|
||||
+23
-11
@@ -214,6 +214,24 @@ def _search_like(
|
||||
return _rows_to_results(db, shaped, query, context_messages)
|
||||
|
||||
|
||||
def _fetch_messages_by_id(db, message_ids):
|
||||
"""Fetch (message, session_name) for many message ids in a single query.
|
||||
|
||||
The FTS search returns a list of hit ids; fetching each row on its own was an
|
||||
N+1 query (one SELECT per hit). Batch them with one IN(...) query and return
|
||||
a lookup so the caller can reassemble results in hit (relevance) order.
|
||||
"""
|
||||
if not message_ids:
|
||||
return {}
|
||||
rows = (
|
||||
db.query(DBChatMessage, DBSession.name)
|
||||
.join(DBSession, DBChatMessage.session_id == DBSession.id)
|
||||
.filter(DBChatMessage.id.in_(message_ids))
|
||||
.all()
|
||||
)
|
||||
return {msg.id: (msg, session_name) for msg, session_name in rows}
|
||||
|
||||
|
||||
def _search_fts(
|
||||
db,
|
||||
query: str,
|
||||
@@ -267,19 +285,13 @@ def _search_fts(
|
||||
if not hits:
|
||||
return None
|
||||
|
||||
by_id = _fetch_messages_by_id(db, [hit[0] for hit in hits])
|
||||
rows = []
|
||||
for hit in hits:
|
||||
message_id = hit[0]
|
||||
snippet = hit[1] or ""
|
||||
row = (
|
||||
db.query(DBChatMessage, DBSession.name)
|
||||
.join(DBSession, DBChatMessage.session_id == DBSession.id)
|
||||
.filter(DBChatMessage.id == message_id)
|
||||
.first()
|
||||
)
|
||||
if row:
|
||||
msg, session_name = row
|
||||
rows.append((msg, session_name, snippet))
|
||||
found = by_id.get(hit[0])
|
||||
if found:
|
||||
msg, session_name = found
|
||||
rows.append((msg, session_name, hit[1] or ""))
|
||||
return _rows_to_results(db, rows, query, context_messages)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -283,7 +283,7 @@ def load_features() -> dict:
|
||||
if not isinstance(saved, dict):
|
||||
raise ValueError("features must be an object")
|
||||
merged = {**DEFAULT_FEATURES, **saved}
|
||||
except (FileNotFoundError, json.JSONDecodeError, ValueError):
|
||||
except (FileNotFoundError, PermissionError, json.JSONDecodeError, ValueError):
|
||||
merged = dict(DEFAULT_FEATURES)
|
||||
_features_cache = (now, merged)
|
||||
return merged
|
||||
|
||||
+11
-1
@@ -12,6 +12,8 @@ tunnel / reverse proxy. Scrubbing is deep (recurses nested dicts/lists) and keye
|
||||
on secret-shaped names.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_SECRET_KEY_PATTERNS = (
|
||||
"_api_key", "_apikey", "_password", "_passwd", "_pass", "_pwd",
|
||||
"_secret", "_client_secret", "_token", "_access_token", "_refresh_token",
|
||||
@@ -26,8 +28,16 @@ _SENSITIVE_KEY_EXACT = (
|
||||
)
|
||||
|
||||
|
||||
def _canonical_key_name(name: str) -> str:
|
||||
"""Normalize common JS-style key names so secret matching is style-agnostic."""
|
||||
n = (name or "").replace("-", "_")
|
||||
n = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", n)
|
||||
n = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", n)
|
||||
return n.lower()
|
||||
|
||||
|
||||
def is_secret_key(name: str) -> bool:
|
||||
n = (name or "").lower()
|
||||
n = _canonical_key_name(name)
|
||||
if n in _SECRET_KEY_ALLOW:
|
||||
return False
|
||||
if n in _SENSITIVE_KEY_EXACT:
|
||||
|
||||
+94
-7
@@ -9,6 +9,7 @@ Extracted from agent_tools.py.
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -146,7 +147,13 @@ def _resolve_tool_path(raw_path: str) -> str:
|
||||
|
||||
Returns the realpath on success. Raises ValueError on rejection.
|
||||
Symlinks are resolved before comparison.
|
||||
|
||||
When a workspace is active for this turn, paths are confined to it instead
|
||||
of the default allowlist (see _resolve_tool_path_in_workspace).
|
||||
"""
|
||||
ws = get_active_workspace()
|
||||
if ws:
|
||||
return _resolve_tool_path_in_workspace(ws, raw_path)
|
||||
if raw_path is None or not str(raw_path).strip():
|
||||
raise ValueError("path is required")
|
||||
expanded = os.path.expanduser(str(raw_path).strip())
|
||||
@@ -207,6 +214,55 @@ def _resolve_tool_path_in_workspace(workspace: str, raw_path: str) -> str:
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Active workspace (per-turn, context-local)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Set ONCE in execute_tool_block from the request's `workspace`. The path
|
||||
# resolvers (_resolve_tool_path / _resolve_search_root) and the subprocess cwd
|
||||
# helper (agent_cwd) read it from here, so confinement is enforced in a single
|
||||
# place: any tool that resolves paths through these helpers is confined
|
||||
# automatically and cannot accidentally bypass the workspace. contextvars are
|
||||
# task-local, so concurrent turns don't leak into each other.
|
||||
_active_workspace: contextvars.ContextVar = contextvars.ContextVar(
|
||||
"agent_active_workspace", default=None
|
||||
)
|
||||
|
||||
|
||||
def get_active_workspace() -> Optional[str]:
|
||||
"""The folder the agent is confined to this turn, or None."""
|
||||
return _active_workspace.get()
|
||||
|
||||
|
||||
def vet_workspace(raw: str) -> Optional[str]:
|
||||
"""Validate a requested workspace path at bind time.
|
||||
|
||||
Returns the canonical path, or None when it is unusable: not a real
|
||||
directory, or itself a sensitive path (.ssh, .gnupg, ...). The in-workspace
|
||||
resolver deny-lists sensitive paths *inside* the workspace, but the
|
||||
empty-path search root is the workspace itself, so the root has to be
|
||||
vetted before it is ever bound.
|
||||
"""
|
||||
raw = (raw or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
resolved = os.path.realpath(os.path.expanduser(raw))
|
||||
if not os.path.isdir(resolved) or _is_sensitive_path(resolved):
|
||||
return None
|
||||
# Reject filesystem roots: binding / (or a Windows drive/UNC root) as the
|
||||
# workspace would make every absolute path "inside" it, collapsing the
|
||||
# confinement into host-wide file access. A root is its own dirname, which
|
||||
# also covers C:\ and \\server\share without platform-specific lists.
|
||||
if os.path.dirname(resolved) == resolved:
|
||||
return None
|
||||
return resolved
|
||||
|
||||
|
||||
def agent_cwd() -> str:
|
||||
"""Working directory for agent subprocesses (bash/python/background jobs):
|
||||
the active workspace when set, else the persistent data dir."""
|
||||
return get_active_workspace() or _AGENT_WORKDIR
|
||||
|
||||
|
||||
def get_mcp_manager():
|
||||
from src import agent_tools
|
||||
return agent_tools.get_mcp_manager()
|
||||
@@ -217,10 +273,15 @@ def get_mcp_manager():
|
||||
def _resolve_search_root(raw_path: str) -> str:
|
||||
"""Resolve + confine a code-nav path (grep/glob/ls).
|
||||
|
||||
An empty path defaults to the agent's primary root (project data dir) and a
|
||||
supplied path is confined by the global allowlist + sensitive-file policy.
|
||||
With a workspace active, the workspace folder is the root and a supplied
|
||||
path is confined inside it. Otherwise an empty path defaults to the agent's
|
||||
primary root (project data dir) and a supplied path is confined by the
|
||||
global allowlist + sensitive-file policy.
|
||||
"""
|
||||
raw = (raw_path or "").strip()
|
||||
ws = get_active_workspace()
|
||||
if ws:
|
||||
return os.path.realpath(ws) if not raw else _resolve_tool_path_in_workspace(ws, raw)
|
||||
if not raw:
|
||||
roots = _tool_path_roots()
|
||||
return roots[0] if roots else os.path.realpath(".")
|
||||
@@ -392,7 +453,6 @@ async def _direct_fallback(
|
||||
tool: str,
|
||||
content: str,
|
||||
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
||||
workspace: Optional[str] = None,
|
||||
) -> Optional[Dict]:
|
||||
_subproc_env = {
|
||||
**os.environ,
|
||||
@@ -405,7 +465,6 @@ async def _direct_fallback(
|
||||
try:
|
||||
ctx = {
|
||||
"progress_cb": progress_cb,
|
||||
"workspace": workspace,
|
||||
"subproc_env": _subproc_env,
|
||||
}
|
||||
|
||||
@@ -448,6 +507,34 @@ async def execute_tool_block(
|
||||
) -> Tuple[str, Dict]:
|
||||
"""Execute a single tool block. Returns (description, result_dict).
|
||||
|
||||
Thin wrapper: bind the per-turn workspace (so the path resolvers + subprocess
|
||||
cwd confine to it) for the duration of this call, then delegate. Reset on the
|
||||
way out so the binding never leaks to the next tool call.
|
||||
"""
|
||||
token = _active_workspace.set(workspace or None)
|
||||
try:
|
||||
return await _execute_tool_block_impl(
|
||||
block,
|
||||
session_id=session_id,
|
||||
disabled_tools=disabled_tools,
|
||||
owner=owner,
|
||||
progress_cb=progress_cb,
|
||||
tool_policy=tool_policy,
|
||||
)
|
||||
finally:
|
||||
_active_workspace.reset(token)
|
||||
|
||||
|
||||
async def _execute_tool_block_impl(
|
||||
block: Any,
|
||||
session_id: Optional[str] = None,
|
||||
disabled_tools: Optional[set] = None,
|
||||
owner: Optional[str] = None,
|
||||
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
||||
tool_policy: Optional[Any] = None,
|
||||
) -> Tuple[str, Dict]:
|
||||
"""Execute a single tool block. Returns (description, result_dict).
|
||||
|
||||
`progress_cb` is forwarded to long-running subprocess tools
|
||||
(bash, python) so the agent loop can emit `tool_progress` SSE
|
||||
events while the command is in flight. Ignored by other tools.
|
||||
@@ -621,7 +708,7 @@ async def execute_tool_block(
|
||||
_is_bg, _bg_cmd = _split_bg_marker(content)
|
||||
if _is_bg and _bg_cmd:
|
||||
from src import bg_jobs
|
||||
rec = bg_jobs.launch(_bg_cmd, session_id=session_id, cwd=_AGENT_WORKDIR)
|
||||
rec = bg_jobs.launch(_bg_cmd, session_id=session_id, cwd=agent_cwd())
|
||||
short = _bg_cmd.strip().split(chr(10))[0][:80]
|
||||
desc = f"bash (background): {short}"
|
||||
result = {
|
||||
@@ -644,7 +731,7 @@ async def execute_tool_block(
|
||||
first_line = content.split(chr(10))[0][:80]
|
||||
desc = f"{tool}: {first_line}"
|
||||
result = await _call_mcp_tool(tool, content, progress_cb=progress_cb)
|
||||
elif tool in ("grep", "glob", "ls"):
|
||||
elif tool in ("grep", "glob", "ls", "get_workspace"):
|
||||
# Code-navigation tools — no MCP server; run the direct implementation.
|
||||
first_line = content.split(chr(10))[0][:80]
|
||||
desc = f"{tool}: {first_line}"
|
||||
@@ -744,7 +831,7 @@ async def execute_tool_block(
|
||||
desc = "edit_image"
|
||||
result = await do_edit_image(content, owner=owner)
|
||||
elif tool == "edit_file":
|
||||
result = await _direct_fallback(tool, content, workspace=workspace) or {"error": "edit failed", "exit_code": 1}
|
||||
result = await _direct_fallback(tool, content) or {"error": "edit failed", "exit_code": 1}
|
||||
desc = result.get("output") or result.get("error") or "edit_file"
|
||||
elif tool == "trigger_research":
|
||||
desc = "trigger_research"
|
||||
|
||||
@@ -2054,13 +2054,14 @@ async def _cookbook_env_for_host(host: str) -> Dict[str, Any]:
|
||||
else:
|
||||
env_prefix = f'eval "$(conda shell.bash hook)" && conda activate {env_path}'
|
||||
|
||||
from routes.cookbook_helpers import load_stored_hf_token
|
||||
return {
|
||||
"env_prefix": env_prefix,
|
||||
"env_type": env_kind,
|
||||
"env_path": env_path,
|
||||
"gpus": env_root.get("gpus") or "",
|
||||
"platform": platform,
|
||||
"hf_token": env_root.get("hfToken") or "",
|
||||
"hf_token": load_stored_hf_token(),
|
||||
"ssh_port": ssh_port,
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -67,14 +67,15 @@ COLLECTION_NAME = "odysseus_tool_index"
|
||||
# Each tool gets a searchable description that helps retrieval.
|
||||
# These are richer than the system prompt one-liners — they're for embedding.
|
||||
BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"bash": "Run shell commands on the server. Install packages, check files, git operations, system info, and process management. Do not use for web lookup/search; use web_search or web_fetch when web tools are available.",
|
||||
"python": "Execute Python code for computation, data processing, math, scripting, and parsing. Not for writing code for the user. Do not use for web lookup/search; use web_search or web_fetch when web tools are available.",
|
||||
"bash": "Run shell commands on the server. Install packages, git operations, builds, system info, process management. Prefer a dedicated tool whenever one fits the job (file read/write/edit, search, listing); use bash only for what no dedicated tool covers. Do not use for web lookup/search; use web_search or web_fetch when web tools are available.",
|
||||
"python": "Execute Python code for computation, data processing, math, scripting, and parsing. Not for writing code for the user. Prefer a dedicated tool for reading, writing, or searching files; use python only for what no dedicated tool covers. Do not use for web lookup/search; use web_search or web_fetch when web tools are available.",
|
||||
"web_search": "Quick single web lookup for a fact, current event, latest/current information, or doc mid-task. Use this instead of bash/curl/python/requests for web searches. NOT for 'research X' / 'do research on X' requests — those are deep-research jobs (use trigger_research). web_search = one query; trigger_research = a full researched report in the sidebar.",
|
||||
"web_fetch": "Fetch and read the text content of a specific URL/website the user names (e.g. 'check example.com', 'open this link'). Use when you have a concrete URL; for open-ended lookups use web_search instead.",
|
||||
"read_file": "Read a file from disk and return its contents. View source code, config files, logs. Supports an optional line range (offset/limit) for large files.",
|
||||
"grep": "Search file CONTENTS for a regex across a directory tree (ripgrep-backed, honours .gitignore). Returns file:line:match. Use to find where code/symbols/strings live — prefer over bash grep.",
|
||||
"glob": "Find FILES by glob pattern (e.g. '**/*.py'), newest first. Use to locate files by name/extension — prefer over bash find/ls.",
|
||||
"ls": "List a directory's entries (folders then files with sizes). Use to see what's in a folder — prefer over bash ls.",
|
||||
"get_workspace": "Return the absolute path of the active workspace folder the user is working in. File tools are confined to it; the shell starts there but is not sandboxed. Call this first when the user refers to 'the project'/'the code'/'this folder' without giving a path, instead of asking them.",
|
||||
"write_file": "Write/create or fully rewrite a file ON DISK (source code, configs, project files). Use for new files or full rewrites — NOT create_document (editor panel) and NOT a bash heredoc.",
|
||||
"edit_file": "Edit an existing file ON DISK by exact string replacement (fix a bug, change a function). Shows a diff. The tool for changing files on disk — NOT edit_document (editor panel) and NOT bash sed/heredoc.",
|
||||
"create_document": "Create a new document in the editor panel. For code, articles, text content longer than 15 lines, unless an already-open document/email draft is the obvious target. If an email compose draft is open, edit that draft instead of creating another document.",
|
||||
|
||||
+12
-2
@@ -25,7 +25,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (full access)",
|
||||
"description": "Run a shell command (full access). Prefer a dedicated tool whenever one fits the job (reading, writing, editing, searching, or listing files); use bash only for what no dedicated tool covers (installs, git, builds, running programs, system info). Do NOT create or edit files via bash redirects/heredocs/sed -- use the dedicated file tools.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -39,7 +39,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "python",
|
||||
"description": "Execute Python code to compute a result or test something",
|
||||
"description": "Execute Python code to compute a result or test something. Prefer a dedicated tool whenever one fits the job (reading, writing, or searching files); use python only for computation, data processing, or scripting no dedicated tool covers.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -141,6 +141,14 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_workspace",
|
||||
"description": "Return the absolute path of the active workspace folder the user is working in. File tools are confined to it; the shell starts there but is not sandboxed. Call this first when the user refers to 'the project'/'the code'/'this folder' without a path, instead of asking them. Takes no arguments.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -1246,6 +1254,8 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
||||
content = args.get("path", "")
|
||||
elif tool_type in ("grep", "glob", "ls"):
|
||||
content = json.dumps(args) if args else "{}"
|
||||
elif tool_type == "get_workspace":
|
||||
content = ""
|
||||
elif tool_type == "write_file":
|
||||
content = args.get("path", "") + "\n" + args.get("content", "")
|
||||
elif tool_type == "edit_file":
|
||||
|
||||
@@ -20,6 +20,7 @@ NON_ADMIN_BLOCKED_TOOLS = {
|
||||
"grep",
|
||||
"glob",
|
||||
"ls",
|
||||
"get_workspace",
|
||||
"search_chats",
|
||||
"manage_memory",
|
||||
"manage_skills",
|
||||
@@ -66,6 +67,7 @@ PLAN_MODE_READONLY_TOOLS = {
|
||||
"grep",
|
||||
"glob",
|
||||
"ls",
|
||||
"get_workspace",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"search_chats",
|
||||
|
||||
@@ -352,6 +352,86 @@ class UploadHandler:
|
||||
return dict(info)
|
||||
return None
|
||||
|
||||
def _renamed_upload_index_key(self, key: str, info: Dict[str, Any], old_owner: str, new_owner: str) -> str:
|
||||
"""Return the storage key to use after renaming an owned upload row."""
|
||||
if isinstance(key, str) and ":" in key:
|
||||
owner_part, rest = key.split(":", 1)
|
||||
if owner_part.strip().lower() == old_owner:
|
||||
return f"{new_owner}:{rest}"
|
||||
file_hash = info.get("hash")
|
||||
if file_hash:
|
||||
return f"{new_owner}:{file_hash}"
|
||||
return key
|
||||
|
||||
def _unique_upload_index_key(self, base_key: str, used_keys: set, reserved_keys: set, info: Dict[str, Any]) -> str:
|
||||
"""Choose a deterministic collision key without overwriting an existing row."""
|
||||
if base_key not in used_keys and base_key not in reserved_keys:
|
||||
return base_key
|
||||
|
||||
upload_id = str(info.get("id") or "renamed").strip() or "renamed"
|
||||
candidate = f"{base_key}:{upload_id}"
|
||||
if candidate not in used_keys and candidate not in reserved_keys:
|
||||
return candidate
|
||||
|
||||
index = 2
|
||||
while True:
|
||||
candidate = f"{base_key}:{upload_id}:{index}"
|
||||
if candidate not in used_keys and candidate not in reserved_keys:
|
||||
return candidate
|
||||
index += 1
|
||||
|
||||
def rename_owner(self, old_owner: str, new_owner: str) -> int:
|
||||
"""Rename upload metadata ownership from old_owner to new_owner.
|
||||
|
||||
Upload rows are keyed by owner-qualified hashes for dedupe and also
|
||||
carry an `owner` field for access checks. Both must move together when
|
||||
usernames change.
|
||||
"""
|
||||
old_owner_normalized = str(old_owner or "").strip().lower()
|
||||
new_owner = str(new_owner or "").strip()
|
||||
if not old_owner_normalized or not new_owner:
|
||||
return 0
|
||||
if old_owner_normalized == new_owner.lower():
|
||||
return 0
|
||||
|
||||
uploads_db_path = os.path.join(self.upload_dir, "uploads.json")
|
||||
with self._index_lock:
|
||||
current = self._load_upload_index()
|
||||
if not current:
|
||||
return 0
|
||||
|
||||
updated = {}
|
||||
renamed = 0
|
||||
original_keys = set(current.keys())
|
||||
|
||||
for key, info in current.items():
|
||||
new_key = key
|
||||
new_info = info
|
||||
if isinstance(info, dict) and str(info.get("owner", "")).strip().lower() == old_owner_normalized:
|
||||
new_info = dict(info)
|
||||
new_info["owner"] = new_owner
|
||||
base_key = self._renamed_upload_index_key(key, new_info, old_owner_normalized, new_owner)
|
||||
new_key = self._unique_upload_index_key(
|
||||
base_key,
|
||||
set(updated.keys()),
|
||||
original_keys - {key},
|
||||
new_info,
|
||||
)
|
||||
if new_key != base_key:
|
||||
logger.warning(
|
||||
"Upload owner rename key collision for %s -> %s at %s; preserving row as %s",
|
||||
old_owner_normalized,
|
||||
new_owner,
|
||||
base_key,
|
||||
new_key,
|
||||
)
|
||||
renamed += 1
|
||||
updated[new_key] = new_info
|
||||
|
||||
if renamed:
|
||||
self._atomic_write_json(uploads_db_path, updated)
|
||||
return renamed
|
||||
|
||||
def _find_upload_path(self, upload_id: str) -> Optional[str]:
|
||||
"""Find an upload file by ID while staying inside upload_dir."""
|
||||
if not self.validate_upload_id(upload_id):
|
||||
|
||||
+15
-3
@@ -202,6 +202,18 @@ class WebhookManager:
|
||||
self._client = httpx.AsyncClient(timeout=10, follow_redirects=False)
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._api_key_manager = api_key_manager
|
||||
# Strong references to in-flight fire-and-forget tasks. asyncio only
|
||||
# keeps weak references to tasks, so without this the GC can collect a
|
||||
# delivery task mid-flight and the webhook is silently never sent.
|
||||
self._bg_tasks: set = set()
|
||||
|
||||
def _spawn_tracked(self, coro):
|
||||
"""Schedule a background task and hold a strong reference until it
|
||||
finishes, so it can't be garbage-collected before delivery completes."""
|
||||
task = asyncio.ensure_future(coro)
|
||||
self._bg_tasks.add(task)
|
||||
task.add_done_callback(self._bg_tasks.discard)
|
||||
return task
|
||||
|
||||
def set_loop(self, loop: asyncio.AbstractEventLoop):
|
||||
self._loop = loop
|
||||
@@ -223,8 +235,8 @@ class WebhookManager:
|
||||
if event not in ALLOWED_EVENTS:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(self.fire(event, payload))
|
||||
asyncio.get_running_loop()
|
||||
self._spawn_tracked(self.fire(event, payload))
|
||||
except RuntimeError:
|
||||
# Called from a sync thread (e.g. sync FastAPI route in threadpool)
|
||||
if self._loop and self._loop.is_running():
|
||||
@@ -243,7 +255,7 @@ class WebhookManager:
|
||||
|
||||
for wh in matching:
|
||||
decrypted_secret = self._decrypt_secret(wh.secret)
|
||||
asyncio.create_task(self._deliver(wh.id, wh.url, decrypted_secret, event, payload))
|
||||
self._spawn_tracked(self._deliver(wh.id, wh.url, decrypted_secret, event, payload))
|
||||
|
||||
async def deliver_test(self, webhook_id: str, url: str, encrypted_secret: Optional[str]):
|
||||
"""Public method for the test-webhook route."""
|
||||
|
||||
+6
-6
@@ -4,6 +4,7 @@
|
||||
// ============================================
|
||||
import Storage from './js/storage.js';
|
||||
import uiModule from './js/ui.js';
|
||||
import workspaceModule from './js/workspace.js';
|
||||
import fileHandlerModule from './js/fileHandler.js';
|
||||
import modelsModule from './js/models.js';
|
||||
import ragModule from './js/rag.js';
|
||||
@@ -1159,7 +1160,7 @@ function initializeEventListeners() {
|
||||
if (!p.can_use_bash) {
|
||||
const bashToggle = document.getElementById('bash-toggle');
|
||||
if (bashToggle) bashToggle.closest('.chat-input-toggle')?.style.setProperty('display', 'none');
|
||||
const bashBtn = document.getElementById('tool-bash-btn');
|
||||
const bashBtn = document.getElementById('bash-toggle-btn');
|
||||
if (bashBtn) bashBtn.style.display = 'none';
|
||||
}
|
||||
// Hide document button
|
||||
@@ -1176,11 +1177,7 @@ function initializeEventListeners() {
|
||||
const resOverflow = document.getElementById('overflow-research-btn');
|
||||
if (resOverflow) resOverflow.style.display = 'none';
|
||||
}
|
||||
// Hide image generation options
|
||||
if (!p.can_generate_images) {
|
||||
const imgBtn = document.getElementById('tool-image-btn');
|
||||
if (imgBtn) imgBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -1626,6 +1623,8 @@ function initializeEventListeners() {
|
||||
// Slide the pill to the active button
|
||||
const toggle = agentBtn.closest('.mode-toggle');
|
||||
if (toggle) toggle.classList.toggle('mode-chat', mode === 'chat');
|
||||
// Workspace pill + overflow entry are agent-only - hide immediately (no flash).
|
||||
try { workspaceModule.applyMode(mode); } catch (_) {}
|
||||
// Delay tool glow-up for a staggered effect
|
||||
setTimeout(() => applyModeToToggles(mode), 500);
|
||||
}
|
||||
@@ -1701,6 +1700,7 @@ function initializeEventListeners() {
|
||||
}
|
||||
setupToggle('web-toggle-btn', 'web-toggle', 'web');
|
||||
setupToggle('bash-toggle-btn', 'bash-toggle', 'bash');
|
||||
try { workspaceModule.initWorkspace(); } catch (_) {}
|
||||
|
||||
// Document editor toggle (special: uses module panel, not a checkbox)
|
||||
const overflowDocBtn = el('overflow-doc-btn');
|
||||
|
||||
+14
-1
@@ -1040,6 +1040,13 @@
|
||||
<span>RAG</span>
|
||||
<span class="overflow-active-dot"></span>
|
||||
</button>
|
||||
<button type="button" class="overflow-menu-item" id="overflow-workspace-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
</svg>
|
||||
<span>Workspace</span>
|
||||
<span class="overflow-active-dot"></span>
|
||||
</button>
|
||||
<!-- Inline "deep research mode" toggle removed (superseded by the
|
||||
Deep Research sidebar / trigger_research). The hidden
|
||||
#research-toggle checkbox is kept inert so existing JS refs
|
||||
@@ -1071,6 +1078,12 @@
|
||||
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Workspace indicator (hidden until a folder is set) -->
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="Workspace - click to clear" id="workspace-indicator-btn" aria-label="Clear workspace" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
<span style="font-size:11px;margin-left:2px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" id="workspace-indicator-name"></span>
|
||||
<svg class="tool-indicator-x" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
||||
</button>
|
||||
<!-- RAG toolbar indicator (hidden until active) -->
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="RAG active — click to deactivate" id="rag-indicator-btn" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -2342,7 +2355,7 @@
|
||||
<script type="module" src="/static/js/chatRenderer.js"></script>
|
||||
<script type="module" src="/static/js/codeRunner.js"></script>
|
||||
<script type="module" src="/static/js/chatStream.js"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260604s"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260609ws"></script>
|
||||
<script type="module" src="/static/js/cookbook.js"></script>
|
||||
<script src="/static/js/cookbookSchedule.js"></script>
|
||||
<script type="module" src="/static/js/search-chat.js"></script>
|
||||
|
||||
+22
-3
@@ -802,15 +802,15 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
} else {
|
||||
fd.append('use_web', 'true');
|
||||
}
|
||||
} else if (isAgentMode) {
|
||||
fd.append('allow_web_search', 'false');
|
||||
}
|
||||
if (el('research-toggle').checked) {
|
||||
fd.append('use_research', 'true');
|
||||
// Research always runs in chat mode — override agent if set
|
||||
fd.set('mode', 'chat');
|
||||
}
|
||||
if (el('bash-toggle').checked) {
|
||||
fd.append('allow_bash', 'true');
|
||||
}
|
||||
fd.append('allow_bash', el('bash-toggle').checked ? 'true' : 'false');
|
||||
const ragChk = el('rag-toggle');
|
||||
if (ragChk && !ragChk.checked) {
|
||||
fd.append('use_rag', 'false');
|
||||
@@ -819,6 +819,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (incognitoChk && incognitoChk.checked) {
|
||||
fd.append('incognito', 'true');
|
||||
}
|
||||
const _ws = (Storage.KEYS && Storage.get(Storage.KEYS.WORKSPACE, '')) || '';
|
||||
if (_ws) {
|
||||
fd.append('workspace', _ws);
|
||||
}
|
||||
if (presetsModule.getSelectedPreset()) {
|
||||
fd.append('preset_id', presetsModule.getSelectedPreset());
|
||||
}
|
||||
@@ -1781,6 +1785,21 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
_sourcesData = json.data; _sourcesType = 'web';
|
||||
_sourcesHtml = _buildSourcesBox(json.data, 'web');
|
||||
}
|
||||
} else if (json.type === 'workspace_rejected') {
|
||||
// Server refused to bind the posted workspace (deleted folder,
|
||||
// file path, sensitive dir, filesystem root). Clear the stored
|
||||
// value so the pill stops claiming a confinement that is not in
|
||||
// effect, and tell the user.
|
||||
const _wsPath = (json.data && json.data.path) || '';
|
||||
import('./workspace.js').then((m) => {
|
||||
const ws = m.default || m;
|
||||
if (ws && ws.setWorkspace) ws.setWorkspace('');
|
||||
});
|
||||
uiModule.showToast(
|
||||
`Workspace ${_wsPath || '(unknown)'} is no longer usable; running without confinement`,
|
||||
6000
|
||||
);
|
||||
continue;
|
||||
} else if (json.type === 'model_fallback') {
|
||||
// Model went offline — switched to fallback
|
||||
var _fbData = json.data || {};
|
||||
|
||||
@@ -406,7 +406,7 @@ export const ERROR_PATTERNS = [
|
||||
{ label: 'Repair kernel package', action: () => {
|
||||
const _vp = (_envState.env === 'venv' && _envState.envPath)
|
||||
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
|
||||
_launchServeTask('repair-kernels', 'pip-update', `${_vp} -m pip install --user --break-system-packages kernels<0.15`);
|
||||
_launchServeTask('repair-kernels', 'pip-update', `${_vp} -m pip install --user --break-system-packages "kernels<0.15"`);
|
||||
}},
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
|
||||
],
|
||||
|
||||
@@ -1506,12 +1506,10 @@ export function _hwfitInit() {
|
||||
clearTimeout(_hwfitDebounce);
|
||||
_hwfitDebounce = setTimeout(() => _hwfitFetch(), 400);
|
||||
});
|
||||
// HF Token
|
||||
const hfToken = document.getElementById('hwfit-hftoken');
|
||||
if (hfToken) {
|
||||
hfToken.addEventListener('change', () => { _envState.hfToken = hfToken.value.trim(); _persistEnvState(); });
|
||||
hfToken.addEventListener('input', () => { _envState.hfToken = hfToken.value.trim(); });
|
||||
}
|
||||
// HF token save is owned by cookbook.js (_wireTabEvents) — do not wire a
|
||||
// second change/input handler here. The old duplicate ran after cookbook.js
|
||||
// cleared the input on save and overwrote _envState.hfToken with "", so the
|
||||
// debounced state sync never persisted the token to cookbook_state.json.
|
||||
|
||||
// Rebuild all server select dropdowns with current servers
|
||||
function _rebuildServerSelect() {
|
||||
|
||||
@@ -3547,6 +3547,7 @@ async function _pollBackgroundStatus() {
|
||||
updates.status = live.status === 'ready' ? 'ready' : 'running';
|
||||
}
|
||||
if (live.progress && live.progress !== task.progress) updates.progress = live.progress;
|
||||
if (live.exit_code != null && live.exit_code !== task.exit_code) updates.exit_code = live.exit_code;
|
||||
if (live.output_tail) {
|
||||
const previous = String(task.output || '');
|
||||
const tail = String(live.output_tail || '');
|
||||
|
||||
@@ -17,6 +17,7 @@ import chatRenderer from './chatRenderer.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import themeModule from './theme.js';
|
||||
import documentModule from './document.js';
|
||||
import workspaceModule from './workspace.js';
|
||||
import settingsModule from './settings.js';
|
||||
import cookbookModule from './cookbook.js';
|
||||
import { EVAL_PROMPTS } from './compare/index.js';
|
||||
@@ -1229,6 +1230,40 @@ async function _cmdToggleDoc(args, ctx) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Workspace: confine the agent's file/shell tools to a folder. Not a boolean -
|
||||
// show / set <path> / clear / pick (open the directory browser).
|
||||
async function _cmdWorkspace(args, ctx) {
|
||||
const sub = (args[0] || '').toLowerCase();
|
||||
const rest = args.slice(1).join(' ').trim();
|
||||
const cur = workspaceModule.getWorkspace();
|
||||
if (!sub || sub === 'show' || sub === 'status' || sub === 'info') {
|
||||
slashReply(cur ? `Workspace: <code>${uiModule.esc(cur)}</code>` : 'No workspace set. <code>/workspace pick</code> or <code>/workspace set /path</code>.');
|
||||
return true;
|
||||
}
|
||||
if (sub === 'set' || sub === 'cd' || sub === 'use') {
|
||||
if (!rest) { slashReply('Usage: <code>/workspace set /absolute/path</code>'); return true; }
|
||||
// Validate server-side before persisting so the pill never claims a
|
||||
// workspace the backend will refuse to bind (typo, file path, deleted
|
||||
// folder, sensitive dir, filesystem root).
|
||||
workspaceModule.vetAndSetWorkspace(rest).then(({ ok, path }) => {
|
||||
if (ok) slashReply(`Workspace set: <code>${uiModule.esc(path)}</code>`);
|
||||
else slashReply(`Not a usable workspace folder: <code>${uiModule.esc(rest)}</code>. It must be an existing directory, not a filesystem root or sensitive path.`);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (sub === 'clear' || sub === 'off' || sub === 'none' || sub === 'unset') {
|
||||
workspaceModule.clearWorkspace();
|
||||
slashReply('Workspace cleared.');
|
||||
return true;
|
||||
}
|
||||
if (sub === 'pick' || sub === 'browse' || sub === 'open') {
|
||||
workspaceModule.openWorkspaceBrowser();
|
||||
return true;
|
||||
}
|
||||
slashReply('Usage: <code>/workspace</code> · <code>set /path</code> · <code>clear</code> · <code>pick</code>');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _cmdToggleShow(args, ctx) {
|
||||
const name = (args[0] || '').toLowerCase();
|
||||
const val = (args[1] || '').toLowerCase();
|
||||
@@ -5731,6 +5766,14 @@ const COMMANDS = {
|
||||
'_show': { handler: _cmdToggleShow, alias: [], help: 'Show all toggle states', usage: '/toggle' }
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
alias: ['ws'],
|
||||
category: 'Agent',
|
||||
help: 'Set the folder the agent works in',
|
||||
handler: _cmdWorkspace,
|
||||
noUserBubble: true,
|
||||
usage: '/workspace [set <path> | clear | pick]',
|
||||
},
|
||||
memory: {
|
||||
alias: ['m'],
|
||||
category: 'Memory',
|
||||
|
||||
@@ -23,7 +23,8 @@ export const KEYS = {
|
||||
MCP_ACTIVE: 'odysseus-mcp-active',
|
||||
SECTION_ORDER: 'sidebar-section-order',
|
||||
ADMIN_LAST_TAB: 'admin-last-tab',
|
||||
DENSITY: 'odysseus-density'
|
||||
DENSITY: 'odysseus-density',
|
||||
WORKSPACE: 'odysseus-workspace'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// static/js/workspace.js
|
||||
//
|
||||
// Workspace picker: browse server directories in a draggable modal, choose a
|
||||
// folder, and show it as a removable pill in the chat input bar. While set, the
|
||||
// chat request sends `workspace` so the agent's file/shell tools are confined
|
||||
// to that folder (see routes/chat_routes.py + src/tool_execution.py).
|
||||
|
||||
import Storage, { KEYS } from './storage.js';
|
||||
import uiModule from './ui.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
// Same folder glyph as the overflow menu item + pill (not an emoji).
|
||||
const _FOLDER_SVG = '<svg class="workspace-row-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>';
|
||||
let _modal = null;
|
||||
let _curPath = '';
|
||||
|
||||
export function getWorkspace() {
|
||||
return Storage.get(KEYS.WORKSPACE, '') || '';
|
||||
}
|
||||
|
||||
function _basename(p) {
|
||||
if (!p) return '';
|
||||
// Handle both POSIX (/) and Windows (\) separators.
|
||||
const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/);
|
||||
return parts[parts.length - 1] || p;
|
||||
}
|
||||
|
||||
// Workspace only applies to agent mode (it scopes the file/shell tools), so the
|
||||
// pill + overflow entry are hidden in chat mode, like the bash toggle.
|
||||
function _isChatMode() {
|
||||
const b = document.getElementById('mode-chat-btn');
|
||||
return !!(b && b.classList.contains('active'));
|
||||
}
|
||||
|
||||
export function syncWorkspaceIndicator(path) {
|
||||
const chat = _isChatMode();
|
||||
const pill = document.getElementById('workspace-indicator-btn');
|
||||
const name = document.getElementById('workspace-indicator-name');
|
||||
const overflow = document.getElementById('overflow-workspace-btn');
|
||||
if (pill) {
|
||||
pill.style.display = (path && !chat) ? '' : 'none';
|
||||
pill.classList.toggle('active', !!path);
|
||||
if (path) pill.title = `Workspace: ${path}\nFile tools are confined here; shell commands start here but are not sandboxed and can reach outside it.\nClick to clear.`;
|
||||
}
|
||||
if (name) name.textContent = path ? _basename(path) : '';
|
||||
if (overflow) {
|
||||
overflow.style.display = chat ? 'none' : '';
|
||||
overflow.classList.toggle('active', !!path);
|
||||
}
|
||||
// Recompute the "+" overflow dot (app.js owns updatePlusDot via this event).
|
||||
try { document.dispatchEvent(new CustomEvent('overflow-state-change')); } catch (_) {}
|
||||
}
|
||||
|
||||
// Called by the agent/chat mode toggle so the pill + overflow entry follow mode.
|
||||
export function applyMode(_mode) {
|
||||
syncWorkspaceIndicator(getWorkspace());
|
||||
}
|
||||
|
||||
export function setWorkspace(path) {
|
||||
if (path) Storage.set(KEYS.WORKSPACE, path);
|
||||
else Storage.remove(KEYS.WORKSPACE);
|
||||
syncWorkspaceIndicator(path || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a manually entered path server-side, then persist the canonical
|
||||
* form. Returns {ok, path|null}. Without this, a typo / file path / deleted
|
||||
* folder / filesystem root would be stored and shown as active while the
|
||||
* backend silently refuses to bind it on every send.
|
||||
*/
|
||||
export async function vetAndSetWorkspace(path) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/workspace/vet?path=${encodeURIComponent(path)}`, { credentials: 'same-origin' });
|
||||
if (!res.ok) return { ok: false, path: null };
|
||||
const data = await res.json();
|
||||
if (data.ok && data.path) {
|
||||
setWorkspace(data.path);
|
||||
return { ok: true, path: data.path };
|
||||
}
|
||||
return { ok: false, path: null };
|
||||
} catch (e) {
|
||||
return { ok: false, path: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWorkspace() {
|
||||
setWorkspace('');
|
||||
if (uiModule && uiModule.showToast) uiModule.showToast('Workspace cleared');
|
||||
}
|
||||
|
||||
async function _load(path) {
|
||||
const url = `${API_BASE}/api/workspace/browse${path ? `?path=${encodeURIComponent(path)}` : ''}`;
|
||||
const res = await fetch(url, { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`browse failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function _render(data) {
|
||||
_curPath = data.path;
|
||||
const body = _modal.querySelector('#workspace-body');
|
||||
const pathEl = _modal.querySelector('#workspace-cur-path');
|
||||
if (pathEl) {
|
||||
// Reflect the resolved (realpath) location back into the editable field.
|
||||
pathEl.value = data.path;
|
||||
pathEl.title = data.path;
|
||||
}
|
||||
let rows = '';
|
||||
if (data.parent) {
|
||||
rows += `<div class="workspace-row workspace-up" data-path="${encodeURIComponent(data.parent)}">↑ ..</div>`;
|
||||
}
|
||||
for (const d of data.dirs) {
|
||||
// Backend supplies the full child path (os.path.join → cross-platform).
|
||||
rows += `<div class="workspace-row" data-path="${encodeURIComponent(d.path)}">${_FOLDER_SVG}<span>${uiModule.esc(d.name)}</span></div>`;
|
||||
}
|
||||
if (data.truncated) {
|
||||
rows += '<div class="workspace-empty">Too many folders to list. Type or paste a path above to jump in.</div>';
|
||||
}
|
||||
if (!data.dirs.length && !data.parent) rows = '<div class="workspace-empty">No subfolders</div>';
|
||||
body.innerHTML = rows || '<div class="workspace-empty">No subfolders</div>';
|
||||
body.querySelectorAll('.workspace-row').forEach((row) => {
|
||||
row.addEventListener('click', () => _navigate(decodeURIComponent(row.dataset.path)));
|
||||
});
|
||||
// Filesystem roots (and sensitive dirs) can be browsed through but never
|
||||
// bound as the workspace; the backend rejects them too.
|
||||
const useBtn = _modal.querySelector('#workspace-use');
|
||||
if (useBtn) {
|
||||
useBtn.disabled = data.selectable === false;
|
||||
useBtn.title = data.selectable === false ? 'This folder cannot be used as a workspace' : '';
|
||||
}
|
||||
}
|
||||
|
||||
async function _navigate(path) {
|
||||
try {
|
||||
_render(await _load(path));
|
||||
} catch (e) {
|
||||
if (uiModule && uiModule.showError) uiModule.showError('Could not open folder');
|
||||
}
|
||||
}
|
||||
|
||||
function _getModal() {
|
||||
if (_modal) return _modal;
|
||||
_modal = document.createElement('div');
|
||||
_modal.id = 'workspace-modal';
|
||||
_modal.className = 'modal';
|
||||
_modal.style.display = 'none';
|
||||
_modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>Select workspace</h4>
|
||||
<button class="close-btn" id="workspace-close" aria-label="Close">✖</button>
|
||||
</div>
|
||||
<input type="text" class="styled-prompt-input workspace-cur" id="workspace-cur-path"
|
||||
spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"
|
||||
placeholder="Type or paste a folder path, then press Enter" />
|
||||
<p class="muted workspace-note">File tools are <strong>confined</strong> to this folder. Shell commands start here but are <strong>not sandboxed</strong> and can reach outside it. A workspace scopes the tools; it is not a security boundary.</p>
|
||||
<div class="modal-body workspace-body" id="workspace-body"></div>
|
||||
<div class="modal-footer workspace-footer">
|
||||
<button type="button" class="confirm-btn confirm-btn-secondary" id="workspace-cancel">Cancel</button>
|
||||
<button type="button" class="confirm-btn confirm-btn-primary" id="workspace-use">Use this folder</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(_modal);
|
||||
_modal.querySelector('#workspace-close').addEventListener('click', closeWorkspaceBrowser);
|
||||
_modal.querySelector('#workspace-cancel').addEventListener('click', closeWorkspaceBrowser);
|
||||
// Editable path bar: Enter navigates to a typed/pasted folder.
|
||||
_modal.querySelector('#workspace-cur-path').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const v = e.target.value.trim();
|
||||
if (v) _navigate(v);
|
||||
}
|
||||
});
|
||||
_modal.querySelector('#workspace-use').addEventListener('click', () => {
|
||||
setWorkspace(_curPath);
|
||||
if (uiModule && uiModule.showToast) uiModule.showToast(`Workspace set: ${_basename(_curPath)}`);
|
||||
closeWorkspaceBrowser();
|
||||
});
|
||||
const content = _modal.querySelector('.modal-content');
|
||||
const header = _modal.querySelector('.modal-header');
|
||||
if (content && header) makeWindowDraggable(_modal, { content, header });
|
||||
return _modal;
|
||||
}
|
||||
|
||||
export async function openWorkspaceBrowser() {
|
||||
const modal = _getModal();
|
||||
modal.style.display = 'flex';
|
||||
try {
|
||||
_render(await _load(getWorkspace() || ''));
|
||||
} catch (e) {
|
||||
if (uiModule && uiModule.showError) uiModule.showError('Could not browse folders');
|
||||
}
|
||||
}
|
||||
|
||||
export function closeWorkspaceBrowser() {
|
||||
if (_modal) _modal.style.display = 'none';
|
||||
}
|
||||
|
||||
export function initWorkspace() {
|
||||
// Restore persisted workspace into the pill on load.
|
||||
syncWorkspaceIndicator(getWorkspace());
|
||||
const overflow = document.getElementById('overflow-workspace-btn');
|
||||
if (overflow) overflow.addEventListener('click', openWorkspaceBrowser);
|
||||
const pill = document.getElementById('workspace-indicator-btn');
|
||||
if (pill) pill.addEventListener('click', clearWorkspace);
|
||||
}
|
||||
|
||||
export default { initWorkspace, openWorkspaceBrowser, getWorkspace, setWorkspace, vetAndSetWorkspace, clearWorkspace, syncWorkspaceIndicator, applyMode };
|
||||
@@ -36606,3 +36606,48 @@ body.theme-frosted .modal {
|
||||
the input beside it (.confirm-btn won't stretch on its own). */
|
||||
.ask-user-other-send { flex-shrink: 0; white-space: nowrap; min-height: 39px; }
|
||||
.ask-user-other-send:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* ── Workspace picker ───────────────────────────────────────────── */
|
||||
/* Layout (width/flex column/max-height) inherited from base .modal-content. */
|
||||
/* Editable path/address bar: reuses .styled-prompt-input for border/bg/radius/
|
||||
focus ring (set in the element's class list). Overrides only the deltas:
|
||||
mono font, and full-bleed via flex stretch with no horizontal margin (the
|
||||
modal-content's 10px padding is the gutter) instead of the base width:100%,
|
||||
which overflowed against the overflow:auto scrollbar. */
|
||||
.workspace-cur {
|
||||
align-self: stretch;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
margin: 4px 0 8px;
|
||||
font-family: var(--mono, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* flex/overflow inherited from base .modal-body; only the padding differs. */
|
||||
.workspace-body { padding: 6px 0; }
|
||||
.workspace-row {
|
||||
padding: 7px 18px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.workspace-row > span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.workspace-row-icon { flex-shrink: 0; opacity: 0.75; }
|
||||
.workspace-row:hover {
|
||||
background: color-mix(in srgb, var(--border) 20%, transparent);
|
||||
}
|
||||
.workspace-up { opacity: 0.7; }
|
||||
.workspace-empty { padding: 14px 18px; opacity: 0.5; font-size: 13px; }
|
||||
.workspace-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.workspace-note { margin: 0 0 8px; font-size: 11px; line-height: 1.4; }
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# Test Layout Inventory
|
||||
|
||||
## Purpose
|
||||
|
||||
Inventory for the first low-risk split of the flat `tests/` directory
|
||||
(issue #3712, parent #2523). This document only records *what* should move
|
||||
first and *why*; it moves nothing. The actual move is a separate, mechanical
|
||||
PR that relocates the listed files verbatim and changes no test content.
|
||||
|
||||
The target layout and category definitions come from
|
||||
[`TESTING_STANDARD.md`](./TESTING_STANDARD.md); the collection-time markers
|
||||
come from [`_taxonomy.py`](./_taxonomy.py), which classifies by **filename
|
||||
tokens only** (paths are ignored, except the `tests/helpers/` rule). A file
|
||||
keeps its `area_*`/`sub_*` markers when moved into a subdirectory, and
|
||||
`conftest.py` discovers marker names recursively (`rglob`), so a move does not
|
||||
disturb marker registration or focused selection.
|
||||
|
||||
## Current low-risk candidate groups
|
||||
|
||||
Groups whose tests need no route/app setup and no real DB/session setup:
|
||||
|
||||
1. **CLI / script tests** (`area_cli`, 28 files) - load `scripts/` entry
|
||||
points via `tests.helpers.cli_loader.load_script`; DB access is stubbed
|
||||
with `tests.helpers.db_stubs` (`SessionLocal` is a plain stub attribute).
|
||||
No `TestClient`, no FastAPI app import, no SQLite files.
|
||||
2. **Helper self-tests** (`area_helpers`) - e.g. `test_helpers_import_state.py`,
|
||||
`test_db_stubs_helper.py`. Safe but tiny (two files), and they test the
|
||||
shared helpers from the #3685 audit (merged) that the rest of the suite
|
||||
depends on; little payoff as a first slice.
|
||||
3. **Pure unit / parsing tests** (`area_unit`) - `*_nonstring.py`,
|
||||
`*_nondict.py`, parsing tests. Large and heterogeneous; some touch
|
||||
provider/session modules, so the boundary is less crisp.
|
||||
4. **Static checks** - e.g. `test_readme_ascii_fenced.py`,
|
||||
`test_docs_no_orphan_images.py`. Safe but tiny and `uncategorized` in the
|
||||
taxonomy, so a move buys little and matches no existing marker.
|
||||
|
||||
Not candidates for the first move (per #3712 guidance): security/owner-scope
|
||||
tests, route/API tests, DB/session-heavy tests, auth/session concurrency
|
||||
tests, and the taxonomy/runner infrastructure tests that changed recently
|
||||
(#3491, #3556, #3659, #3711).
|
||||
|
||||
## Recommended first move
|
||||
|
||||
**CLI / script tests → `tests/cli/`**
|
||||
|
||||
Why this group over the alternatives:
|
||||
|
||||
- Lowest coupling: every file imports only the script under test (via
|
||||
`cli_loader`) plus `tests.helpers` stubs - no app, no routes, no real DB.
|
||||
- Crisp, machine-checkable boundary: the set is exactly the files classified
|
||||
`area_cli` by `_taxonomy.py`, so before/after selection counts can be
|
||||
compared mechanically.
|
||||
- Already the planned target dir for this category in `TESTING_STANDARD.md`
|
||||
(`tests/cli/`).
|
||||
- Absolute imports (`from tests.helpers...`) and unique basenames mean no
|
||||
import-order or module-name collisions after the move.
|
||||
- Lower risk than helper self-tests (tiny group, little payoff), unit tests
|
||||
(fuzzy boundary), or anything security/route/session-shaped.
|
||||
|
||||
## Files included in the first move
|
||||
|
||||
The 28 files classified `area_cli` (verified against `_taxonomy.py`):
|
||||
|
||||
Note: this inventory was refreshed against current `dev` after `tests/test_research_cli_status.py` was added to the `area_cli` set.
|
||||
|
||||
- `tests/test_calendar_cli_name.py`
|
||||
- `tests/test_contacts_cli_rows.py`
|
||||
- `tests/test_cookbook_cli_state.py`
|
||||
- `tests/test_docs_cli_content_length.py`
|
||||
- `tests/test_gallery_cli_album_count.py`
|
||||
- `tests/test_gallery_cli_preview.py`
|
||||
- `tests/test_logs_cli_resolve_nonstring.py`
|
||||
- `tests/test_mail_cli_read_empty_fetch.py`
|
||||
- `tests/test_mail_cli_recipients.py`
|
||||
- `tests/test_mcp_cli_env_serialize.py`
|
||||
- `tests/test_mcp_cli_json.py`
|
||||
- `tests/test_memory_cli_rows.py`
|
||||
- `tests/test_notes_cli_items.py`
|
||||
- `tests/test_personal_cli_rows.py`
|
||||
- `tests/test_preset_cli_invalid_entries.py`
|
||||
- `tests/test_preset_cli_set_corrupt_entry.py`
|
||||
- `tests/test_preset_cli_store.py`
|
||||
- `tests/test_research_cli_preview.py`
|
||||
- `tests/test_research_cli_status_filter.py`
|
||||
- `tests/test_research_cli_status.py`
|
||||
- `tests/test_research_cli_store.py`
|
||||
- `tests/test_sessions_cli.py`
|
||||
- `tests/test_signature_cli_export.py`
|
||||
- `tests/test_skills_cli_preview.py`
|
||||
- `tests/test_skills_cli_rows.py`
|
||||
- `tests/test_tasks_cli_preview.py`
|
||||
- `tests/test_theme_cli_store.py`
|
||||
- `tests/test_webhook_cli_mask.py`
|
||||
|
||||
## Files intentionally excluded
|
||||
|
||||
- `tests/test_backup_cli_security.py` - classifies as `area_security`
|
||||
(security outranks cli in the taxonomy); moving it into `tests/cli/` would
|
||||
make the directory disagree with its marker. It belongs with the security
|
||||
group in a later phase.
|
||||
- `tests/test_run_focus.py`, `tests/test_taxonomy.py` - taxonomy/runner
|
||||
infrastructure tests, recently changed (#3556, #3659); they also pin
|
||||
flat-layout paths (e.g. `tests/test_auth_config_lock_concurrency.py` in
|
||||
`test_run_focus.py`), so they stay put.
|
||||
- Script-like but `uncategorized` files - `test_pr_blocker_audit.py`,
|
||||
`test_update_database_script.py`, `test_windows_update_script.py`,
|
||||
`test_setup_admin_user.py`, `test_amd_gpu_check_args.py`, `test_hwfit_*.py`.
|
||||
They exercise `scripts/` too, but moving them would make `tests/cli/`
|
||||
diverge from the `area_cli` marker set. Reclassify or move them in a later,
|
||||
separate slice.
|
||||
- Everything else (security, routes, services, unit, js, helpers) - out of
|
||||
scope for the first move by design.
|
||||
|
||||
## How this was verified
|
||||
|
||||
Read-only checks, run from the repo root on this branch. Note the real API is
|
||||
`classify_test_path` (there is no `classify_test_file`).
|
||||
|
||||
```bash
|
||||
# Compute the area_cli set and confirm test_backup_cli_security.py is
|
||||
# area_security. Expected: 28 files, then "security".
|
||||
.venv/bin/python - <<'PY'
|
||||
from pathlib import Path
|
||||
from tests._taxonomy import classify_test_path
|
||||
|
||||
cli = [p for p in sorted(Path("tests").glob("test_*.py"))
|
||||
if classify_test_path(p).area == "cli"]
|
||||
print(len(cli))
|
||||
for p in cli:
|
||||
print(p)
|
||||
print(classify_test_path("tests/test_backup_cli_security.py").area)
|
||||
PY
|
||||
|
||||
# Coupling check across the CLI files. Expected: the only hits are
|
||||
# "SessionLocal" as stub attribute names passed to tests.helpers.db_stubs;
|
||||
# no TestClient, FastAPI, create_app, sqlite, or dependency_overrides.
|
||||
rg -n "TestClient|FastAPI|create_app|SessionLocal|sqlite|dependency_overrides" \
|
||||
tests/test_*cli*.py tests/test_sessions_cli.py
|
||||
|
||||
# Hard-coded flat paths to the exact CLI files outside tests/. Expected: no matches.
|
||||
.venv/bin/python - <<'PY2' > /tmp/area_cli_paths.txt
|
||||
from pathlib import Path
|
||||
from tests._taxonomy import classify_test_path
|
||||
|
||||
for path in sorted(Path("tests").glob("test_*.py")):
|
||||
if classify_test_path(path).area == "cli":
|
||||
print(path)
|
||||
PY2
|
||||
|
||||
rg -n -F -f /tmp/area_cli_paths.txt .github scripts docs \
|
||||
tests/README.md tests/TESTING_STANDARD.md pyproject.toml 2>/dev/null || true
|
||||
```
|
||||
|
||||
Also checked by reading the code: `tests/conftest.py` registers sub-markers
|
||||
from a recursive `rglob` scan, and `tests/_taxonomy.py` classifies by filename
|
||||
tokens only (plus the `tests/helpers/` directory rule), so the markers of the
|
||||
28 files do not change when they move into `tests/cli/`.
|
||||
|
||||
## Validation for the future move PR
|
||||
|
||||
Run with the project venv (`.venv/bin/python`); system `python3` may miss
|
||||
pinned deps. Before the move, record the baseline; after, compare:
|
||||
|
||||
```bash
|
||||
# Selection must match the 28 files before and after the move.
|
||||
.venv/bin/python tests/run_focus.py --dry-run --area cli
|
||||
.venv/bin/python -m pytest -m area_cli -q
|
||||
|
||||
# Moved files pass when targeted directly.
|
||||
.venv/bin/python -m pytest tests/cli/ -q
|
||||
|
||||
# Whole-suite collection still succeeds (catches import/path breakage).
|
||||
.venv/bin/python -m pytest --collect-only -q
|
||||
|
||||
# Taxonomy/runner infrastructure is unaffected.
|
||||
.venv/bin/python -m pytest tests/test_taxonomy.py tests/test_run_focus.py -q
|
||||
|
||||
# No stale flat-path references to the moved files. Expected: no matches
|
||||
# outside tests/cli/ itself.
|
||||
.venv/bin/python - <<'PY2' > /tmp/area_cli_paths.txt
|
||||
from pathlib import Path
|
||||
from tests._taxonomy import classify_test_path
|
||||
|
||||
for path in sorted(Path("tests").glob("test_*.py")):
|
||||
if classify_test_path(path).area == "cli":
|
||||
print(path)
|
||||
PY2
|
||||
|
||||
rg -n -F -f /tmp/area_cli_paths.txt .github scripts docs \
|
||||
tests/README.md tests/TESTING_STANDARD.md pyproject.toml 2>/dev/null || true
|
||||
```
|
||||
|
||||
Pass criteria: identical test counts for `-m area_cli` before/after, zero
|
||||
collection errors, and no changes outside the moved files.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No file moves, renames, or deletions in this PR.
|
||||
- No changes to `conftest.py`, `_taxonomy.py`, `run_focus.py`, helpers,
|
||||
markers, CI workflows, or production code.
|
||||
- No recommendation to split the whole suite at once; later groups get their
|
||||
own inventory-then-move slices.
|
||||
@@ -0,0 +1,317 @@
|
||||
# Oversized Test File Split Plan
|
||||
|
||||
## Purpose
|
||||
|
||||
This document plans future oversized test-file splits using current repo data.
|
||||
It does not move files, rewrite assertions, extract helpers, or change CI.
|
||||
|
||||
## Roadmap context
|
||||
|
||||
- Issue: #3983
|
||||
- Parent tracker: #2523
|
||||
- Follows #3973 / #3982, the report-only order-sensitivity diagnostics slice.
|
||||
|
||||
## Methodology
|
||||
|
||||
Metrics were generated from the current test tree using:
|
||||
|
||||
- physical line counts for every recursive `test_*.py` file under `tests/`;
|
||||
- AST counts for `test_*` functions and `Test*` classes;
|
||||
- one `pytest --collect-only -q tests` run to count collected items per file;
|
||||
- current taxonomy classification from `tests._taxonomy.classify_test_path`; and
|
||||
- static setup-signal scans for route/API, DB/session, import-state, security, filesystem, subprocess/script, async/threading, and UI/static indicators.
|
||||
|
||||
Static signals are not proof of risk. They are review prompts.
|
||||
Future split PRs must still inspect each file manually before editing.
|
||||
|
||||
## Current summary
|
||||
|
||||
- test files scanned: 541
|
||||
- collected pytest items counted: 3263
|
||||
- large-file threshold: 300 lines
|
||||
- large-collected threshold: 20 collected items
|
||||
|
||||
Area distribution:
|
||||
|
||||
| Value | Files |
|
||||
|---|---:|
|
||||
| cli | 28 |
|
||||
| helpers | 1 |
|
||||
| js | 36 |
|
||||
| routes | 22 |
|
||||
| security | 71 |
|
||||
| services | 134 |
|
||||
| uncategorized | 212 |
|
||||
| unit | 37 |
|
||||
|
||||
Sub-area distribution:
|
||||
|
||||
| Value | Files |
|
||||
|---|---:|
|
||||
| api | 5 |
|
||||
| atomic | 3 |
|
||||
| auth | 8 |
|
||||
| calendar | 9 |
|
||||
| cli | 28 |
|
||||
| confinement | 5 |
|
||||
| cookbook | 10 |
|
||||
| document | 11 |
|
||||
| email | 11 |
|
||||
| embedding | 3 |
|
||||
| gallery | 4 |
|
||||
| history | 3 |
|
||||
| js | 36 |
|
||||
| llm | 15 |
|
||||
| mcp | 8 |
|
||||
| memory | 13 |
|
||||
| nondict | 7 |
|
||||
| nonstring | 22 |
|
||||
| owner | 13 |
|
||||
| owner_scope | 23 |
|
||||
| parse | 4 |
|
||||
| provider | 6 |
|
||||
| research | 16 |
|
||||
| route | 6 |
|
||||
| routes | 9 |
|
||||
| scheduler | 3 |
|
||||
| scope | 5 |
|
||||
| security | 9 |
|
||||
| session | 16 |
|
||||
| ssrf | 2 |
|
||||
| webhook | 2 |
|
||||
| xss | 5 |
|
||||
|
||||
Values below 2 files: 221 values covering 221 files.
|
||||
|
||||
## Top files by collected pytest items
|
||||
|
||||
| File | Lines | Collected tests | Test defs | Test classes | Area | Sub-area | Signals |
|
||||
|---|---:|---:|---:|---:|---|---|---|
|
||||
| `tests/test_model_routes.py` | 1662 | 133 | 110 | 10 | routes | routes | route/api, db/session, import-state, async/threading |
|
||||
| `tests/test_security_regressions.py` | 1203 | 91 | 67 | 0 | security | security | route/api, db/session, import-state, security, filesystem, async/threading, ui/static |
|
||||
| `tests/test_provider_classification.py` | 188 | 67 | 21 | 4 | services | provider | - |
|
||||
| `tests/test_shell_routes.py` | 460 | 62 | 47 | 8 | routes | routes | route/api, import-state, filesystem |
|
||||
| `tests/test_cookbook_helpers.py` | 819 | 59 | 59 | 0 | services | cookbook | route/api, filesystem, subprocess/script, async/threading |
|
||||
| `tests/test_pr_blocker_audit.py` | 964 | 58 | 58 | 0 | uncategorized | pr_blocker_audit | import-state, security, filesystem |
|
||||
| `tests/test_provider_endpoints.py` | 241 | 58 | 18 | 1 | services | provider | subprocess/script |
|
||||
| `tests/test_agent_loop.py` | 458 | 51 | 51 | 5 | uncategorized | agent_loop | db/session, import-state |
|
||||
| `tests/test_service_health.py` | 472 | 47 | 42 | 0 | uncategorized | service_health | async/threading |
|
||||
| `tests/test_run_focus.py` | 399 | 47 | 44 | 0 | uncategorized | run_focus | security, filesystem, subprocess/script, ui/static |
|
||||
| `tests/test_endpoint_probing.py` | 411 | 34 | 30 | 6 | uncategorized | endpoint_probing | route/api, db/session, import-state |
|
||||
| `tests/test_llm_core_anthropic_temp_omit.py` | 94 | 32 | 6 | 0 | services | llm | db/session |
|
||||
| `tests/test_provider_detection.py` | 146 | 31 | 31 | 5 | services | provider | - |
|
||||
| `tests/test_model_context.py` | 251 | 30 | 30 | 4 | uncategorized | model_context | db/session, import-state |
|
||||
| `tests/test_endpoint_resolver.py` | 148 | 30 | 30 | 6 | uncategorized | endpoint_resolver | - |
|
||||
| `tests/test_embedding_lanes.py` | 1104 | 29 | 29 | 0 | services | embedding | filesystem |
|
||||
| `tests/test_upload_limits_centralized.py` | 110 | 29 | 5 | 0 | uncategorized | upload_limits_centralized | import-state, filesystem |
|
||||
| `tests/test_rename_user_owner_sync.py` | 686 | 26 | 26 | 0 | security | owner | route/api, db/session, import-state, filesystem, async/threading |
|
||||
| `tests/test_helpers_import_state.py` | 426 | 26 | 26 | 0 | helpers | helpers | route/api, db/session, import-state |
|
||||
| `tests/test_chat_helpers.py` | 220 | 26 | 13 | 0 | uncategorized | chat_helpers | route/api |
|
||||
| `tests/test_taxonomy.py` | 145 | 26 | 16 | 0 | uncategorized | taxonomy | security, ui/static |
|
||||
| `tests/test_llm_core_temperature.py` | 127 | 26 | 11 | 0 | services | llm | - |
|
||||
| `tests/test_review_regressions.py` | 902 | 25 | 25 | 0 | uncategorized | review_regressions | route/api, db/session, import-state, filesystem, async/threading |
|
||||
| `tests/test_tool_path_confinement.py` | 282 | 24 | 24 | 0 | security | confinement | import-state, filesystem, async/threading |
|
||||
| `tests/test_copilot.py` | 170 | 23 | 16 | 0 | uncategorized | copilot | - |
|
||||
| `tests/test_research_utils.py` | 97 | 23 | 23 | 2 | services | research | - |
|
||||
| `tests/test_api_chat_security.py` | 401 | 22 | 8 | 0 | security | security | route/api, db/session, import-state, filesystem, async/threading |
|
||||
| `tests/test_platform_compat.py` | 317 | 21 | 21 | 0 | uncategorized | platform_compat | import-state, filesystem, subprocess/script |
|
||||
| `tests/test_prompt_security.py` | 203 | 21 | 21 | 0 | security | security | - |
|
||||
| `tests/test_null_owner_gates.py` | 342 | 20 | 20 | 0 | security | owner | route/api, db/session, import-state |
|
||||
|
||||
## Top files by physical line count
|
||||
|
||||
| File | Lines | Collected tests | Test defs | Test classes | Area | Sub-area | Signals |
|
||||
|---|---:|---:|---:|---:|---|---|---|
|
||||
| `tests/test_model_routes.py` | 1662 | 133 | 110 | 10 | routes | routes | route/api, db/session, import-state, async/threading |
|
||||
| `tests/test_security_regressions.py` | 1203 | 91 | 67 | 0 | security | security | route/api, db/session, import-state, security, filesystem, async/threading, ui/static |
|
||||
| `tests/test_embedding_lanes.py` | 1104 | 29 | 29 | 0 | services | embedding | filesystem |
|
||||
| `tests/test_pr_blocker_audit.py` | 964 | 58 | 58 | 0 | uncategorized | pr_blocker_audit | import-state, security, filesystem |
|
||||
| `tests/test_review_regressions.py` | 902 | 25 | 25 | 0 | uncategorized | review_regressions | route/api, db/session, import-state, filesystem, async/threading |
|
||||
| `tests/test_cookbook_helpers.py` | 819 | 59 | 59 | 0 | services | cookbook | route/api, filesystem, subprocess/script, async/threading |
|
||||
| `tests/test_rename_user_owner_sync.py` | 686 | 26 | 26 | 0 | security | owner | route/api, db/session, import-state, filesystem, async/threading |
|
||||
| `tests/test_api_token_routes.py` | 504 | 14 | 14 | 0 | routes | api_routes | route/api, db/session, import-state, async/threading |
|
||||
| `tests/test_email_owner_scope.py` | 474 | 9 | 9 | 0 | security | owner_scope | route/api, db/session, filesystem, async/threading |
|
||||
| `tests/test_service_health.py` | 472 | 47 | 42 | 0 | uncategorized | service_health | async/threading |
|
||||
| `tests/test_kv_cache_invalidation_2927.py` | 463 | 8 | 8 | 0 | uncategorized | kv_cache_invalidation_2927 | route/api, db/session, import-state, async/threading |
|
||||
| `tests/test_shell_routes.py` | 460 | 62 | 47 | 8 | routes | routes | route/api, import-state, filesystem |
|
||||
| `tests/test_agent_loop.py` | 458 | 51 | 51 | 5 | uncategorized | agent_loop | db/session, import-state |
|
||||
| `tests/test_helpers_import_state.py` | 426 | 26 | 26 | 0 | helpers | helpers | route/api, db/session, import-state |
|
||||
| `tests/test_endpoint_owner_scope_followup.py` | 414 | 11 | 11 | 0 | security | owner_scope | route/api, db/session, filesystem |
|
||||
| `tests/test_endpoint_probing.py` | 411 | 34 | 30 | 6 | uncategorized | endpoint_probing | route/api, db/session, import-state |
|
||||
| `tests/test_imap_leak_fixes.py` | 404 | 15 | 15 | 0 | uncategorized | imap_leak_fixes | route/api, db/session, security, filesystem |
|
||||
| `tests/test_api_chat_security.py` | 401 | 22 | 8 | 0 | security | security | route/api, db/session, import-state, filesystem, async/threading |
|
||||
| `tests/test_upload_handler_atomicity.py` | 401 | 9 | 9 | 0 | uncategorized | upload_handler_atomicity | filesystem, async/threading |
|
||||
| `tests/test_run_focus.py` | 399 | 47 | 44 | 0 | uncategorized | run_focus | security, filesystem, subprocess/script, ui/static |
|
||||
| `tests/test_auth_regressions.py` | 375 | 15 | 15 | 0 | security | auth | route/api, db/session, import-state, async/threading |
|
||||
| `tests/test_companion_readonly.py` | 372 | 16 | 16 | 0 | uncategorized | companion_readonly | db/session, import-state |
|
||||
| `tests/test_calendar_owner_scope.py` | 344 | 7 | 7 | 0 | security | owner_scope | route/api, db/session, import-state, filesystem, async/threading, ui/static |
|
||||
| `tests/test_null_owner_gates.py` | 342 | 20 | 20 | 0 | security | owner | route/api, db/session, import-state |
|
||||
| `tests/test_calendar_recurrence.py` | 338 | 19 | 19 | 0 | services | calendar | - |
|
||||
| `tests/test_tool_policy.py` | 330 | 13 | 13 | 0 | uncategorized | tool_policy | import-state, async/threading |
|
||||
| `tests/test_workspace_confine.py` | 328 | 18 | 18 | 0 | uncategorized | workspace_confine | route/api, filesystem, subprocess/script, async/threading |
|
||||
| `tests/test_diffusion_server_security.py` | 325 | 14 | 14 | 0 | security | security | route/api, import-state, security, filesystem, async/threading, ui/static |
|
||||
| `tests/test_platform_compat.py` | 317 | 21 | 21 | 0 | uncategorized | platform_compat | import-state, filesystem, subprocess/script |
|
||||
| `tests/test_upload_routes_owner_scope.py` | 315 | 11 | 11 | 0 | security | owner_scope | route/api, filesystem, async/threading |
|
||||
|
||||
## Split planning candidates
|
||||
|
||||
This section is generated from metrics, not from manual judgement.
|
||||
Files are included when they meet at least one threshold:
|
||||
|
||||
- at least 300 physical lines; or
|
||||
- at least 20 collected pytest items.
|
||||
|
||||
These are planning candidates only. A later split PR still needs a focused manual review of each file before moving tests.
|
||||
|
||||
| File | Why included | Setup/risk signals | Suggested handling |
|
||||
|---|---|---|---|
|
||||
| `tests/test_model_routes.py` | 1662 lines, 133 collected tests | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_security_regressions.py` | 1203 lines, 91 collected tests | route/api, db/session, import-state, security, filesystem, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_provider_classification.py` | 67 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||
| `tests/test_shell_routes.py` | 460 lines, 62 collected tests | route/api, import-state, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_cookbook_helpers.py` | 819 lines, 59 collected tests | route/api, filesystem, subprocess/script, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_pr_blocker_audit.py` | 964 lines, 58 collected tests | import-state, security, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_provider_endpoints.py` | 58 collected tests | subprocess/script | Good first manual-review candidate if test themes are cohesive. |
|
||||
| `tests/test_agent_loop.py` | 458 lines, 51 collected tests | db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_service_health.py` | 472 lines, 47 collected tests | async/threading | Good first manual-review candidate if test themes are cohesive. |
|
||||
| `tests/test_run_focus.py` | 399 lines, 47 collected tests | security, filesystem, subprocess/script, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_endpoint_probing.py` | 411 lines, 34 collected tests | route/api, db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_llm_core_anthropic_temp_omit.py` | 32 collected tests | db/session | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_provider_detection.py` | 31 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||
| `tests/test_model_context.py` | 30 collected tests | db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_endpoint_resolver.py` | 30 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||
| `tests/test_embedding_lanes.py` | 1104 lines, 29 collected tests | filesystem | Good first manual-review candidate if test themes are cohesive. |
|
||||
| `tests/test_upload_limits_centralized.py` | 29 collected tests | import-state, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_rename_user_owner_sync.py` | 686 lines, 26 collected tests | route/api, db/session, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_helpers_import_state.py` | 426 lines, 26 collected tests | route/api, db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_chat_helpers.py` | 26 collected tests | route/api | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_taxonomy.py` | 26 collected tests | security, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_llm_core_temperature.py` | 26 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||
| `tests/test_review_regressions.py` | 902 lines, 25 collected tests | route/api, db/session, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_tool_path_confinement.py` | 24 collected tests | import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_copilot.py` | 23 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||
| `tests/test_research_utils.py` | 23 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||
| `tests/test_api_chat_security.py` | 401 lines, 22 collected tests | route/api, db/session, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_platform_compat.py` | 317 lines, 21 collected tests | import-state, filesystem, subprocess/script | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_prompt_security.py` | 21 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||
| `tests/test_null_owner_gates.py` | 342 lines, 20 collected tests | route/api, db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_tool_support_heuristic.py` | 20 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||
| `tests/test_calendar_recurrence.py` | 338 lines | No obvious setup signals from static scan. | Plan split boundaries before editing. |
|
||||
| `tests/test_workspace_confine.py` | 328 lines | route/api, filesystem, subprocess/script, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_companion_readonly.py` | 372 lines | db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_imap_leak_fixes.py` | 404 lines | route/api, db/session, security, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_auth_regressions.py` | 375 lines | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_api_token_routes.py` | 504 lines | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_diffusion_server_security.py` | 325 lines | route/api, import-state, security, filesystem, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_tool_policy.py` | 330 lines | import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_endpoint_owner_scope_followup.py` | 414 lines | route/api, db/session, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_upload_routes_owner_scope.py` | 315 lines | route/api, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_email_owner_scope.py` | 474 lines | route/api, db/session, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_upload_handler_atomicity.py` | 401 lines | filesystem, async/threading | Plan split boundaries before editing. |
|
||||
| `tests/test_kv_cache_invalidation_2927.py` | 463 lines | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_calendar_owner_scope.py` | 344 lines | route/api, db/session, import-state, filesystem, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
| `tests/test_skills_manager_owner_isolation.py` | 306 lines | import-state, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||
|
||||
## Taxonomy coverage gaps among split candidates
|
||||
|
||||
`uncategorized` is a current taxonomy area, not a builder failure.
|
||||
This plan does not reclassify tests because taxonomy changes should be reviewed separately from oversized-file split planning.
|
||||
|
||||
Before using any of these files as a split target, first decide whether the taxonomy should be refined in a separate focused issue/PR.
|
||||
|
||||
| File | Lines | Collected tests | Sub-area | Signals | Suggested follow-up |
|
||||
|---|---:|---:|---|---|---|
|
||||
| `tests/test_pr_blocker_audit.py` | 964 | 58 | pr_blocker_audit | import-state, security, filesystem | Review taxonomy and setup/risk boundaries before any split. |
|
||||
| `tests/test_agent_loop.py` | 458 | 51 | agent_loop | db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
||||
| `tests/test_service_health.py` | 472 | 47 | service_health | async/threading | Review taxonomy mapping before using as a split target. |
|
||||
| `tests/test_run_focus.py` | 399 | 47 | run_focus | security, filesystem, subprocess/script, ui/static | Review taxonomy and setup/risk boundaries before any split. |
|
||||
| `tests/test_endpoint_probing.py` | 411 | 34 | endpoint_probing | route/api, db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
||||
| `tests/test_model_context.py` | 251 | 30 | model_context | db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
||||
| `tests/test_endpoint_resolver.py` | 148 | 30 | endpoint_resolver | - | Review taxonomy mapping before using as a split target. |
|
||||
| `tests/test_upload_limits_centralized.py` | 110 | 29 | upload_limits_centralized | import-state, filesystem | Review taxonomy and setup/risk boundaries before any split. |
|
||||
| `tests/test_chat_helpers.py` | 220 | 26 | chat_helpers | route/api | Review taxonomy and setup/risk boundaries before any split. |
|
||||
| `tests/test_taxonomy.py` | 145 | 26 | taxonomy | security, ui/static | Review taxonomy and setup/risk boundaries before any split. |
|
||||
| `tests/test_review_regressions.py` | 902 | 25 | review_regressions | route/api, db/session, import-state, filesystem, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||
| `tests/test_copilot.py` | 170 | 23 | copilot | - | Review taxonomy mapping before using as a split target. |
|
||||
| `tests/test_platform_compat.py` | 317 | 21 | platform_compat | import-state, filesystem, subprocess/script | Review taxonomy and setup/risk boundaries before any split. |
|
||||
| `tests/test_tool_support_heuristic.py` | 154 | 20 | tool_support_heuristic | - | Review taxonomy mapping before using as a split target. |
|
||||
| `tests/test_workspace_confine.py` | 328 | 18 | workspace_confine | route/api, filesystem, subprocess/script, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||
| `tests/test_companion_readonly.py` | 372 | 16 | companion_readonly | db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
||||
| `tests/test_imap_leak_fixes.py` | 404 | 15 | imap_leak_fixes | route/api, db/session, security, filesystem | Review taxonomy and setup/risk boundaries before any split. |
|
||||
| `tests/test_tool_policy.py` | 330 | 13 | tool_policy | import-state, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||
| `tests/test_upload_handler_atomicity.py` | 401 | 9 | upload_handler_atomicity | filesystem, async/threading | Review taxonomy mapping before using as a split target. |
|
||||
| `tests/test_kv_cache_invalidation_2927.py` | 463 | 8 | kv_cache_invalidation_2927 | route/api, db/session, import-state, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||
|
||||
## Suggested first manual-review candidates
|
||||
|
||||
These are not automatic split approvals. They are categorized candidates with enough size/collection value and no route/API, DB/session, import-state, or security signal from the static scan.
|
||||
|
||||
Files still in the `uncategorized` taxonomy area are listed separately below so taxonomy review does not get mixed into the first split decision.
|
||||
|
||||
| File | Lines | Collected tests | Area | Sub-area | Signals | Why this is a candidate |
|
||||
|---|---:|---:|---|---|---|---|
|
||||
| `tests/test_provider_classification.py` | 188 | 67 | services | provider | - | 67 collected tests |
|
||||
| `tests/test_provider_endpoints.py` | 241 | 58 | services | provider | subprocess/script | 58 collected tests |
|
||||
| `tests/test_provider_detection.py` | 146 | 31 | services | provider | - | 31 collected tests |
|
||||
| `tests/test_embedding_lanes.py` | 1104 | 29 | services | embedding | filesystem | 1104 lines, 29 collected tests |
|
||||
| `tests/test_llm_core_temperature.py` | 127 | 26 | services | llm | - | 26 collected tests |
|
||||
| `tests/test_research_utils.py` | 97 | 23 | services | research | - | 23 collected tests |
|
||||
| `tests/test_prompt_security.py` | 203 | 21 | security | security | - | 21 collected tests |
|
||||
| `tests/test_calendar_recurrence.py` | 338 | 19 | services | calendar | - | 338 lines |
|
||||
|
||||
## High-risk candidates to defer first
|
||||
|
||||
These files may still be split later, but not as the first implementation slice without a separate manual boundary review.
|
||||
|
||||
| File | Lines | Collected tests | High-risk signals |
|
||||
|---|---:|---:|---|
|
||||
| `tests/test_model_routes.py` | 1662 | 133 | db/session, import-state, route/api |
|
||||
| `tests/test_security_regressions.py` | 1203 | 91 | db/session, import-state, route/api, security |
|
||||
| `tests/test_shell_routes.py` | 460 | 62 | import-state, route/api |
|
||||
| `tests/test_cookbook_helpers.py` | 819 | 59 | route/api |
|
||||
| `tests/test_pr_blocker_audit.py` | 964 | 58 | import-state, security |
|
||||
| `tests/test_agent_loop.py` | 458 | 51 | db/session, import-state |
|
||||
| `tests/test_run_focus.py` | 399 | 47 | security |
|
||||
| `tests/test_endpoint_probing.py` | 411 | 34 | db/session, import-state, route/api |
|
||||
| `tests/test_llm_core_anthropic_temp_omit.py` | 94 | 32 | db/session |
|
||||
| `tests/test_model_context.py` | 251 | 30 | db/session, import-state |
|
||||
| `tests/test_upload_limits_centralized.py` | 110 | 29 | import-state |
|
||||
| `tests/test_rename_user_owner_sync.py` | 686 | 26 | db/session, import-state, route/api |
|
||||
| `tests/test_helpers_import_state.py` | 426 | 26 | db/session, import-state, route/api |
|
||||
| `tests/test_chat_helpers.py` | 220 | 26 | route/api |
|
||||
| `tests/test_taxonomy.py` | 145 | 26 | security |
|
||||
|
||||
## Rules for future split PRs
|
||||
|
||||
- One file or one coherent file-family per PR.
|
||||
- No assertion rewrites mixed with file moves.
|
||||
- No helper extraction mixed with file moves.
|
||||
- No production code changes.
|
||||
- No CI workflow changes.
|
||||
- Preserve existing markers and taxonomy unless the split issue explicitly says otherwise.
|
||||
- Validate the original file's collected tests before and after the split.
|
||||
- Validate any neighboring taxonomy/focused-runner behavior if paths change.
|
||||
- Treat files with route/API, DB/session, import-state, or security signals as higher-risk until manually reviewed.
|
||||
|
||||
## Suggested next step
|
||||
|
||||
Use this plan to choose the first actual oversized-file split issue.
|
||||
The first split should prefer a file with high review value and low setup risk.
|
||||
Do not start a split PR from this planning issue alone if the file's boundaries are still ambiguous.
|
||||
|
||||
## Reproduction command
|
||||
|
||||
This document was generated with:
|
||||
|
||||
```bash
|
||||
.venv/bin/python tests/tools/build_oversized_test_split_plan.py
|
||||
```
|
||||
|
||||
## Freshness check
|
||||
|
||||
After editing the builder or rebasing the branch, regenerate the plan and confirm no unexpected plan drift:
|
||||
|
||||
```bash
|
||||
.venv/bin/python tests/tools/build_oversized_test_split_plan.py
|
||||
git diff --exit-code -- tests/OVERSIZED_TEST_SPLIT_PLAN.md
|
||||
```
|
||||
@@ -51,10 +51,11 @@ Every new or refactored test should be:
|
||||
|
||||
## Test taxonomy
|
||||
|
||||
Tests are classified by the categories below. Today the suite is flat under
|
||||
`tests/`; the **Target dir** column is the phased layout from #2523 that we move
|
||||
toward *after* helpers and determinism are stable. Until a category is moved,
|
||||
new tests in that category stay in flat `tests/` but should still follow this
|
||||
Tests are classified by the categories below. Today the suite is mostly flat
|
||||
under `tests/` (the current `area_cli` set has moved to `tests/cli/`); the
|
||||
**Target dir** column is the phased layout from #2523 that we move toward
|
||||
*after* helpers and determinism are stable. Until a category is moved, new
|
||||
tests in that category stay in flat `tests/` but should still follow this
|
||||
standard.
|
||||
|
||||
| Category | What it covers | Examples today | Target dir |
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""`odysseus-research list --status complete` must match completed runs.
|
||||
|
||||
Completed research runs are persisted with status "done" (research_handler),
|
||||
but the user-facing CLI value is the friendlier "complete". The CLI offered
|
||||
"complete" yet filtered `status != args.status`, so `--status complete` never
|
||||
matched any record. The fix keeps "complete" as the CLI value and maps it to
|
||||
the stored "done" at filter time, so the on-disk corpus stays the source of
|
||||
truth and the documented CLI surface keeps working.
|
||||
"""
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _load_cli():
|
||||
path = ROOT / "scripts" / "odysseus-research"
|
||||
loader = importlib.machinery.SourceFileLoader("odysseus_research_cli_status", str(path))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_complete_is_a_valid_status_choice():
|
||||
cli = _load_cli()
|
||||
parser = cli._build_parser()
|
||||
ns = parser.parse_args(["list", "--status", "complete"])
|
||||
assert ns.status == "complete"
|
||||
|
||||
|
||||
def test_filter_returns_completed_runs(tmp_path, monkeypatch):
|
||||
cli = _load_cli(); cli._DATA_DIR = tmp_path
|
||||
(tmp_path / "r1.json").write_text(json.dumps({"query": "q1", "status": "done"}))
|
||||
(tmp_path / "r2.json").write_text(json.dumps({"query": "q2", "status": "running"}))
|
||||
emitted = []
|
||||
monkeypatch.setattr(cli, "emit", lambda value, args: emitted.append(value))
|
||||
# CLI "complete" must map to the stored "done" and match r1.
|
||||
cli.cmd_list(SimpleNamespace(status="complete", limit=50))
|
||||
ids = [r["id"] for r in emitted[0]]
|
||||
assert ids == ["r1"] # only the completed run
|
||||
|
||||
|
||||
def test_verbatim_status_still_filters(tmp_path, monkeypatch):
|
||||
cli = _load_cli(); cli._DATA_DIR = tmp_path
|
||||
(tmp_path / "r1.json").write_text(json.dumps({"query": "q1", "status": "done"}))
|
||||
(tmp_path / "r2.json").write_text(json.dumps({"query": "q2", "status": "running"}))
|
||||
emitted = []
|
||||
monkeypatch.setattr(cli, "emit", lambda value, args: emitted.append(value))
|
||||
cli.cmd_list(SimpleNamespace(status="running", limit=50))
|
||||
ids = [r["id"] for r in emitted[0]]
|
||||
assert ids == ["r2"] # verbatim choices pass through unchanged
|
||||
+1
-1
@@ -21,7 +21,7 @@ import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _load_cli():
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Tool-output display truncation uses _truncate with an indicator.
|
||||
|
||||
Previously agent_loop sliced tool output to a hard character limit ([:2000]
|
||||
or [:4000]) with no signal to the UI that data was lost. Now it delegates to
|
||||
tool_utils._truncate which caps at MAX_OUTPUT_CHARS (10 000) and appends
|
||||
a ``... (truncated, N chars total)`` suffix so the frontend can show a
|
||||
truncation indicator in the tool bubble.
|
||||
"""
|
||||
from src.tool_utils import _truncate, MAX_OUTPUT_CHARS
|
||||
|
||||
|
||||
def test_short_output_unchanged():
|
||||
"""Outputs within the limit pass through verbatim."""
|
||||
text = "hello world"
|
||||
assert _truncate(text) == text
|
||||
|
||||
|
||||
def test_long_output_truncated_with_indicator():
|
||||
"""Outputs exceeding MAX_OUTPUT_CHARS are truncated with a suffix."""
|
||||
text = "x" * (MAX_OUTPUT_CHARS + 500)
|
||||
result = _truncate(text)
|
||||
assert len(result) > MAX_OUTPUT_CHARS # includes suffix
|
||||
assert result.startswith("x" * MAX_OUTPUT_CHARS)
|
||||
assert "truncated" in result
|
||||
assert str(len(text)) in result # original length reported
|
||||
|
||||
|
||||
def test_exact_limit_unchanged():
|
||||
"""An output exactly at the limit is not truncated."""
|
||||
text = "a" * MAX_OUTPUT_CHARS
|
||||
assert _truncate(text) == text
|
||||
|
||||
|
||||
def test_default_limit_matches_constant():
|
||||
"""_truncate default limit equals MAX_OUTPUT_CHARS (10 000)."""
|
||||
assert MAX_OUTPUT_CHARS == 10_000
|
||||
text = "y" * 10_001
|
||||
result = _truncate(text)
|
||||
assert "truncated" in result
|
||||
|
||||
|
||||
def test_empty_string():
|
||||
assert _truncate("") == ""
|
||||
@@ -33,3 +33,19 @@ def test_api_key_manager_load_resilience(tmp_path):
|
||||
assert loaded["good_provider"] == "good_value"
|
||||
assert "bad_provider" not in loaded
|
||||
assert "garbage_provider" not in loaded
|
||||
|
||||
|
||||
def test_load_ignores_non_string_raw_values(tmp_path):
|
||||
mgr = APIKeyManager(str(tmp_path))
|
||||
|
||||
mgr.save("openai", "sk-openai")
|
||||
with open(mgr.api_keys_file, "r", encoding="utf-8") as f:
|
||||
keys = json.load(f)
|
||||
|
||||
keys["missing_provider"] = None
|
||||
keys["numeric_provider"] = 42
|
||||
keys["object_provider"] = {"encrypted": keys["openai"]}
|
||||
with open(mgr.api_keys_file, "w", encoding="utf-8") as f:
|
||||
json.dump(keys, f)
|
||||
|
||||
assert mgr.load() == {"openai": "sk-openai"}
|
||||
|
||||
@@ -287,8 +287,9 @@ def test_delete_token_deletes_and_invalidates_cache(monkeypatch, token_routes_mo
|
||||
monkeypatch.setattr(mod, "get_current_user", lambda req: req.state.current_user)
|
||||
monkeypatch.setattr(mod, "ApiToken", MagicMock())
|
||||
|
||||
fake_token = SimpleNamespace(id="abcd1234", owner="alice", name="test")
|
||||
fake_session = MagicMock()
|
||||
fake_session.query.return_value.filter.return_value.delete.return_value = 1
|
||||
fake_session.query.return_value.filter.return_value.first.return_value = fake_token
|
||||
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
|
||||
|
||||
invalidator = MagicMock()
|
||||
@@ -297,6 +298,7 @@ def test_delete_token_deletes_and_invalidates_cache(monkeypatch, token_routes_mo
|
||||
resp = delete_token(request=req, token_id="abcd1234")
|
||||
|
||||
assert resp == {"status": "deleted"}
|
||||
fake_session.delete.assert_called_once_with(fake_token)
|
||||
invalidator.assert_called_once()
|
||||
|
||||
|
||||
@@ -312,7 +314,7 @@ def test_delete_missing_token_returns_404_without_invalidating_cache(monkeypatch
|
||||
monkeypatch.setattr(mod, "ApiToken", MagicMock())
|
||||
|
||||
fake_session = MagicMock()
|
||||
fake_session.query.return_value.filter.return_value.delete.return_value = 0
|
||||
fake_session.query.return_value.filter.return_value.first.return_value = None
|
||||
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
|
||||
|
||||
invalidator = MagicMock()
|
||||
@@ -404,3 +406,99 @@ def test_update_missing_token_returns_404(monkeypatch, token_routes_mod):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(update_token(request=req, token_id="missing99"))
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Owner check — update/delete reject a different admin's token with 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bob_patch_request(invalidator, body):
|
||||
"""An admin request from bob whose async .json() yields `body`."""
|
||||
req = _req("bob", is_admin=True, invalidator=invalidator)
|
||||
|
||||
async def _json():
|
||||
return body
|
||||
|
||||
req.json = _json
|
||||
return req
|
||||
|
||||
|
||||
def test_update_token_rejects_non_owner(monkeypatch, token_routes_mod):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
mod = token_routes_mod
|
||||
monkeypatch.setattr(mod, "get_current_user", lambda req: req.state.current_user)
|
||||
|
||||
token = SimpleNamespace(
|
||||
id="tok123", name="alice-token", owner="alice",
|
||||
token_prefix="ody_alic", scopes="chat", is_active=True,
|
||||
)
|
||||
fake_session = MagicMock()
|
||||
fake_session.query.return_value.filter.return_value.first.return_value = token
|
||||
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
|
||||
|
||||
req = _bob_patch_request(MagicMock(), {"name": "hijacked"})
|
||||
update_token = _get_handler(mod, "PATCH", "/tokens/{token_id}")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(update_token(request=req, token_id="tok123"))
|
||||
assert exc.value.status_code == 403
|
||||
assert token.name == "alice-token"
|
||||
|
||||
|
||||
def test_delete_token_rejects_non_owner(monkeypatch, token_routes_mod):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
mod = token_routes_mod
|
||||
monkeypatch.setattr(mod, "get_current_user", lambda req: req.state.current_user)
|
||||
monkeypatch.setattr(mod, "ApiToken", MagicMock())
|
||||
|
||||
fake_token = SimpleNamespace(id="tok123", owner="alice", name="alice-token")
|
||||
fake_session = MagicMock()
|
||||
fake_session.query.return_value.filter.return_value.first.return_value = fake_token
|
||||
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
|
||||
|
||||
invalidator = MagicMock()
|
||||
req = _req("bob", is_admin=True, invalidator=invalidator)
|
||||
delete_token = _get_handler(mod, "DELETE", "/tokens/{token_id}")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
delete_token(request=req, token_id="tok123")
|
||||
assert exc.value.status_code == 403
|
||||
fake_session.delete.assert_not_called()
|
||||
invalidator.assert_not_called()
|
||||
|
||||
|
||||
def test_update_token_owner_check_skipped_when_auth_disabled(monkeypatch, token_routes_mod):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
mod = token_routes_mod
|
||||
monkeypatch.setattr(mod, "get_current_user", lambda req: None)
|
||||
|
||||
token = SimpleNamespace(
|
||||
id="tok123", name="original", owner="alice",
|
||||
token_prefix="ody_alic", scopes="chat", is_active=True,
|
||||
)
|
||||
fake_session = MagicMock()
|
||||
fake_session.query.return_value.filter.return_value.first.return_value = token
|
||||
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
|
||||
|
||||
req = _bob_patch_request(MagicMock(), {"name": "renamed-in-single-user"})
|
||||
update_token = _get_handler(mod, "PATCH", "/tokens/{token_id}")
|
||||
resp = asyncio.run(update_token(request=req, token_id="tok123"))
|
||||
assert resp["name"] == "renamed-in-single-user"
|
||||
|
||||
|
||||
def test_delete_token_owner_check_skipped_when_auth_disabled(monkeypatch, token_routes_mod):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
mod = token_routes_mod
|
||||
monkeypatch.setattr(mod, "get_current_user", lambda req: None)
|
||||
monkeypatch.setattr(mod, "ApiToken", MagicMock())
|
||||
|
||||
fake_token = SimpleNamespace(id="tok123", owner="alice", name="alice-token")
|
||||
fake_session = MagicMock()
|
||||
fake_session.query.return_value.filter.return_value.first.return_value = fake_token
|
||||
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
|
||||
|
||||
invalidator = MagicMock()
|
||||
req = _req("", is_admin=True, invalidator=invalidator)
|
||||
delete_token = _get_handler(mod, "DELETE", "/tokens/{token_id}")
|
||||
resp = delete_token(request=req, token_id="tok123")
|
||||
assert resp == {"status": "deleted"}
|
||||
fake_session.delete.assert_called_once_with(fake_token)
|
||||
|
||||
@@ -106,6 +106,9 @@ async def test_learn_sender_signatures_resolves_llm_for_task_owner(monkeypatch):
|
||||
from src.builtin_actions import action_learn_sender_signatures
|
||||
|
||||
class FakeImap:
|
||||
def __init__(self, owner=""):
|
||||
self.owner = owner
|
||||
|
||||
def select(self, *_args, **_kwargs):
|
||||
return "OK", []
|
||||
|
||||
@@ -119,13 +122,20 @@ async def test_learn_sender_signatures_resolves_llm_for_task_owner(monkeypatch):
|
||||
return None
|
||||
|
||||
calls, _fallback_calls = _resolver_spy(monkeypatch, utility_result=("", "", {}), default_result=("", "", {}))
|
||||
monkeypatch.setattr(email_helpers, "_imap_connect", lambda _account_id=None: FakeImap())
|
||||
imap_owners = []
|
||||
|
||||
def fake_imap_connect(_account_id=None, owner=""):
|
||||
imap_owners.append(owner)
|
||||
return FakeImap(owner)
|
||||
|
||||
monkeypatch.setattr(email_helpers, "_imap_connect", fake_imap_connect)
|
||||
|
||||
message, ok = await action_learn_sender_signatures("alice")
|
||||
|
||||
assert ok is False
|
||||
assert message == "No LLM endpoint available"
|
||||
assert calls == [("utility", "alice"), ("default", "alice")]
|
||||
assert imap_owners == ["alice"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""llama.cpp slot-affinity fields must never reach cloud providers (#3793).
|
||||
|
||||
_apply_local_cache_affinity adds session_id + cache_prompt to outgoing
|
||||
payloads for KV-cache slot affinity (#2927). The old gate treated any unknown
|
||||
OpenAI-compatible host as self-hosted, so strict cloud APIs added as custom
|
||||
endpoints (Mistral at api.mistral.ai) received the extra fields and rejected
|
||||
every request with 422 extra_forbidden. Self-hosted now also requires the
|
||||
endpoint to resolve as local: loopback/private/tailscale host, or endpoint
|
||||
kind explicitly configured as "local".
|
||||
"""
|
||||
import pytest
|
||||
|
||||
import src.llm_core as llm_core
|
||||
import src.model_context as model_context
|
||||
|
||||
|
||||
def _affinity_fields(url, monkeypatch, kind=None):
|
||||
monkeypatch.setattr(model_context, "_configured_endpoint_kind", lambda _u: kind)
|
||||
payload = {}
|
||||
llm_core._apply_local_cache_affinity(payload, url, "sess-123")
|
||||
return payload
|
||||
|
||||
|
||||
def test_mistral_cloud_api_gets_no_affinity_fields(monkeypatch):
|
||||
# The #3793 repro: Mistral rejects unknown body fields with 422.
|
||||
payload = _affinity_fields("https://api.mistral.ai/v1", monkeypatch)
|
||||
assert payload == {}
|
||||
|
||||
|
||||
def test_openai_api_gets_no_affinity_fields(monkeypatch):
|
||||
payload = _affinity_fields("https://api.openai.com/v1", monkeypatch)
|
||||
assert payload == {}
|
||||
|
||||
|
||||
def test_unknown_public_host_gets_no_affinity_fields(monkeypatch):
|
||||
# Any strict cloud provider added as a custom endpoint, not just Mistral.
|
||||
payload = _affinity_fields("https://llm.example-cloud.com/v1", monkeypatch)
|
||||
assert payload == {}
|
||||
|
||||
|
||||
def test_localhost_server_gets_affinity_fields(monkeypatch):
|
||||
payload = _affinity_fields("http://localhost:8080/v1", monkeypatch)
|
||||
assert payload == {"session_id": "sess-123", "cache_prompt": True}
|
||||
|
||||
|
||||
def test_private_lan_server_gets_affinity_fields(monkeypatch):
|
||||
payload = _affinity_fields("http://192.168.1.50:8000/v1", monkeypatch)
|
||||
assert payload == {"session_id": "sess-123", "cache_prompt": True}
|
||||
|
||||
|
||||
def test_public_host_with_local_kind_override_gets_affinity_fields(monkeypatch):
|
||||
# Escape hatch: a self-hosted llama.cpp exposed via a tunnel keeps the
|
||||
# slot-affinity hint when its endpoint kind is configured as "local".
|
||||
payload = _affinity_fields("https://my-llama.example.com/v1", monkeypatch, kind="local")
|
||||
assert payload == {"session_id": "sess-123", "cache_prompt": True}
|
||||
|
||||
|
||||
def test_no_session_id_is_a_noop(monkeypatch):
|
||||
monkeypatch.setattr(model_context, "_configured_endpoint_kind", lambda _u: None)
|
||||
payload = {}
|
||||
llm_core._apply_local_cache_affinity(payload, "http://localhost:8080/v1", None)
|
||||
assert payload == {}
|
||||
|
||||
|
||||
# Cloud-host sweep absorbed from #3839 (credit: Shabablinchikow) - every cloud
|
||||
# API that falls through provider detection to the OpenAI-compatible default
|
||||
# must stay clean, not just the Mistral host from the original report.
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://api.mistral.ai/v1/chat/completions",
|
||||
"https://api.deepseek.com/v1/chat/completions",
|
||||
"https://api.x.ai/v1/chat/completions",
|
||||
"https://api.together.xyz/v1/chat/completions",
|
||||
"https://api.fireworks.ai/inference/v1/chat/completions",
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
||||
])
|
||||
def test_cloud_openai_compatible_hosts_get_no_affinity_fields(monkeypatch, url):
|
||||
assert _affinity_fields(url, monkeypatch) == {}
|
||||
|
||||
|
||||
# Tailscale CGNAT boundaries (review finding on #3945): only 100.64.0.0/10 is
|
||||
# Tailscale; the rest of 100.0.0.0/8 contains public ranges, and a strict
|
||||
# provider addressed by one must not receive the llama.cpp extras.
|
||||
def test_host_just_below_cgnat_gets_no_affinity_fields(monkeypatch):
|
||||
assert _affinity_fields("http://100.63.255.255/v1", monkeypatch) == {}
|
||||
|
||||
|
||||
def test_host_just_above_cgnat_gets_no_affinity_fields(monkeypatch):
|
||||
assert _affinity_fields("http://100.128.0.1/v1", monkeypatch) == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["100.64.0.1", "100.100.50.2", "100.127.255.254"])
|
||||
def test_hosts_inside_cgnat_get_affinity_fields(monkeypatch, host):
|
||||
payload = _affinity_fields(f"http://{host}:8080/v1", monkeypatch)
|
||||
assert payload == {"session_id": "sess-123", "cache_prompt": True}
|
||||
@@ -1,50 +1,227 @@
|
||||
"""Issue #3229 — allow_bash / allow_web_search must work for JSON API callers
|
||||
and admin users must get bash enabled by default.
|
||||
|
||||
Bug: allow_bash and allow_web_search were only read from form_data, so JSON
|
||||
API callers (Content-Type: application/json) always had bash disabled.
|
||||
|
||||
Fix: (1) Read from JSON body as fallback.
|
||||
(2) Only add bash/web_search to disabled_tools when explicitly set to a
|
||||
falsy value; when unset (None), defer to per-user privilege checks.
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
CHAT_ROUTES = Path(__file__).resolve().parents[1] / "routes" / "chat_routes.py"
|
||||
_CHAT_ROUTES = Path(__file__).resolve().parent.parent / "routes" / "chat_routes.py"
|
||||
|
||||
|
||||
def _source() -> str:
|
||||
return CHAT_ROUTES.read_text(encoding="utf-8")
|
||||
# ── Source-level guards ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_research_fast_path_respects_tool_policy():
|
||||
src = _source()
|
||||
assert "pre_context_tool_policy = build_effective_tool_policy(" in src
|
||||
assert "allow_tool_preprocessing = not pre_context_tool_policy.block_all_tool_calls" in src
|
||||
assert "allow_tool_preprocessing=allow_tool_preprocessing" in src
|
||||
assert "research_blocked_by_policy = bool(" in src
|
||||
assert 'tool_policy.blocks("trigger_research")' in src
|
||||
assert 'tool_policy.blocks("manage_research")' in src
|
||||
assert 'effective_do_research = bool(' in src
|
||||
assert 'if effective_do_research:' in src
|
||||
assert '"is_research": effective_do_research' in src
|
||||
assert "_effective_mode = 'research' if effective_do_research else (chat_mode or 'chat')" in src
|
||||
assert '_model_suffix = "Research" if effective_do_research else None' in src
|
||||
assert "do_research=effective_do_research" in src
|
||||
def test_allow_bash_reads_from_body_as_fallback():
|
||||
"""chat_stream must read allow_bash from the JSON body, not just form_data."""
|
||||
source = _CHAT_ROUTES.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
|
||||
# Find the chat_stream function
|
||||
chat_stream_func = None
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.AsyncFunctionDef) and node.name == "chat_stream":
|
||||
chat_stream_func = node
|
||||
break
|
||||
assert chat_stream_func is not None, "chat_stream function not found"
|
||||
|
||||
# Look for an assignment to allow_bash that references 'body'
|
||||
found_body_fallback = False
|
||||
for node in ast.walk(chat_stream_func):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == "allow_bash":
|
||||
# Check if 'body' appears in the value
|
||||
src_segment = ast.get_source_segment(source, node)
|
||||
if src_segment and "body" in src_segment:
|
||||
found_body_fallback = True
|
||||
assert found_body_fallback, (
|
||||
"allow_bash assignment in chat_stream must fall back to JSON body"
|
||||
)
|
||||
|
||||
|
||||
def test_non_streaming_chat_path_uses_tool_policy_before_context_and_research():
|
||||
src = _source()
|
||||
chat_endpoint = src[src.index("async def chat_endpoint"):src.index("# ------------------------------------------------------------------ #", src.index("async def chat_endpoint"))]
|
||||
assert "tool_policy = build_effective_tool_policy(last_user_message=message)" in chat_endpoint
|
||||
assert "allow_tool_preprocessing = not tool_policy.block_all_tool_calls" in chat_endpoint
|
||||
assert 'if not tool_policy.blocks("manage_memory"):' in chat_endpoint
|
||||
assert "allow_tool_preprocessing=allow_tool_preprocessing" in chat_endpoint
|
||||
assert 'tool_policy.blocks("trigger_research")' in chat_endpoint
|
||||
assert "if use_research and not research_blocked_by_policy:" in chat_endpoint
|
||||
assert "allow_background_extraction=not tool_policy.block_all_tool_calls" in chat_endpoint
|
||||
def test_allow_web_search_reads_from_body_as_fallback():
|
||||
"""chat_stream must read allow_web_search from the JSON body, not just form_data."""
|
||||
source = _CHAT_ROUTES.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
|
||||
chat_stream_func = None
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.AsyncFunctionDef) and node.name == "chat_stream":
|
||||
chat_stream_func = node
|
||||
break
|
||||
assert chat_stream_func is not None
|
||||
|
||||
found_body_fallback = False
|
||||
for node in ast.walk(chat_stream_func):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == "allow_web_search":
|
||||
src_segment = ast.get_source_segment(source, node)
|
||||
if src_segment and "body" in src_segment:
|
||||
found_body_fallback = True
|
||||
assert found_body_fallback, (
|
||||
"allow_web_search assignment in chat_stream must fall back to JSON body"
|
||||
)
|
||||
|
||||
|
||||
def test_image_generation_fast_path_checks_policy_before_tool_start():
|
||||
src = _source()
|
||||
policy_gate = src.index('if tool_policy.blocks("generate_image"):')
|
||||
tool_start = src.index('"type": "tool_start", "tool": "generate_image"')
|
||||
generator_call = src.index("do_generate_image(")
|
||||
assert policy_gate < tool_start
|
||||
assert policy_gate < generator_call
|
||||
def test_disabled_tools_does_not_bash_when_allow_bash_is_none():
|
||||
"""When allow_bash is not set (None), bash must NOT be unconditionally
|
||||
added to disabled_tools. The per-user privilege check handles it.
|
||||
"""
|
||||
source = _CHAT_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
# The fix changes:
|
||||
# if str(allow_bash).lower() != "true":
|
||||
# to:
|
||||
# if allow_bash is not None and str(allow_bash).lower() != "true":
|
||||
assert "allow_bash is not None" in source, (
|
||||
"disabled_tools check must guard against allow_bash being None"
|
||||
)
|
||||
assert "allow_web_search is not None" in source, (
|
||||
"disabled_tools check must guard against allow_web_search being None"
|
||||
)
|
||||
|
||||
|
||||
def test_streaming_chat_paths_disable_background_extraction_under_policy():
|
||||
src = _source()
|
||||
assert src.count("allow_background_extraction=not tool_policy.block_all_tool_calls") >= 3
|
||||
# ── Functional tests of the disabled-tools logic ───────────────
|
||||
|
||||
|
||||
def _build_disabled_tools(
|
||||
allow_bash=None,
|
||||
allow_web_search=None,
|
||||
can_use_bash=True,
|
||||
can_use_browser=True,
|
||||
):
|
||||
"""Replicate the disabled-tools logic from chat_stream for unit testing.
|
||||
|
||||
Returns the set of tool names that would be disabled.
|
||||
"""
|
||||
disabled_tools = set()
|
||||
|
||||
# Issue #3229 fix: only disable when explicitly set to a falsy value.
|
||||
if allow_bash is not None and str(allow_bash).lower() != "true":
|
||||
disabled_tools.add("bash")
|
||||
if allow_web_search is not None and str(allow_web_search).lower() != "true":
|
||||
disabled_tools.add("web_search")
|
||||
disabled_tools.add("web_fetch")
|
||||
|
||||
# Enforce per-user privileges
|
||||
if not can_use_bash:
|
||||
disabled_tools.update({"bash", "python", "read_file", "write_file"})
|
||||
if not can_use_browser:
|
||||
disabled_tools.add("builtin_browser")
|
||||
|
||||
return disabled_tools
|
||||
|
||||
|
||||
def test_json_body_allow_bash_true_enables_bash():
|
||||
"""API caller sending {"allow_bash": true} gets bash enabled."""
|
||||
disabled = _build_disabled_tools(allow_bash="true")
|
||||
assert "bash" not in disabled
|
||||
|
||||
|
||||
def test_json_body_allow_bash_false_disables_bash():
|
||||
"""API caller sending {"allow_bash": false} gets bash disabled."""
|
||||
disabled = _build_disabled_tools(allow_bash="false")
|
||||
assert "bash" in disabled
|
||||
|
||||
|
||||
def test_json_body_allow_web_search_true_enables_web():
|
||||
"""API caller sending {"allow_web_search": true} gets web tools enabled."""
|
||||
disabled = _build_disabled_tools(allow_web_search="true")
|
||||
assert "web_search" not in disabled
|
||||
assert "web_fetch" not in disabled
|
||||
|
||||
|
||||
def test_json_body_allow_web_search_false_disables_web():
|
||||
"""API caller sending {"allow_web_search": false} gets web tools disabled."""
|
||||
disabled = _build_disabled_tools(allow_web_search="false")
|
||||
assert "web_search" in disabled
|
||||
assert "web_fetch" in disabled
|
||||
|
||||
|
||||
def test_admin_user_gets_bash_enabled_by_default():
|
||||
"""When allow_bash is not set and user has can_use_bash privilege,
|
||||
bash must NOT be disabled.
|
||||
"""
|
||||
disabled = _build_disabled_tools(allow_bash=None, can_use_bash=True)
|
||||
assert "bash" not in disabled
|
||||
|
||||
|
||||
def test_admin_user_gets_web_search_enabled_by_default():
|
||||
"""When allow_web_search is not set and user has normal privileges,
|
||||
web_search must NOT be disabled.
|
||||
"""
|
||||
disabled = _build_disabled_tools(allow_web_search=None)
|
||||
assert "web_search" not in disabled
|
||||
assert "web_fetch" not in disabled
|
||||
|
||||
|
||||
def test_non_privileged_user_without_explicit_flag_still_disabled():
|
||||
"""A user without can_use_bash privilege who doesn't send allow_bash
|
||||
should still have bash disabled via the privilege check.
|
||||
"""
|
||||
disabled = _build_disabled_tools(allow_bash=None, can_use_bash=False)
|
||||
assert "bash" in disabled
|
||||
|
||||
|
||||
def test_non_privileged_user_explicit_true_overridden_by_privilege():
|
||||
"""Even if allow_bash=true is sent, a user without can_use_bash
|
||||
privilege still gets bash disabled by the privilege gate.
|
||||
"""
|
||||
disabled = _build_disabled_tools(allow_bash="true", can_use_bash=False)
|
||||
assert "bash" in disabled
|
||||
|
||||
|
||||
def test_form_data_none_body_true_works():
|
||||
"""Simulates: form_data has no allow_bash, body has allow_bash=true.
|
||||
After the fallback (`form_data.get(...) or body.get(...)`), allow_bash
|
||||
should be "true".
|
||||
"""
|
||||
# Simulate the fallback logic
|
||||
form_data_val = None # not in form_data
|
||||
body_val = "true" # from JSON body
|
||||
allow_bash = form_data_val or body_val
|
||||
assert str(allow_bash).lower() == "true"
|
||||
|
||||
disabled = _build_disabled_tools(allow_bash=allow_bash)
|
||||
assert "bash" not in disabled
|
||||
|
||||
|
||||
def test_explicit_false_disables_even_for_admin():
|
||||
"""An admin who explicitly sends allow_bash=false should have bash disabled."""
|
||||
disabled = _build_disabled_tools(
|
||||
allow_bash="false", can_use_bash=True,
|
||||
)
|
||||
assert "bash" in disabled
|
||||
|
||||
|
||||
# ── Frontend source-level guards ──────────────────────────────
|
||||
|
||||
_CHAT_JS = Path(__file__).resolve().parent.parent / "static" / "js" / "chat.js"
|
||||
|
||||
|
||||
def test_frontend_always_sends_explicit_allow_bash():
|
||||
"""chat.js must always send allow_bash (both true and false), not only on toggle ON."""
|
||||
source = _CHAT_JS.read_text(encoding="utf-8")
|
||||
# Must not only append 'true' — must also handle the false case
|
||||
assert "allow_bash', el('bash-toggle').checked ? 'true' : 'false'" in source or \
|
||||
"allow_bash', 'false'" in source, (
|
||||
"Frontend must send explicit allow_bash=false when toggle is off"
|
||||
)
|
||||
|
||||
|
||||
def test_frontend_sends_explicit_allow_web_search_false_in_agent_mode():
|
||||
"""chat.js must send allow_web_search=false when web toggle is off in agent mode."""
|
||||
source = _CHAT_JS.read_text(encoding="utf-8")
|
||||
assert "allow_web_search', 'false'" in source, (
|
||||
"Frontend must send explicit allow_web_search=false in agent mode when toggle is off"
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import src.model_context as mc
|
||||
|
||||
def _setup(monkeypatch, windows):
|
||||
"""windows: {endpoint_url: context_length}. Force the remote path."""
|
||||
monkeypatch.setattr(mc, "_is_local_endpoint", lambda url: False)
|
||||
monkeypatch.setattr(mc, "is_local_endpoint", lambda url: False)
|
||||
monkeypatch.setattr(mc, "_configured_endpoint_kind", lambda url: "api")
|
||||
monkeypatch.setattr(mc, "_query_context_length", lambda url, model: windows[url])
|
||||
mc._context_cache.clear()
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DIAGNOSIS_JS = ROOT / "static" / "js" / "cookbook-diagnosis.js"
|
||||
|
||||
|
||||
def test_repair_kernels_pip_spec_is_shell_quoted():
|
||||
source = DIAGNOSIS_JS.read_text(encoding="utf-8")
|
||||
|
||||
assert '"kernels<0.15"' in source
|
||||
assert " --break-system-packages kernels<0.15" not in source
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Behavioral guard for the cookbook error output-tail expansion.
|
||||
|
||||
When a task reaches status "error" the status endpoint previously returned
|
||||
only the last 12 lines of the subprocess log. The "Copy last 50 lines"
|
||||
context-menu action was therefore copying the same 12 lines — useless for
|
||||
diagnosing failures that emit long stack traces or build output.
|
||||
|
||||
`error_aware_output_tail` now returns the last 50 lines on error and keeps
|
||||
the cheaper 12-line tail for running/other tasks.
|
||||
"""
|
||||
from routes.cookbook_output import error_aware_output_tail
|
||||
|
||||
|
||||
def _snapshot(n):
|
||||
return "\n".join(f"line {i}" for i in range(n))
|
||||
|
||||
|
||||
def test_error_status_returns_last_50_lines():
|
||||
snap = _snapshot(200)
|
||||
tail = error_aware_output_tail(snap, "error")
|
||||
lines = tail.splitlines()
|
||||
assert len(lines) == 50, f"error tail should be 50 lines, got {len(lines)}"
|
||||
assert lines[0] == "line 150"
|
||||
assert lines[-1] == "line 199"
|
||||
|
||||
|
||||
def test_non_error_status_returns_last_12_lines():
|
||||
snap = _snapshot(200)
|
||||
for status in ("running", "ready", "completed", "stopped", "unknown"):
|
||||
tail = error_aware_output_tail(snap, status)
|
||||
lines = tail.splitlines()
|
||||
assert len(lines) == 12, f"{status} tail should be 12 lines, got {len(lines)}"
|
||||
assert lines[-1] == "line 199"
|
||||
|
||||
|
||||
def test_short_snapshot_returns_all_lines():
|
||||
# Fewer lines than the cap — return everything, no padding.
|
||||
snap = _snapshot(5)
|
||||
assert error_aware_output_tail(snap, "error").splitlines() == [
|
||||
"line 0", "line 1", "line 2", "line 3", "line 4",
|
||||
]
|
||||
assert len(error_aware_output_tail(snap, "running").splitlines()) == 5
|
||||
|
||||
|
||||
def test_empty_snapshot_returns_empty_string():
|
||||
assert error_aware_output_tail("", "error") == ""
|
||||
assert error_aware_output_tail("", "running") == ""
|
||||
|
||||
|
||||
def test_error_tail_is_wider_than_non_error():
|
||||
snap = _snapshot(100)
|
||||
err = error_aware_output_tail(snap, "error").splitlines()
|
||||
run = error_aware_output_tail(snap, "running").splitlines()
|
||||
assert len(err) > len(run)
|
||||
# The non-error tail is a strict suffix of the error tail.
|
||||
assert err[-len(run):] == run
|
||||
@@ -26,7 +26,6 @@ from routes.cookbook_helpers import (
|
||||
_validate_repo_id,
|
||||
_validate_serve_cmd,
|
||||
_validate_serve_model_id,
|
||||
_validate_ssh_port,
|
||||
_shell_path,
|
||||
run_ssh_command_async,
|
||||
)
|
||||
@@ -106,12 +105,6 @@ def test_safe_env_prefix_accepts_powershell_activation_path():
|
||||
)
|
||||
|
||||
|
||||
def test_validate_ssh_port_rejects_shell_payload():
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_ssh_port("22; touch /tmp/pwned")
|
||||
assert _validate_ssh_port("2222") == "2222"
|
||||
|
||||
|
||||
def test_validate_local_dir_accepts_external_drive_paths_with_spaces():
|
||||
path = "/Volumes/T7 2TB/AI Models/llamacpp"
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Cookbook HF token persistence and lookup."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from routes.cookbook_helpers import load_stored_hf_token
|
||||
from src.secret_storage import encrypt
|
||||
|
||||
|
||||
def test_load_stored_hf_token_reads_encrypted_state(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
state_path = tmp_path / "cookbook_state.json"
|
||||
state_path.write_text(
|
||||
json.dumps({"env": {"hfToken": encrypt("hf_test_token_12345")}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert load_stored_hf_token() == "hf_test_token_12345"
|
||||
assert load_stored_hf_token(state_path=state_path) == "hf_test_token_12345"
|
||||
|
||||
|
||||
def test_load_stored_hf_token_falls_back_to_env_when_state_missing(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("HF_TOKEN", "hf_from_env")
|
||||
assert load_stored_hf_token() == "hf_from_env"
|
||||
|
||||
|
||||
def test_load_stored_hf_token_prefers_state_over_env(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("HF_TOKEN", "hf_from_env")
|
||||
state_path = tmp_path / "cookbook_state.json"
|
||||
state_path.write_text(
|
||||
json.dumps({"env": {"hfToken": encrypt("hf_from_state")}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert load_stored_hf_token() == "hf_from_state"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user