Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d30e2436ab | |||
| 4cacd8f64e | |||
| 4fa20e05c2 |
47 changed files with 427 additions and 996 deletions
|
|
@ -8,6 +8,6 @@ Run the demo seeding script to populate today with one tagged sample artifact pe
|
||||||
python scripts/seed_demo_artifacts.py
|
python scripts/seed_demo_artifacts.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires the Daybook dev server to be running on the project's fixed local port: `python manage.py runserver 127.0.0.1:8001`. The script defaults to `DAYBOOK_URL=http://127.0.0.1:8001` / `DAYBOOK_TOKEN=dev-token`; no overrides needed unless the user has changed these.
|
Requires the AgentFeed dev server to be running on the project's fixed local port: `python manage.py runserver 127.0.0.1:8001`. The script defaults to `AGENTFEED_URL=http://127.0.0.1:8001` / `AGENTFEED_TOKEN=dev-token`; no overrides needed unless the user has changed these.
|
||||||
|
|
||||||
After running, report each posted artifact's kind and URL, and remind the user to reload the feed for today.
|
After running, report each posted artifact's kind and URL, and remind the user to reload the feed for today.
|
||||||
|
|
|
||||||
26
CHANGELOG.md
26
CHANGELOG.md
|
|
@ -1,13 +1,32 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to Daybook are recorded here. Format loosely follows
|
All notable changes to AgentFeed are recorded here. Format loosely follows
|
||||||
[Keep a Changelog](https://keepachangelog.com/); versions are SemVer and match
|
[Keep a Changelog](https://keepachangelog.com/); versions are SemVer and match
|
||||||
the git tags (`vX.Y.Z`) and `daybook_project.__version__`.
|
the git tags (`vX.Y.Z`) and `__version__`. (Entries before 0.3.0 use the app's
|
||||||
|
former name, "Daybook".)
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
_Nothing yet._
|
_Nothing yet._
|
||||||
|
|
||||||
|
## [0.3.0] — 2026-09-04
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Renamed the app to AgentFeed.** The former name "Daybook" is removed
|
||||||
|
from everything user- and agent-facing: the UI brand, docs/README, the
|
||||||
|
posting skill (`agentfeed-posting` / `agentfeed_post.py`), CSS
|
||||||
|
(`agentfeed.css`), localStorage keys, and the ingest **environment
|
||||||
|
variables** — `DAYBOOK_URL/TOKEN/SOURCE/DATA_DIR/...` are now
|
||||||
|
`AGENTFEED_*`. Agents must update their env-var names (the token *values*
|
||||||
|
are unchanged). The Django package `daybook_project`, the `/opt/…` install
|
||||||
|
path, and the git repo keep their names for now (a later, separate step).
|
||||||
|
- Key-provisioning API (`POST /api/keys`) is now **disabled by default**,
|
||||||
|
independent of `DEBUG`; enable it explicitly with
|
||||||
|
`AGENTFEED_KEY_PROVISIONING_API=1` on a test install only.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Posting helper now points at Settings → API Keys (was Settings → Account).
|
||||||
|
|
||||||
## [0.2.1] — 2026-09-04
|
## [0.2.1] — 2026-09-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
@ -98,7 +117,8 @@ First tagged release.
|
||||||
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
|
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
|
||||||
runbook (`deploy/cloud_agent_deploy.md`) and health check.
|
runbook (`deploy/cloud_agent_deploy.md`) and health check.
|
||||||
|
|
||||||
[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.2.1...HEAD
|
[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.3.0...HEAD
|
||||||
|
[0.3.0]: https://git.cygnus.systems/peter/daybook/compare/v0.2.1...v0.3.0
|
||||||
[0.2.1]: https://git.cygnus.systems/peter/daybook/compare/v0.2.0...v0.2.1
|
[0.2.1]: https://git.cygnus.systems/peter/daybook/compare/v0.2.0...v0.2.1
|
||||||
[0.2.0]: https://git.cygnus.systems/peter/daybook/compare/v0.1.1...v0.2.0
|
[0.2.0]: https://git.cygnus.systems/peter/daybook/compare/v0.1.1...v0.2.0
|
||||||
[0.1.1]: https://git.cygnus.systems/peter/daybook/compare/v0.1.0...v0.1.1
|
[0.1.1]: https://git.cygnus.systems/peter/daybook/compare/v0.1.0...v0.1.1
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Project
|
## Project
|
||||||
|
|
||||||
Daybook is a self-hosted, multi-user Django + Django REST Framework web app where AI agents, scripts, or people publish artifacts and each logged-in user browses their own time-ordered, label-filterable feed.
|
AgentFeed is a self-hosted, multi-user Django + Django REST Framework web app where AI agents, scripts, or people publish artifacts and each logged-in user browses their own time-ordered, label-filterable feed.
|
||||||
|
|
||||||
The core product principle is: agents publish content, not files. The server owns IDs, styling, storage layout, dates, and normalization into browser-native HTML, image, or PDF outputs.
|
The core product principle is: agents publish content, not files. The server owns IDs, styling, storage layout, dates, and normalization into browser-native HTML, image, or PDF outputs.
|
||||||
|
|
||||||
|
|
@ -44,7 +44,7 @@ The core product principle is: agents publish content, not files. The server own
|
||||||
- Use the local Django commands for normal iteration: `python manage.py check`, `python manage.py migrate`, and `python manage.py runserver`.
|
- Use the local Django commands for normal iteration: `python manage.py check`, `python manage.py migrate`, and `python manage.py runserver`.
|
||||||
- Run the local development server on `127.0.0.1:8001`. Port `8000` may be reserved or blocked on this workstation.
|
- Run the local development server on `127.0.0.1:8001`. Port `8000` may be reserved or blocked on this workstation.
|
||||||
- Always use this same fixed port for the dev server. Never start a second instance on a different port; if `8001` is already serving, reuse it rather than starting another.
|
- Always use this same fixed port for the dev server. Never start a second instance on a different port; if `8001` is already serving, reuse it rather than starting another.
|
||||||
- All artifact-posting tools (`skills/daybook-posting/scripts/daybook_post.py`, `scripts/seed_demo_artifacts.py`, and any future posting skill) must default `DAYBOOK_URL` to `http://127.0.0.1:8001` to match.
|
- All artifact-posting tools (`skills/agentfeed-posting/scripts/agentfeed_post.py`, `scripts/seed_demo_artifacts.py`, and any future posting skill) must default `AGENTFEED_URL` to `http://127.0.0.1:8001` to match.
|
||||||
- A regular (non-superuser) user `test` / password `testpwd` must always exist in the local dev database — it's the permanent account a sibling project (`AGENT_DAYBOOK__LLM_TEST`) uses for API testing. If the local `db.sqlite3` is ever reset, recreate this exact user (e.g. via Settings → Users) before assuming that project is broken.
|
- A regular (non-superuser) user `test` / password `testpwd` must always exist in the local dev database — it's the permanent account a sibling project (`AGENT_DAYBOOK__LLM_TEST`) uses for API testing. If the local `db.sqlite3` is ever reset, recreate this exact user (e.g. via Settings → Users) before assuming that project is broken.
|
||||||
- Do not deploy to the Proxmox NUC during routine code iteration.
|
- Do not deploy to the Proxmox NUC during routine code iteration.
|
||||||
- Treat the NUC container as the local live/staging target. Deploy there only at explicit stable checkpoints, such as an end-of-day build.
|
- Treat the NUC container as the local live/staging target. Deploy there only at explicit stable checkpoints, such as an end-of-day build.
|
||||||
|
|
|
||||||
56
README.md
Normal file
56
README.md
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# AgentFeed
|
||||||
|
|
||||||
|
AgentFeed is a self-hosted, multi-user **artifact journal**. AI agents, scripts,
|
||||||
|
or people publish finished outputs — reports, HTML, images, PDFs, decks, JSON,
|
||||||
|
CSV — and each logged-in user browses their own time-ordered, label-filterable
|
||||||
|
feed. The core principle: **agents publish content, not files.** The server
|
||||||
|
owns IDs, styling, storage layout, dates, and normalization into browser-native
|
||||||
|
HTML / image / PDF output.
|
||||||
|
|
||||||
|
Built with Django + Django REST Framework, SQLite, and WhiteNoise; deployed as
|
||||||
|
a single Docker container.
|
||||||
|
|
||||||
|
## Quick start (local development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
.venv/Scripts/python -m pip install -r requirements.txt # Windows; use .venv/bin on POSIX
|
||||||
|
python manage.py migrate
|
||||||
|
python manage.py createsuperuser
|
||||||
|
python manage.py runserver 127.0.0.1:8001
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open <http://127.0.0.1:8001/> and log in.
|
||||||
|
|
||||||
|
- **Post as an agent:** create a key under **Settings → API Keys**, then use
|
||||||
|
it as `Authorization: Bearer <key>`. Each user can hold several keys (one
|
||||||
|
per agent), each revocable on its own.
|
||||||
|
- **Health check / contract:** `GET /api/capabilities`.
|
||||||
|
- **Ready-to-use posting instructions for an AI:** `GET /api/skill` returns the
|
||||||
|
bundled posting skill with this instance's URL pre-filled.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Doc | For |
|
||||||
|
|---|---|
|
||||||
|
| [`docs/agent_quickstart.md`](docs/agent_quickstart.md) | Shortest path to posting from an agent |
|
||||||
|
| [`docs/api.md`](docs/api.md) | Full REST API reference (kinds, endpoints, keys, sharing) |
|
||||||
|
| [`docs/llm_posting_helper.md`](docs/llm_posting_helper.md) | Using the bundled `agentfeed_post.py` helper |
|
||||||
|
| [`docs/deployment.md`](docs/deployment.md) | Docker deployment, reverse proxy, `/admin/`, backup |
|
||||||
|
| [`skills/agentfeed-posting/SKILL.md`](skills/agentfeed-posting/SKILL.md) | The agent-posting skill (also served at `/api/skill`) |
|
||||||
|
| [`CHANGELOG.md`](CHANGELOG.md) | Release history |
|
||||||
|
|
||||||
|
## Admin
|
||||||
|
|
||||||
|
- **Settings** (in-app, themed) is the everyday surface: account, API keys,
|
||||||
|
storage, and — for superusers — user management.
|
||||||
|
- **Django admin** at `/admin/` is an operator break-glass tool for raw-data
|
||||||
|
fixes. It requires `is_staff` (the `createsuperuser` account has it). See
|
||||||
|
[`docs/deployment.md`](docs/deployment.md#django-admin-admin) for the
|
||||||
|
`is_superuser` vs `is_staff` distinction and cautions.
|
||||||
|
|
||||||
|
## Version
|
||||||
|
|
||||||
|
The version is defined once in `daybook_project/__init__.py`, shown on
|
||||||
|
**Settings → About**, and reported by `GET /api/capabilities`. Releases are
|
||||||
|
git-tagged `vX.Y.Z`.
|
||||||
|
|
@ -2,7 +2,7 @@ from rest_framework.response import Response
|
||||||
from rest_framework.views import exception_handler
|
from rest_framework.views import exception_handler
|
||||||
|
|
||||||
|
|
||||||
def daybook_exception_handler(exc, context):
|
def agentfeed_exception_handler(exc, context):
|
||||||
"""DRF exception handler that additionally maps pathological-input crashes
|
"""DRF exception handler that additionally maps pathological-input crashes
|
||||||
to a clean 400 instead of an unhandled 500.
|
to a clean 400 instead of an unhandled 500.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,5 @@ from django import forms
|
||||||
from django.contrib.auth.forms import AuthenticationForm
|
from django.contrib.auth.forms import AuthenticationForm
|
||||||
|
|
||||||
|
|
||||||
class DaybookLoginForm(AuthenticationForm):
|
class AgentFeedLoginForm(AuthenticationForm):
|
||||||
remember_me = forms.BooleanField(required=False, initial=True)
|
remember_me = forms.BooleanField(required=False, initial=True)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from django.db import models
|
||||||
|
|
||||||
|
|
||||||
def generate_api_key():
|
def generate_api_key():
|
||||||
"""`dbk_` prefix (recognizable as a Daybook key) + 40 hex chars."""
|
"""`dbk_` prefix (recognizable as a AgentFeed key) + 40 hex chars."""
|
||||||
return "dbk_" + secrets.token_hex(20)
|
return "dbk_" + secrets.token_hex(20)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from .demo_data import create_demo_artifacts
|
from .demo_data import create_demo_artifacts
|
||||||
from .forms import DaybookLoginForm
|
from .forms import AgentFeedLoginForm
|
||||||
from .models import ApiKey, Artifact, Asset, TagColor
|
from .models import ApiKey, Artifact, Asset, TagColor
|
||||||
from .storage import (
|
from .storage import (
|
||||||
QuotaExceeded,
|
QuotaExceeded,
|
||||||
|
|
@ -66,7 +66,7 @@ REMEMBER_ME_SESSION_AGE = 60 * 60 * 24 * 30 # 30 days
|
||||||
|
|
||||||
MAX_ASSET_BYTES = 50 * 1024 * 1024 # 50 MB per uploaded image
|
MAX_ASSET_BYTES = 50 * 1024 * 1024 # 50 MB per uploaded image
|
||||||
|
|
||||||
# Matches the asset id in a Daybook asset URL, e.g. /assets/ab12cd34ef56
|
# Matches the asset id in a AgentFeed asset URL, e.g. /assets/ab12cd34ef56
|
||||||
ASSET_URL_RE = re.compile(r"/assets/([0-9a-f]{12})")
|
ASSET_URL_RE = re.compile(r"/assets/([0-9a-f]{12})")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -315,7 +315,7 @@ class CapabilitiesView(APIView):
|
||||||
"/api/capabilities": ["GET"],
|
"/api/capabilities": ["GET"],
|
||||||
"/api/skill": ["GET"],
|
"/api/skill": ["GET"],
|
||||||
}
|
}
|
||||||
if settings.DAYBOOK_KEY_PROVISIONING_API:
|
if settings.AGENTFEED_KEY_PROVISIONING_API:
|
||||||
methods["/api/keys"] = ["GET", "POST"]
|
methods["/api/keys"] = ["GET", "POST"]
|
||||||
methods["/api/keys/{id}"] = ["DELETE"]
|
methods["/api/keys/{id}"] = ["DELETE"]
|
||||||
|
|
||||||
|
|
@ -330,30 +330,30 @@ class CapabilitiesView(APIView):
|
||||||
"auth": "bearer_token",
|
"auth": "bearer_token",
|
||||||
"methods": methods,
|
"methods": methods,
|
||||||
"skill_url": request.build_absolute_uri(reverse("skill-api")),
|
"skill_url": request.build_absolute_uri(reverse("skill-api")),
|
||||||
"key_provisioning_api": settings.DAYBOOK_KEY_PROVISIONING_API,
|
"key_provisioning_api": settings.AGENTFEED_KEY_PROVISIONING_API,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
SKILL_MD_PATH = settings.BASE_DIR / "skills" / "daybook-posting" / "SKILL.md"
|
SKILL_MD_PATH = settings.BASE_DIR / "skills" / "agentfeed-posting" / "SKILL.md"
|
||||||
|
|
||||||
|
|
||||||
def render_posting_skill(origin):
|
def render_posting_skill(origin):
|
||||||
"""Return the bundled posting SKILL.md with its DAYBOOK_URL placeholder
|
"""Return the bundled posting SKILL.md with its AGENTFEED_URL placeholder
|
||||||
rewritten to this instance's origin, so text handed to an agent already
|
rewritten to this instance's origin, so text handed to an agent already
|
||||||
points at the right server. Only the config-block assignment is touched;
|
points at the right server. Only the config-block assignment is touched;
|
||||||
the surrounding prose (which explains DAYBOOK_URL) is left alone."""
|
the surrounding prose (which explains AGENTFEED_URL) is left alone."""
|
||||||
text = SKILL_MD_PATH.read_text(encoding="utf-8")
|
text = SKILL_MD_PATH.read_text(encoding="utf-8")
|
||||||
return re.sub(
|
return re.sub(
|
||||||
r"(?m)^(DAYBOOK_URL=)\S+",
|
r"(?m)^(AGENTFEED_URL=)\S+",
|
||||||
lambda m: m.group(1) + origin,
|
lambda m: m.group(1) + origin,
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SkillView(APIView):
|
class SkillView(APIView):
|
||||||
"""Serve the bundled agent-posting skill (skills/daybook-posting/SKILL.md),
|
"""Serve the bundled agent-posting skill (skills/agentfeed-posting/SKILL.md),
|
||||||
with DAYBOOK_URL pre-filled to this instance so a recipient posts to the
|
with AGENTFEED_URL pre-filled to this instance so a recipient posts to the
|
||||||
right server. Same auth as capabilities: a logged-in user can copy it, or
|
right server. Same auth as capabilities: a logged-in user can copy it, or
|
||||||
an agent can fetch it with its API token.
|
an agent can fetch it with its API token.
|
||||||
|
|
||||||
|
|
@ -390,7 +390,7 @@ def serialize_api_key(api_key):
|
||||||
|
|
||||||
class ApiKeyListCreateView(APIView):
|
class ApiKeyListCreateView(APIView):
|
||||||
"""Programmatic API-key management, for automated test installations that
|
"""Programmatic API-key management, for automated test installations that
|
||||||
provision their own agents. Gated by settings.DAYBOOK_KEY_PROVISIONING_API,
|
provision their own agents. Gated by settings.AGENTFEED_KEY_PROVISIONING_API,
|
||||||
which is OFF in deployment (it hands out key material over the API). When
|
which is OFF in deployment (it hands out key material over the API). When
|
||||||
off, every method returns 403. Authenticated by session or an existing
|
off, every method returns 403. Authenticated by session or an existing
|
||||||
bearer token, and only ever operates on that user's own keys.
|
bearer token, and only ever operates on that user's own keys.
|
||||||
|
|
@ -405,7 +405,7 @@ class ApiKeyListCreateView(APIView):
|
||||||
return resolve_token_user(request)
|
return resolve_token_user(request)
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
if not settings.DAYBOOK_KEY_PROVISIONING_API:
|
if not settings.AGENTFEED_KEY_PROVISIONING_API:
|
||||||
return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403)
|
return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403)
|
||||||
user = self._resolve_user(request)
|
user = self._resolve_user(request)
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|
@ -413,7 +413,7 @@ class ApiKeyListCreateView(APIView):
|
||||||
return Response([serialize_api_key(k) for k in user.api_keys.all()])
|
return Response([serialize_api_key(k) for k in user.api_keys.all()])
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
if not settings.DAYBOOK_KEY_PROVISIONING_API:
|
if not settings.AGENTFEED_KEY_PROVISIONING_API:
|
||||||
return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403)
|
return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403)
|
||||||
user = self._resolve_user(request)
|
user = self._resolve_user(request)
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|
@ -432,7 +432,7 @@ class ApiKeyDetailView(APIView):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def delete(self, request, key_id):
|
def delete(self, request, key_id):
|
||||||
if not settings.DAYBOOK_KEY_PROVISIONING_API:
|
if not settings.AGENTFEED_KEY_PROVISIONING_API:
|
||||||
return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403)
|
return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403)
|
||||||
user = request.user if request.user.is_authenticated else resolve_token_user(request)
|
user = request.user if request.user.is_authenticated else resolve_token_user(request)
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|
@ -858,7 +858,7 @@ def create_binary_artifact(data, request, user):
|
||||||
|
|
||||||
|
|
||||||
# Allowlist for sanitizing the "themed" text kinds (markdown, deck,
|
# Allowlist for sanitizing the "themed" text kinds (markdown, deck,
|
||||||
# html_fragment) — kinds where Daybook, not the poster, owns presentation, so
|
# html_fragment) — kinds where AgentFeed, not the poster, owns presentation, so
|
||||||
# they must never be a script-execution vector (issue #014). html_full is the
|
# they must never be a script-execution vector (issue #014). html_full is the
|
||||||
# deliberate exception (verbatim, poster owns everything) and is NOT run
|
# deliberate exception (verbatim, poster owns everything) and is NOT run
|
||||||
# through this. The list keeps ordinary formatting + tables + code-highlight
|
# through this. The list keeps ordinary formatting + tables + code-highlight
|
||||||
|
|
@ -963,14 +963,14 @@ def wrap_json_viewer(content, title, request):
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="artifact">
|
<body class="artifact">
|
||||||
<script type="application/json" id="daybook-data">{safe_json}</script>
|
<script type="application/json" id="agentfeed-data">{safe_json}</script>
|
||||||
<div id="daybook-json-view"></div>
|
<div id="agentfeed-json-view"></div>
|
||||||
<script>
|
<script>
|
||||||
(function () {{
|
(function () {{
|
||||||
var raw = document.getElementById("daybook-data").textContent;
|
var raw = document.getElementById("agentfeed-data").textContent;
|
||||||
var data = JSON.parse(raw);
|
var data = JSON.parse(raw);
|
||||||
renderjson.set_show_to_level(2);
|
renderjson.set_show_to_level(2);
|
||||||
document.getElementById("daybook-json-view").appendChild(renderjson(data));
|
document.getElementById("agentfeed-json-view").appendChild(renderjson(data));
|
||||||
}})();
|
}})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
@ -1007,18 +1007,18 @@ def wrap_csv_viewer(content, title, request):
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="artifact">
|
<body class="artifact">
|
||||||
<script type="application/json" id="daybook-data">{table_json}</script>
|
<script type="application/json" id="agentfeed-data">{table_json}</script>
|
||||||
<div id="daybook-csv-view"></div>
|
<div id="agentfeed-csv-view"></div>
|
||||||
<script>
|
<script>
|
||||||
(function () {{
|
(function () {{
|
||||||
var table = JSON.parse(document.getElementById("daybook-data").textContent);
|
var table = JSON.parse(document.getElementById("agentfeed-data").textContent);
|
||||||
new gridjs.Grid({{
|
new gridjs.Grid({{
|
||||||
columns: table.columns,
|
columns: table.columns,
|
||||||
data: table.data,
|
data: table.data,
|
||||||
search: true,
|
search: true,
|
||||||
sort: true,
|
sort: true,
|
||||||
pagination: {{ limit: 20 }},
|
pagination: {{ limit: 20 }},
|
||||||
}}).render(document.getElementById("daybook-csv-view"));
|
}}).render(document.getElementById("agentfeed-csv-view"));
|
||||||
}})();
|
}})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
@ -1306,9 +1306,9 @@ def raw_artifact_path(artifact):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DaybookLoginView(auth_views.LoginView):
|
class AgentFeedLoginView(auth_views.LoginView):
|
||||||
template_name = "artifacts/login.html"
|
template_name = "artifacts/login.html"
|
||||||
authentication_form = DaybookLoginForm
|
authentication_form = AgentFeedLoginForm
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
response = super().form_valid(form)
|
response = super().form_valid(form)
|
||||||
|
|
@ -1459,7 +1459,7 @@ def raw_unavailable_html(artifact):
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<h1>Raw source unavailable</h1>
|
<h1>Raw source unavailable</h1>
|
||||||
<p>Daybook does not have preserved raw source for <strong>{html.escape(artifact.title)}</strong>.</p>
|
<p>AgentFeed does not have preserved raw source for <strong>{html.escape(artifact.title)}</strong>.</p>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Single source of truth for the app version. Bump this, commit, then tag the
|
# Single source of truth for the app version. Bump this, commit, then tag the
|
||||||
# commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in
|
# commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in
|
||||||
# GET /api/capabilities ("version").
|
# GET /api/capabilities ("version").
|
||||||
__version__ = "0.2.1"
|
__version__ = "0.3.0"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
DATA_DIR = Path(os.environ.get("DAYBOOK_DATA_DIR", BASE_DIR)).resolve()
|
DATA_DIR = Path(os.environ.get("AGENTFEED_DATA_DIR", BASE_DIR)).resolve()
|
||||||
|
|
||||||
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me")
|
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me")
|
||||||
DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"}
|
DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
@ -11,14 +11,13 @@ ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS",
|
||||||
|
|
||||||
# Allow creating/listing/revoking API keys over the ingest API (POST /api/keys).
|
# Allow creating/listing/revoking API keys over the ingest API (POST /api/keys).
|
||||||
# Meant for automated test installations that provision their own agents. It
|
# Meant for automated test installations that provision their own agents. It
|
||||||
# exposes key material over the API, so it is OFF in deployment. Unset, it
|
# exposes key material over the API, so it is DISABLED BY DEFAULT and must be
|
||||||
# follows DEBUG (on locally, off in production); set DAYBOOK_KEY_PROVISIONING_API
|
# turned on explicitly — set AGENTFEED_KEY_PROVISIONING_API=1 only on a throwaway
|
||||||
# explicitly (0/1) to override either way.
|
# test install. Never enable it in a real deployment. (Independent of DEBUG, so
|
||||||
_key_api_env = os.environ.get("DAYBOOK_KEY_PROVISIONING_API")
|
# a misconfigured DEBUG=1 production box still won't expose it.)
|
||||||
if _key_api_env is None:
|
AGENTFEED_KEY_PROVISIONING_API = os.environ.get(
|
||||||
DAYBOOK_KEY_PROVISIONING_API = DEBUG
|
"AGENTFEED_KEY_PROVISIONING_API", ""
|
||||||
else:
|
).lower() in {"1", "true", "yes", "on"}
|
||||||
DAYBOOK_KEY_PROVISIONING_API = _key_api_env.lower() in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
# Needed when running behind a TLS-terminating reverse proxy (Apache/Nginx/etc.):
|
# Needed when running behind a TLS-terminating reverse proxy (Apache/Nginx/etc.):
|
||||||
# Django otherwise thinks every request is plain HTTP, which breaks CSRF checks
|
# Django otherwise thinks every request is plain HTTP, which breaks CSRF checks
|
||||||
|
|
@ -71,7 +70,7 @@ WSGI_APPLICATION = "daybook_project.wsgi.application"
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
# Maps deeply-nested-JSON RecursionErrors (raised in DRF's parser, before
|
# Maps deeply-nested-JSON RecursionErrors (raised in DRF's parser, before
|
||||||
# view validation) to a 400 instead of an unhandled 500. See issue #015.
|
# view validation) to a 400 instead of an unhandled 500. See issue #015.
|
||||||
"EXCEPTION_HANDLER": "artifacts.exception_handlers.daybook_exception_handler",
|
"EXCEPTION_HANDLER": "artifacts.exception_handlers.agentfeed_exception_handler",
|
||||||
}
|
}
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ from django.contrib import admin
|
||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
from artifacts.views import DaybookLoginView
|
from artifacts.views import AgentFeedLoginView
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("accounts/login/", DaybookLoginView.as_view(), name="login"),
|
path("accounts/login/", AgentFeedLoginView.as_view(), name="login"),
|
||||||
path("accounts/logout/", auth_views.LogoutView.as_view(next_page="login"), name="logout"),
|
path("accounts/logout/", auth_views.LogoutView.as_view(next_page="login"), name="logout"),
|
||||||
path("", include("artifacts.urls")),
|
path("", include("artifacts.urls")),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Cloud Deployment Runbook For A Dedicated Deploy Agent
|
# Cloud Deployment Runbook For A Dedicated Deploy Agent
|
||||||
|
|
||||||
This is written for a capable (Opus-class) agent deploying and operating
|
This is written for a capable (Opus-class) agent deploying and operating
|
||||||
Daybook on a cloud VM. Treat it as a guide and a source of Daybook-specific
|
AgentFeed on a cloud VM. Treat it as a guide and a source of AgentFeed-specific
|
||||||
facts, **not a rigid script** — you're expected to use judgment, adapt to
|
facts, **not a rigid script** — you're expected to use judgment, adapt to
|
||||||
the actual VM, and read the authoritative sources rather than follow steps
|
the actual VM, and read the authoritative sources rather than follow steps
|
||||||
blindly. The steps below are a sensible default path; deviate where the host
|
blindly. The steps below are a sensible default path; deviate where the host
|
||||||
|
|
@ -25,7 +25,7 @@ disagree):
|
||||||
Where a decision needs info you don't have (VM IP, domain, whether it's
|
Where a decision needs info you don't have (VM IP, domain, whether it's
|
||||||
internet-facing, whether a reverse proxy/secondary volume exists), ask the
|
internet-facing, whether a reverse proxy/secondary volume exists), ask the
|
||||||
operator once, then proceed autonomously for routine redeploys. The rest of
|
operator once, then proceed autonomously for routine redeploys. The rest of
|
||||||
this file is the recommended path plus the non-obvious, Daybook-specific
|
this file is the recommended path plus the non-obvious, AgentFeed-specific
|
||||||
gotchas (SSH port, CSRF-behind-proxy, bind address, data volume, healthcheck)
|
gotchas (SSH port, CSRF-behind-proxy, bind address, data volume, healthcheck)
|
||||||
that are easy to get wrong.
|
that are easy to get wrong.
|
||||||
|
|
||||||
|
|
@ -37,9 +37,9 @@ a token tied to a full user account.
|
||||||
|
|
||||||
1. Generate an SSH key pair on the VM (or ask the operator for one to install):
|
1. Generate an SSH key pair on the VM (or ask the operator for one to install):
|
||||||
```bash
|
```bash
|
||||||
ssh-keygen -t ed25519 -C "daybook-deploy-agent" -f ~/.ssh/daybook_deploy -N ""
|
ssh-keygen -t ed25519 -C "agentfeed-deploy-agent" -f ~/.ssh/agentfeed_deploy -N ""
|
||||||
```
|
```
|
||||||
2. Ask the operator to add the **public** key (`~/.ssh/daybook_deploy.pub`) as
|
2. Ask the operator to add the **public** key (`~/.ssh/agentfeed_deploy.pub`) as
|
||||||
a Deploy Key on the repo in Forgejo (repo → Settings → Deploy Keys →
|
a Deploy Key on the repo in Forgejo (repo → Settings → Deploy Keys →
|
||||||
read-only is sufficient), or hand it to them directly — never ask them to
|
read-only is sufficient), or hand it to them directly — never ask them to
|
||||||
give you their personal account credentials.
|
give you their personal account credentials.
|
||||||
|
|
@ -52,7 +52,7 @@ a token tied to a full user account.
|
||||||
cat >> ~/.ssh/config <<'EOF'
|
cat >> ~/.ssh/config <<'EOF'
|
||||||
Host git.cygnus.systems
|
Host git.cygnus.systems
|
||||||
Port 2222
|
Port 2222
|
||||||
IdentityFile ~/.ssh/daybook_deploy
|
IdentityFile ~/.ssh/agentfeed_deploy
|
||||||
IdentitiesOnly yes
|
IdentitiesOnly yes
|
||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|
@ -112,12 +112,12 @@ Edit `deploy/.env`:
|
||||||
DJANGO_DEBUG=0
|
DJANGO_DEBUG=0
|
||||||
DJANGO_SECRET_KEY=<generated-secret>
|
DJANGO_SECRET_KEY=<generated-secret>
|
||||||
DJANGO_ALLOWED_HOSTS=<vm-public-ip>,<domain-if-any>,localhost,127.0.0.1
|
DJANGO_ALLOWED_HOSTS=<vm-public-ip>,<domain-if-any>,localhost,127.0.0.1
|
||||||
DAYBOOK_DATA_DIR=/data
|
AGENTFEED_DATA_DIR=/data
|
||||||
DATABASE_PATH=/data/db.sqlite3
|
DATABASE_PATH=/data/db.sqlite3
|
||||||
ARTIFACTS_ROOT=/data/artifacts_store
|
ARTIFACTS_ROOT=/data/artifacts_store
|
||||||
```
|
```
|
||||||
|
|
||||||
There is no shared API token to set — Daybook issues a personal token per
|
There is no shared API token to set — AgentFeed issues a personal token per
|
||||||
user from Settings → Account once a user exists (step 5). Never commit
|
user from Settings → Account once a user exists (step 5). Never commit
|
||||||
`deploy/.env`; it is already gitignored.
|
`deploy/.env`; it is already gitignored.
|
||||||
|
|
||||||
|
|
@ -131,17 +131,17 @@ if unsure which applies here.
|
||||||
## 4. Network Access
|
## 4. Network Access
|
||||||
|
|
||||||
**If this VM is reachable from the public internet, a reverse proxy with TLS
|
**If this VM is reachable from the public internet, a reverse proxy with TLS
|
||||||
in front of Daybook is not optional — it's the only path.** Bearer tokens are
|
in front of AgentFeed is not optional — it's the only path.** Bearer tokens are
|
||||||
this app's entire write-auth model, and they'd otherwise go over plaintext
|
this app's entire write-auth model, and they'd otherwise go over plaintext
|
||||||
HTTP straight to a port on the public IP. Direct port exposure (below) is
|
HTTP straight to a port on the public IP. Direct port exposure (below) is
|
||||||
only for private/VPN-only networks with no public exposure.
|
only for private/VPN-only networks with no public exposure.
|
||||||
|
|
||||||
- **Internet-facing (the common case):** set `DAYBOOK_BIND_ADDR=127.0.0.1` in
|
- **Internet-facing (the common case):** set `AGENTFEED_BIND_ADDR=127.0.0.1` in
|
||||||
`deploy/.env` so the container port is not reachable from outside the VM at
|
`deploy/.env` so the container port is not reachable from outside the VM at
|
||||||
all, then open `80`/`443` in the VM's firewall/security group for the
|
all, then open `80`/`443` in the VM's firewall/security group for the
|
||||||
reverse proxy (see §8). Do not open `8000` externally.
|
reverse proxy (see §8). Do not open `8000` externally.
|
||||||
- **Private/VPN-only network:** open TCP `8000` to the addresses that should
|
- **Private/VPN-only network:** open TCP `8000` to the addresses that should
|
||||||
reach it (never `0.0.0.0/0`), and leave `DAYBOOK_BIND_ADDR` unset.
|
reach it (never `0.0.0.0/0`), and leave `AGENTFEED_BIND_ADDR` unset.
|
||||||
|
|
||||||
Before opening any port, check whether one is already open and in use —
|
Before opening any port, check whether one is already open and in use —
|
||||||
`ss -tlnp | grep -E ':80|:443|:8000'` — rather than assuming a clean slate.
|
`ss -tlnp | grep -E ':80|:443|:8000'` — rather than assuming a clean slate.
|
||||||
|
|
@ -153,8 +153,8 @@ don't have API access to it yourself.
|
||||||
## 5. Build, Start, Create A User
|
## 5. Build, Start, Create A User
|
||||||
|
|
||||||
If the target has a separate, larger data volume than its main disk (common
|
If the target has a separate, larger data volume than its main disk (common
|
||||||
on space-constrained VMs), point Daybook's persistent data at it before
|
on space-constrained VMs), point AgentFeed's persistent data at it before
|
||||||
starting: set `DAYBOOK_HOST_DATA_DIR=/path/on/that/volume` in `deploy/.env`.
|
starting: set `AGENTFEED_HOST_DATA_DIR=/path/on/that/volume` in `deploy/.env`.
|
||||||
Ask the operator whether such a volume exists and where before defaulting to
|
Ask the operator whether such a volume exists and where before defaulting to
|
||||||
the main disk — filling the main disk on a host running other services is a
|
the main disk — filling the main disk on a host running other services is a
|
||||||
bigger problem than it would be on a dedicated box. Create that same path
|
bigger problem than it would be on a dedicated box. Create that same path
|
||||||
|
|
@ -162,9 +162,9 @@ bigger problem than it would be on a dedicated box. Create that same path
|
||||||
unset) before starting the stack:
|
unset) before starting the stack:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p deploy/data # or the DAYBOOK_HOST_DATA_DIR path if you set one above
|
mkdir -p deploy/data # or the AGENTFEED_HOST_DATA_DIR path if you set one above
|
||||||
docker compose -f deploy/docker-compose.yml up -d --build
|
docker compose -f deploy/docker-compose.yml up -d --build
|
||||||
docker compose -f deploy/docker-compose.yml exec daybook python manage.py createsuperuser
|
docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py createsuperuser
|
||||||
```
|
```
|
||||||
|
|
||||||
The entrypoint runs migrations and collects static files automatically on
|
The entrypoint runs migrations and collects static files automatically on
|
||||||
|
|
@ -183,7 +183,7 @@ response when unauthenticated — it confirms the app is up. (Don't use
|
||||||
container's own healthcheck hits the login page instead, which is 200
|
container's own healthcheck hits the login page instead, which is 200
|
||||||
without auth.) This `curl` runs
|
without auth.) This `curl` runs
|
||||||
against `127.0.0.1` from on the VM itself, so it works regardless of whether
|
against `127.0.0.1` from on the VM itself, so it works regardless of whether
|
||||||
`DAYBOOK_BIND_ADDR` is set. Log in from a browser at whichever address is
|
`AGENTFEED_BIND_ADDR` is set. Log in from a browser at whichever address is
|
||||||
actually externally reachable at this point in the runbook — `http://<vm-ip>:8000/`
|
actually externally reachable at this point in the runbook — `http://<vm-ip>:8000/`
|
||||||
if you're on the private/VPN-only path from §4, or `https://<domain>/` through
|
if you're on the private/VPN-only path from §4, or `https://<domain>/` through
|
||||||
the reverse proxy once §8 is done if you're on the internet-facing path — with
|
the reverse proxy once §8 is done if you're on the internet-facing path — with
|
||||||
|
|
@ -230,13 +230,13 @@ For a routine forward release that's fine; if a rollback is needed after a
|
||||||
migration, restore the pre-deploy `deploy/data` backup (see §9) alongside
|
migration, restore the pre-deploy `deploy/data` backup (see §9) alongside
|
||||||
the code checkout.
|
the code checkout.
|
||||||
|
|
||||||
`deploy/.env` and the data directory (wherever `DAYBOOK_HOST_DATA_DIR` points,
|
`deploy/.env` and the data directory (wherever `AGENTFEED_HOST_DATA_DIR` points,
|
||||||
or `deploy/data` by default) are untracked, so `git pull` never touches them —
|
or `deploy/data` by default) are untracked, so `git pull` never touches them —
|
||||||
no need to back them up before a routine redeploy.
|
no need to back them up before a routine redeploy.
|
||||||
|
|
||||||
**If the container crash-loops right after a build** (`docker compose ps`
|
**If the container crash-loops right after a build** (`docker compose ps`
|
||||||
shows `restarting`, health never goes green), the most likely cause is the
|
shows `restarting`, health never goes green), the most likely cause is the
|
||||||
entrypoint's `collectstatic` step failing — check `docker logs daybook`
|
entrypoint's `collectstatic` step failing — check `docker logs agentfeed`
|
||||||
before assuming an env/network problem. A `whitenoise ... MissingFileError`
|
before assuming an env/network problem. A `whitenoise ... MissingFileError`
|
||||||
there means a static asset references a file that isn't present (this is what
|
there means a static asset references a file that isn't present (this is what
|
||||||
bit v0.1.0). No data or schema impact; fix the asset in the repo, tag, and
|
bit v0.1.0). No data or schema impact; fix the asset in the repo, tag, and
|
||||||
|
|
@ -277,14 +277,14 @@ instead. Apache example:
|
||||||
|
|
||||||
```apache
|
```apache
|
||||||
<VirtualHost *:443>
|
<VirtualHost *:443>
|
||||||
ServerName daybook.example.com
|
ServerName agentfeed.example.com
|
||||||
ProxyPreserveHost On
|
ProxyPreserveHost On
|
||||||
RequestHeader set X-Forwarded-Proto "https"
|
RequestHeader set X-Forwarded-Proto "https"
|
||||||
ProxyPass / http://127.0.0.1:8000/
|
ProxyPass / http://127.0.0.1:8000/
|
||||||
ProxyPassReverse / http://127.0.0.1:8000/
|
ProxyPassReverse / http://127.0.0.1:8000/
|
||||||
SSLEngine on
|
SSLEngine on
|
||||||
SSLCertificateFile /etc/letsencrypt/live/daybook.example.com/fullchain.pem
|
SSLCertificateFile /etc/letsencrypt/live/agentfeed.example.com/fullchain.pem
|
||||||
SSLCertificateKeyFile /etc/letsencrypt/live/daybook.example.com/privkey.pem
|
SSLCertificateKeyFile /etc/letsencrypt/live/agentfeed.example.com/privkey.pem
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -296,7 +296,7 @@ Only reach for a standalone proxy like Caddy if the target genuinely has
|
||||||
nothing on 80/443 yet — it still needs the same header:
|
nothing on 80/443 yet — it still needs the same header:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
daybook.example.com {
|
agentfeed.example.com {
|
||||||
reverse_proxy 127.0.0.1:8000 {
|
reverse_proxy 127.0.0.1:8000 {
|
||||||
header_up X-Forwarded-Proto https
|
header_up X-Forwarded-Proto https
|
||||||
}
|
}
|
||||||
|
|
@ -305,15 +305,15 @@ daybook.example.com {
|
||||||
|
|
||||||
## 9. Backup
|
## 9. Backup
|
||||||
|
|
||||||
If you set `DAYBOOK_HOST_DATA_DIR` in step 5, the real data lives there, not
|
If you set `AGENTFEED_HOST_DATA_DIR` in step 5, the real data lives there, not
|
||||||
at `deploy/data` — back up whichever path is actually configured, read
|
at `deploy/data` — back up whichever path is actually configured, read
|
||||||
straight from `deploy/.env` rather than assumed, so this keeps working
|
straight from `deploy/.env` rather than assumed, so this keeps working
|
||||||
whether or not that override is set:
|
whether or not that override is set:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/daybook
|
cd /opt/daybook
|
||||||
DATA_DIR="$(grep -oP '(?<=^DAYBOOK_HOST_DATA_DIR=).*' deploy/.env || echo deploy/data)"
|
DATA_DIR="$(grep -oP '(?<=^AGENTFEED_HOST_DATA_DIR=).*' deploy/.env || echo deploy/data)"
|
||||||
tar -czf "/tmp/daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")"
|
tar -czf "/tmp/agentfeed-data-$(date +%Y%m%d-%H%M%S).tar.gz" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")"
|
||||||
```
|
```
|
||||||
|
|
||||||
Copy the resulting archive off the VM (to object storage, the operator's
|
Copy the resulting archive off the VM (to object storage, the operator's
|
||||||
|
|
@ -331,12 +331,12 @@ Before reporting the deployment complete, confirm all of these:
|
||||||
public HTTPS origin, and the proxy forwards `X-Forwarded-Proto: https`.
|
public HTTPS origin, and the proxy forwards `X-Forwarded-Proto: https`.
|
||||||
Verify by actually logging in through the proxy, not just curling
|
Verify by actually logging in through the proxy, not just curling
|
||||||
`/api/capabilities` — CSRF only bites on POST (login, artifact writes).
|
`/api/capabilities` — CSRF only bites on POST (login, artifact writes).
|
||||||
- [ ] `docker compose -f deploy/docker-compose.yml ps` shows `daybook` healthy.
|
- [ ] `docker compose -f deploy/docker-compose.yml ps` shows `agentfeed` healthy.
|
||||||
- [ ] `/api/capabilities` returns `403` unauthenticated, `200` with a token.
|
- [ ] `/api/capabilities` returns `403` unauthenticated, `200` with a token.
|
||||||
- [ ] At least one superuser exists with a working personal API token.
|
- [ ] At least one superuser exists with a working personal API token.
|
||||||
- [ ] A smoke-test artifact posts and renders successfully.
|
- [ ] A smoke-test artifact posts and renders successfully.
|
||||||
- [ ] If internet-facing: TLS is in front of the app, not raw HTTP on `8000`.
|
- [ ] If internet-facing: TLS is in front of the app, not raw HTTP on `8000`.
|
||||||
- [ ] A backup of the data directory (`deploy/data`, or `DAYBOOK_HOST_DATA_DIR`
|
- [ ] A backup of the data directory (`deploy/data`, or `AGENTFEED_HOST_DATA_DIR`
|
||||||
if you set it) has been taken and copied off the VM.
|
if you set it) has been taken and copied off the VM.
|
||||||
- [ ] If the target host has its own service-inventory, monitoring, or
|
- [ ] If the target host has its own service-inventory, monitoring, or
|
||||||
changelog conventions for new services, they've been followed — ask
|
changelog conventions for new services, they've been followed — ask
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# ${VAR} substitution below (DAYBOOK_BIND_ADDR, DAYBOOK_HOST_DATA_DIR) reads
|
# ${VAR} substitution below (AGENTFEED_BIND_ADDR, AGENTFEED_HOST_DATA_DIR) reads
|
||||||
# from deploy/.env because Compose resolves .env relative to *this file's*
|
# from deploy/.env because Compose resolves .env relative to *this file's*
|
||||||
# directory, not the caller's cwd. That relies on being invoked as
|
# directory, not the caller's cwd. That relies on being invoked as
|
||||||
# `docker compose -f deploy/docker-compose.yml` from the repo root, per
|
# `docker compose -f deploy/docker-compose.yml` from the repo root, per
|
||||||
|
|
@ -7,20 +7,20 @@
|
||||||
# the defaults (0.0.0.0 bind, ./data) would be a security regression, not
|
# the defaults (0.0.0.0 bind, ./data) would be a security regression, not
|
||||||
# just a broken build.
|
# just a broken build.
|
||||||
services:
|
services:
|
||||||
daybook:
|
agentfeed:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: deploy/Dockerfile
|
dockerfile: deploy/Dockerfile
|
||||||
container_name: daybook
|
container_name: agentfeed
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
mem_limit: 384m
|
mem_limit: 384m
|
||||||
mem_reservation: 128m
|
mem_reservation: 128m
|
||||||
ports:
|
ports:
|
||||||
- "${DAYBOOK_BIND_ADDR:-0.0.0.0}:8000:8000"
|
- "${AGENTFEED_BIND_ADDR:-0.0.0.0}:8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ${DAYBOOK_HOST_DATA_DIR:-./data}:/data
|
- ${AGENTFEED_HOST_DATA_DIR:-./data}:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# Hit the login page, not /api/capabilities: capabilities returns 403
|
# Hit the login page, not /api/capabilities: capabilities returns 403
|
||||||
# when unauthenticated, and `curl --fail` treats 403 as an error, which
|
# when unauthenticated, and `curl --fail` treats 403 as an error, which
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
mkdir -p "${DAYBOOK_DATA_DIR:-/data}" "${ARTIFACTS_ROOT:-/data/artifacts_store}"
|
mkdir -p "${AGENTFEED_DATA_DIR:-/data}" "${ARTIFACTS_ROOT:-/data/artifacts_store}"
|
||||||
|
|
||||||
python manage.py migrate --noinput
|
python manage.py migrate --noinput
|
||||||
python manage.py collectstatic --noinput
|
python manage.py collectstatic --noinput
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
DJANGO_DEBUG=0
|
DJANGO_DEBUG=0
|
||||||
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
|
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
|
||||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,your-vm-ip,your-domain.example
|
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,your-vm-ip,your-domain.example
|
||||||
# Required if Daybook sits behind a TLS-terminating reverse proxy (Apache,
|
# Required if AgentFeed sits behind a TLS-terminating reverse proxy (Apache,
|
||||||
# Nginx, Traefik, a cloud load balancer). Without it, login/POST fails with
|
# Nginx, Traefik, a cloud load balancer). Without it, login/POST fails with
|
||||||
# "CSRF verification failed" because Django can't tell the original request
|
# "CSRF verification failed" because Django can't tell the original request
|
||||||
# was HTTPS. Set to the public HTTPS origin(s) users actually visit:
|
# was HTTPS. Set to the public HTTPS origin(s) users actually visit:
|
||||||
DJANGO_CSRF_TRUSTED_ORIGINS=https://your-domain.example
|
DJANGO_CSRF_TRUSTED_ORIGINS=https://your-domain.example
|
||||||
# Leave OFF in deployment. When on, POST /api/keys can create/list/revoke API
|
# Disabled by default. When on, POST /api/keys can create/list/revoke API keys
|
||||||
# keys over the network (it returns key material), which is only appropriate
|
# over the network (it returns key material), which is only appropriate for
|
||||||
# for automated test installations. Unset, it follows DJANGO_DEBUG — so with
|
# automated test installations. Leave unset in deployment; set to 1 only on a
|
||||||
# DJANGO_DEBUG=0 above it is already off. Set to 1 only on a throwaway test box.
|
# throwaway test box.
|
||||||
# DAYBOOK_KEY_PROVISIONING_API=0
|
# AGENTFEED_KEY_PROVISIONING_API=0
|
||||||
DAYBOOK_DATA_DIR=/data
|
AGENTFEED_DATA_DIR=/data
|
||||||
DATABASE_PATH=/data/db.sqlite3
|
DATABASE_PATH=/data/db.sqlite3
|
||||||
ARTIFACTS_ROOT=/data/artifacts_store
|
ARTIFACTS_ROOT=/data/artifacts_store
|
||||||
|
|
||||||
# Optional, read by deploy/docker-compose.yml directly (not by the app):
|
# Optional, read by deploy/docker-compose.yml directly (not by the app):
|
||||||
# DAYBOOK_BIND_ADDR=127.0.0.1
|
# AGENTFEED_BIND_ADDR=127.0.0.1
|
||||||
# Uncomment and set to 127.0.0.1 when a reverse proxy on the same host will
|
# Uncomment and set to 127.0.0.1 when a reverse proxy on the same host will
|
||||||
# front this service — this stops the container port from being reachable
|
# front this service — this stops the container port from being reachable
|
||||||
# directly from the network. Leave unset (binds all interfaces) only for
|
# directly from the network. Leave unset (binds all interfaces) only for
|
||||||
# private/VPN-only deployments with no proxy in front.
|
# private/VPN-only deployments with no proxy in front.
|
||||||
# DAYBOOK_HOST_DATA_DIR=/mnt/some-larger-volume/daybook-data
|
# AGENTFEED_HOST_DATA_DIR=/mnt/some-larger-volume/agentfeed-data
|
||||||
# Uncomment and point at a path on a separate/larger volume if the host's
|
# Uncomment and point at a path on a separate/larger volume if the host's
|
||||||
# main disk is space-constrained. Defaults to ./data next to this file.
|
# main disk is space-constrained. Defaults to ./data next to this file.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Agent Quickstart
|
# Agent Quickstart
|
||||||
|
|
||||||
This is the shortest path for an agent to publish and retrieve artifacts from Daybook.
|
This is the shortest path for an agent to publish and retrieve artifacts from AgentFeed.
|
||||||
|
|
||||||
## 0. Get Your API Key
|
## 0. Get Your API Key
|
||||||
|
|
||||||
|
|
|
||||||
28
docs/api.md
28
docs/api.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Daybook API
|
# AgentFeed API
|
||||||
|
|
||||||
Daybook is built around agents publishing artifacts. Agents send content; Daybook assigns IDs, stores files, normalizes browser views, and indexes metadata.
|
AgentFeed is built around agents publishing artifacts. Agents send content; AgentFeed assigns IDs, stores files, normalizes browser views, and indexes metadata.
|
||||||
|
|
||||||
Base URL while running locally:
|
Base URL while running locally:
|
||||||
|
|
||||||
|
|
@ -28,7 +28,7 @@ Currently supported kinds:
|
||||||
|
|
||||||
| Kind | Upload type | Rendered behavior | Raw support |
|
| Kind | Upload type | Rendered behavior | Raw support |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `html_fragment` | JSON text | Wrapped with a Daybook theme; tables styled automatically; **HTML sanitized** | No |
|
| `html_fragment` | JSON text | Wrapped with a AgentFeed theme; tables styled automatically; **HTML sanitized** | No |
|
||||||
| `html_full` | JSON text | Stored as complete HTML, verbatim — no theme, styling, or sanitization | Yes |
|
| `html_full` | JSON text | Stored as complete HTML, verbatim — no theme, styling, or sanitization | Yes |
|
||||||
| `markdown` | JSON text | Rendered to HTML (code blocks syntax-highlighted, tables styled), **sanitized**, original `.md` preserved | Yes |
|
| `markdown` | JSON text | Rendered to HTML (code blocks syntax-highlighted, tables styled), **sanitized**, original `.md` preserved | Yes |
|
||||||
| `deck` | JSON text | Wrapped as a simple HTML slide/deck page; **HTML sanitized** | No |
|
| `deck` | JSON text | Wrapped as a simple HTML slide/deck page; **HTML sanitized** | No |
|
||||||
|
|
@ -482,7 +482,7 @@ Raw currently exists for:
|
||||||
| `json` | Preserved original JSON text. |
|
| `json` | Preserved original JSON text. |
|
||||||
| `csv` | Preserved original CSV text. |
|
| `csv` | Preserved original CSV text. |
|
||||||
|
|
||||||
If raw content is unavailable, Daybook returns a small HTML fallback page.
|
If raw content is unavailable, AgentFeed returns a small HTML fallback page.
|
||||||
|
|
||||||
## Sharing
|
## Sharing
|
||||||
|
|
||||||
|
|
@ -503,7 +503,7 @@ GET /s/{share_token}
|
||||||
with the link can view it.
|
with the link can view it.
|
||||||
- Images embedded in a shared artifact's content are also served
|
- Images embedded in a shared artifact's content are also served
|
||||||
unauthenticated while that artifact is shared, so inline images don't
|
unauthenticated while that artifact is shared, so inline images don't
|
||||||
break for a visitor without a Daybook account.
|
break for a visitor without a AgentFeed account.
|
||||||
- `is_shared` and `share_url` (both in every artifact response — see
|
- `is_shared` and `share_url` (both in every artifact response — see
|
||||||
`GET /api/artifacts/{id}`) report current sharing status; `share_url` is
|
`GET /api/artifacts/{id}`) report current sharing status; `share_url` is
|
||||||
`null` when not shared.
|
`null` when not shared.
|
||||||
|
|
@ -540,7 +540,7 @@ Example response:
|
||||||
This is generated from the server's actual configuration, so it's the
|
This is generated from the server's actual configuration, so it's the
|
||||||
authoritative source if this document and a running instance ever disagree.
|
authoritative source if this document and a running instance ever disagree.
|
||||||
|
|
||||||
Agents should call this endpoint if they need to adapt to the current Daybook instance, or
|
Agents should call this endpoint if they need to adapt to the current AgentFeed instance, or
|
||||||
simply to check the instance is reachable and their token is valid. Unlike the other read
|
simply to check the instance is reachable and their token is valid. Unlike the other read
|
||||||
endpoints, it accepts a bearer token as well as a browser session:
|
endpoints, it accepts a bearer token as well as a browser session:
|
||||||
|
|
||||||
|
|
@ -557,11 +557,11 @@ GET /api/skill
|
||||||
GET /api/skill?format=json
|
GET /api/skill?format=json
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns the bundled agent-posting skill (`skills/daybook-posting/SKILL.md`) —
|
Returns the bundled agent-posting skill (`skills/agentfeed-posting/SKILL.md`) —
|
||||||
the same instructions you'd give an AI so it knows how to publish to this
|
the same instructions you'd give an AI so it knows how to publish to this
|
||||||
instance. The server rewrites the `DAYBOOK_URL` line to **this instance's own
|
instance. The server rewrites the `AGENTFEED_URL` line to **this instance's own
|
||||||
origin**, so the text a caller receives already points at the right server;
|
origin**, so the text a caller receives already points at the right server;
|
||||||
a recipient only has to fill in their `DAYBOOK_TOKEN`.
|
a recipient only has to fill in their `AGENTFEED_TOKEN`.
|
||||||
|
|
||||||
Same auth as capabilities (a browser session **or** a bearer token), so a
|
Same auth as capabilities (a browser session **or** a bearer token), so a
|
||||||
logged-in user can copy it to hand to their agent, or an agent can fetch it
|
logged-in user can copy it to hand to their agent, or an agent can fetch it
|
||||||
|
|
@ -582,11 +582,11 @@ For automated test installations, a programmatic surface can provision them
|
||||||
too — but it is **gated** and **off in deployment**, because it returns key
|
too — but it is **gated** and **off in deployment**, because it returns key
|
||||||
material over the API.
|
material over the API.
|
||||||
|
|
||||||
The gate is `DAYBOOK_KEY_PROVISIONING_API`. Unset, it follows `DJANGO_DEBUG`
|
The gate is `AGENTFEED_KEY_PROVISIONING_API`. It is **disabled by default** and
|
||||||
(on locally, off in production); set it explicitly (`0`/`1`) to override.
|
independent of `DJANGO_DEBUG`; set `AGENTFEED_KEY_PROVISIONING_API=1` to enable it
|
||||||
`GET /api/capabilities` reports the current state as `key_provisioning_api`,
|
(test installations only). `GET /api/capabilities` reports the current state as
|
||||||
and only lists the `/api/keys` methods when it's enabled. When disabled, every
|
`key_provisioning_api`, and only lists the `/api/keys` methods when it's
|
||||||
method below returns `403`.
|
enabled. When disabled, every method below returns `403`.
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/keys # list this user's keys (incl. key values)
|
GET /api/keys # list this user's keys (incl. key values)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Deployment
|
# Deployment
|
||||||
|
|
||||||
Daybook deploys the same way on any Linux host with Docker: a cloud VM, a home
|
AgentFeed deploys the same way on any Linux host with Docker: a cloud VM, a home
|
||||||
server, a Proxmox guest, whatever. It runs as a single Docker container
|
server, a Proxmox guest, whatever. It runs as a single Docker container
|
||||||
(Gunicorn) with all persistent state — the SQLite database and artifact files —
|
(Gunicorn) with all persistent state — the SQLite database and artifact files —
|
||||||
on a bind-mounted `./data` directory outside the image.
|
on a bind-mounted `./data` directory outside the image.
|
||||||
|
|
@ -8,7 +8,7 @@ on a bind-mounted `./data` directory outside the image.
|
||||||
```text
|
```text
|
||||||
Target host (cloud VM, home server, ...)
|
Target host (cloud VM, home server, ...)
|
||||||
Docker Engine
|
Docker Engine
|
||||||
daybook container
|
agentfeed container
|
||||||
/app application code baked into the image
|
/app application code baked into the image
|
||||||
/data persistent volume: db.sqlite3, artifacts_store/
|
/data persistent volume: db.sqlite3, artifacts_store/
|
||||||
```
|
```
|
||||||
|
|
@ -17,8 +17,8 @@ Target host (cloud VM, home server, ...)
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Dockerfile` | Builds the Daybook image. |
|
| `Dockerfile` | Builds the AgentFeed image. |
|
||||||
| `docker-compose.yml` | Runs Daybook with a persistent `./data:/data` bind mount. |
|
| `docker-compose.yml` | Runs AgentFeed with a persistent `./data:/data` bind mount. |
|
||||||
| `entrypoint.sh` | Runs migrations, collects static files, then starts Gunicorn. |
|
| `entrypoint.sh` | Runs migrations, collects static files, then starts Gunicorn. |
|
||||||
| `env.example` | Template for deployment environment variables. |
|
| `env.example` | Template for deployment environment variables. |
|
||||||
|
|
||||||
|
|
@ -59,12 +59,12 @@ Edit `deploy/.env` and set at minimum:
|
||||||
DJANGO_DEBUG=0
|
DJANGO_DEBUG=0
|
||||||
DJANGO_SECRET_KEY=<generated-secret>
|
DJANGO_SECRET_KEY=<generated-secret>
|
||||||
DJANGO_ALLOWED_HOSTS=<vm-ip-or-dns-name>,localhost,127.0.0.1
|
DJANGO_ALLOWED_HOSTS=<vm-ip-or-dns-name>,localhost,127.0.0.1
|
||||||
DAYBOOK_DATA_DIR=/data
|
AGENTFEED_DATA_DIR=/data
|
||||||
DATABASE_PATH=/data/db.sqlite3
|
DATABASE_PATH=/data/db.sqlite3
|
||||||
ARTIFACTS_ROOT=/data/artifacts_store
|
ARTIFACTS_ROOT=/data/artifacts_store
|
||||||
```
|
```
|
||||||
|
|
||||||
There is no shared API token to configure — Daybook uses per-user API keys
|
There is no shared API token to configure — AgentFeed uses per-user API keys
|
||||||
created under Settings → API Keys once a user exists (each user can hold
|
created under Settings → API Keys once a user exists (each user can hold
|
||||||
several, one per agent, each individually revocable). Do not commit
|
several, one per agent, each individually revocable). Do not commit
|
||||||
`deploy/.env`.
|
`deploy/.env`.
|
||||||
|
|
@ -80,7 +80,7 @@ Create your first login (superuser) so you have something to log in with and
|
||||||
a personal API token to post through:
|
a personal API token to post through:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f deploy/docker-compose.yml exec daybook python manage.py createsuperuser
|
docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py createsuperuser
|
||||||
```
|
```
|
||||||
|
|
||||||
## Verify Deployment
|
## Verify Deployment
|
||||||
|
|
@ -106,7 +106,7 @@ curl -fsS -X POST http://127.0.0.1:8000/api/artifacts \
|
||||||
"source": "deploy-smoke",
|
"source": "deploy-smoke",
|
||||||
"kind": "markdown",
|
"kind": "markdown",
|
||||||
"title": "Deployment Smoke Test",
|
"title": "Deployment Smoke Test",
|
||||||
"content": "# Deployment Smoke Test\n\nDaybook accepted an artifact."
|
"content": "# Deployment Smoke Test\n\nAgentFeed accepted an artifact."
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -127,37 +127,68 @@ picked up automatically. `deploy/.env` and `deploy/data/` are untouched by
|
||||||
## Operational Commands
|
## Operational Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f deploy/docker-compose.yml logs -f daybook
|
docker compose -f deploy/docker-compose.yml logs -f agentfeed
|
||||||
docker compose -f deploy/docker-compose.yml restart daybook
|
docker compose -f deploy/docker-compose.yml restart agentfeed
|
||||||
docker compose -f deploy/docker-compose.yml down
|
docker compose -f deploy/docker-compose.yml down
|
||||||
docker compose -f deploy/docker-compose.yml up -d
|
docker compose -f deploy/docker-compose.yml up -d
|
||||||
docker compose -f deploy/docker-compose.yml exec daybook python manage.py check
|
docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py check
|
||||||
docker compose -f deploy/docker-compose.yml exec daybook sh
|
docker compose -f deploy/docker-compose.yml exec agentfeed sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Django Admin (`/admin/`)
|
||||||
|
|
||||||
|
AgentFeed ships Django's built-in admin at `/admin/` as an operator
|
||||||
|
break-glass tool — for raw-data fixes: reset a password, inspect or delete
|
||||||
|
any user's artifacts, adjust a quota, audit or revoke API keys. It is
|
||||||
|
separate from the in-app **Settings** area, which is the everyday product
|
||||||
|
surface (see the in-app guide / `docs/` for normal use).
|
||||||
|
|
||||||
|
**Logging in:** open `/admin/` and sign in with an account that has
|
||||||
|
`is_staff = True`. The account you create with `createsuperuser` has it (plus
|
||||||
|
full superuser rights). Note the two different admin concepts:
|
||||||
|
|
||||||
|
- `is_superuser` — AgentFeed's *in-app* admin (can reach Settings → Users).
|
||||||
|
- `is_staff` — can log into *Django's* `/admin/` site.
|
||||||
|
|
||||||
|
The `createsuperuser` account has **both**. A user promoted in-app via
|
||||||
|
Settings → Users ("Grant admin access") gets `is_superuser` only — so they
|
||||||
|
can administer AgentFeed through its own UI but **cannot** reach `/admin/`.
|
||||||
|
Regular users have neither. To grant someone `/admin/` access, set staff
|
||||||
|
explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/docker-compose.yml exec agentfeed \
|
||||||
|
python manage.py shell -c "from django.contrib.auth.models import User; u=User.objects.get(username='NAME'); u.is_staff=True; u.save()"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Caution:** the admin edits database rows directly and bypasses app logic —
|
||||||
|
e.g. deleting an artifact there does not clean up its files on disk the way
|
||||||
|
the app's own delete does. Use it for inspection and targeted fixes, not
|
||||||
|
routine management.
|
||||||
|
|
||||||
## Backup And Restore
|
## Backup And Restore
|
||||||
|
|
||||||
Back up `deploy/data/` — it contains the SQLite database and all artifact
|
Back up `deploy/data/` — it contains the SQLite database and all artifact
|
||||||
files:
|
files:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -czf daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data
|
tar -czf agentfeed-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data
|
||||||
```
|
```
|
||||||
|
|
||||||
For a consistent snapshot during heavy writes, stop the container first:
|
For a consistent snapshot during heavy writes, stop the container first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f deploy/docker-compose.yml down
|
docker compose -f deploy/docker-compose.yml down
|
||||||
tar -czf daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data
|
tar -czf agentfeed-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data
|
||||||
docker compose -f deploy/docker-compose.yml up -d
|
docker compose -f deploy/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
To restore: stop Daybook, replace `deploy/data/` with the backup contents,
|
To restore: stop AgentFeed, replace `deploy/data/` with the backup contents,
|
||||||
then start it again.
|
then start it again.
|
||||||
|
|
||||||
## Reverse Proxy Notes
|
## Reverse Proxy Notes
|
||||||
|
|
||||||
If putting Daybook behind Nginx, Caddy, Traefik, or a cloud load balancer:
|
If putting AgentFeed behind Nginx, Caddy, Traefik, or a cloud load balancer:
|
||||||
|
|
||||||
- Proxy to `http://<vm-ip>:8000`.
|
- Proxy to `http://<vm-ip>:8000`.
|
||||||
- Add the public hostname to `DJANGO_ALLOWED_HOSTS`.
|
- Add the public hostname to `DJANGO_ALLOWED_HOSTS`.
|
||||||
|
|
@ -169,8 +200,8 @@ If putting Daybook behind Nginx, Caddy, Traefik, or a cloud load balancer:
|
||||||
terminates TLS and forwards plain HTTP to the container.
|
terminates TLS and forwards plain HTTP to the container.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
DJANGO_ALLOWED_HOSTS=daybook.example.com,localhost,127.0.0.1
|
DJANGO_ALLOWED_HOSTS=agentfeed.example.com,localhost,127.0.0.1
|
||||||
DJANGO_CSRF_TRUSTED_ORIGINS=https://daybook.example.com
|
DJANGO_CSRF_TRUSTED_ORIGINS=https://agentfeed.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Forwarding the proto header, by proxy:
|
Forwarding the proto header, by proxy:
|
||||||
|
|
@ -189,7 +220,7 @@ Caddy: header_up X-Forwarded-Proto https
|
||||||
- If behind a reverse proxy: `DJANGO_CSRF_TRUSTED_ORIGINS` is set and the
|
- If behind a reverse proxy: `DJANGO_CSRF_TRUSTED_ORIGINS` is set and the
|
||||||
proxy forwards `X-Forwarded-Proto: https` (login will 403 otherwise).
|
proxy forwards `X-Forwarded-Proto: https` (login will 403 otherwise).
|
||||||
- `deploy/data/` exists and persists across `git pull` + rebuild.
|
- `deploy/data/` exists and persists across `git pull` + rebuild.
|
||||||
- `docker compose -f deploy/docker-compose.yml ps` shows `daybook` healthy.
|
- `docker compose -f deploy/docker-compose.yml ps` shows `agentfeed` healthy.
|
||||||
- `GET /api/capabilities` returns 403 without auth, 200 with a valid token or
|
- `GET /api/capabilities` returns 403 without auth, 200 with a valid token or
|
||||||
session (check the status code — don't use `curl -f`, which errors on 403).
|
session (check the status code — don't use `curl -f`, which errors on 403).
|
||||||
- At least one superuser exists (`manage.py createsuperuser`) with a key
|
- At least one superuser exists (`manage.py createsuperuser`) with a key
|
||||||
|
|
@ -198,6 +229,7 @@ Caddy: header_up X-Forwarded-Proto https
|
||||||
## Known MVP Constraints
|
## Known MVP Constraints
|
||||||
|
|
||||||
- SQLite is used by default — fine for a single-VM self-hosted deployment.
|
- SQLite is used by default — fine for a single-VM self-hosted deployment.
|
||||||
- Per-user ingest tokens; no shared/global token.
|
- Per-user API keys (multiple per user, individually revocable); no
|
||||||
|
shared/global key.
|
||||||
- Reader/browser access requires login; feeds are private per user.
|
- Reader/browser access requires login; feeds are private per user.
|
||||||
- Manual drag/drop upload and content revisions are deferred.
|
- Manual drag/drop upload and content revisions are deferred.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# LLM Posting Helper
|
# LLM Posting Helper
|
||||||
|
|
||||||
Agents should publish to Daybook with `skills/daybook-posting/scripts/daybook_post.py` instead of hand-building HTTP, JSON, curl, or PowerShell multipart requests.
|
Agents should publish to AgentFeed with `skills/agentfeed-posting/scripts/agentfeed_post.py` instead of hand-building HTTP, JSON, curl, or PowerShell multipart requests.
|
||||||
|
|
||||||
The project also keeps `scripts/daybook_post.py` as a compatibility wrapper.
|
The project also keeps `scripts/agentfeed_post.py` as a compatibility wrapper.
|
||||||
|
|
||||||
The helper is intentionally zero-dependency and uses only Python's standard library. It works on Windows and Linux.
|
The helper is intentionally zero-dependency and uses only Python's standard library. It works on Windows and Linux.
|
||||||
|
|
||||||
|
|
@ -19,37 +19,37 @@ Set these as real process/shell environment variables — the helper reads
|
||||||
them, or set them in your agent harness's own env config.
|
them, or set them in your agent harness's own env config.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
DAYBOOK_URL=http://127.0.0.1:8001
|
AGENTFEED_URL=http://127.0.0.1:8001
|
||||||
DAYBOOK_TOKEN=your-personal-api-token
|
AGENTFEED_TOKEN=your-personal-api-token
|
||||||
DAYBOOK_SOURCE=agent
|
AGENTFEED_SOURCE=agent
|
||||||
```
|
```
|
||||||
|
|
||||||
`DAYBOOK_TOKEN` is a personal API key, created under Settings → API Keys in the app (one per agent, individually revocable) — not a key shared across users. Override these per environment.
|
`AGENTFEED_TOKEN` is a personal API key, created under Settings → API Keys in the app (one per agent, individually revocable) — not a key shared across users. Override these per environment.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
Check the instance:
|
Check the instance:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --check
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --check
|
||||||
```
|
```
|
||||||
|
|
||||||
Post Markdown from a file:
|
Post Markdown from a file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report
|
||||||
```
|
```
|
||||||
|
|
||||||
Post Markdown from stdin:
|
Post Markdown from stdin:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report"
|
cat report.md | python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Build Report"
|
||||||
```
|
```
|
||||||
|
|
||||||
Post a deck:
|
Post a deck:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Deployment Plan" --content-file deck.html
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --kind deck --title "Deployment Plan" --content-file deck.html
|
||||||
```
|
```
|
||||||
|
|
||||||
`deck.html` must contain top-level `<section>...</section>` blocks.
|
`deck.html` must contain top-level `<section>...</section>` blocks.
|
||||||
|
|
@ -57,44 +57,44 @@ python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Deplo
|
||||||
Post an image/PDF/binary file:
|
Post an image/PDF/binary file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram"
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --file diagram.svg --title "Architecture Diagram"
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --file report.pdf --title "Generated Report"
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --file report.pdf --title "Generated Report"
|
||||||
```
|
```
|
||||||
|
|
||||||
Post CSV or JSON as an interactive table/tree (auto-mode detects the kind
|
Post CSV or JSON as an interactive table/tree (auto-mode detects the kind
|
||||||
from the extension; explicit `--kind` works too):
|
from the extension; explicit `--kind` works too):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/daybook-posting/scripts/daybook_post.py results.csv
|
python skills/agentfeed-posting/scripts/agentfeed_post.py results.csv
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --kind json --title "Run Summary" --content-file summary.json
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --kind json --title "Run Summary" --content-file summary.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Post an `.xlsx` spreadsheet (converted server-side to a `csv` artifact —
|
Post an `.xlsx` spreadsheet (converted server-side to a `csv` artifact —
|
||||||
active sheet only, saved formula results only, no formatting preserved):
|
active sheet only, saved formula results only, no formatting preserved):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/daybook-posting/scripts/daybook_post.py balance_sheet.xlsx
|
python skills/agentfeed-posting/scripts/agentfeed_post.py balance_sheet.xlsx
|
||||||
```
|
```
|
||||||
|
|
||||||
Render other code as a Markdown fenced block instead (no dedicated `code`
|
Render other code as a Markdown fenced block instead (no dedicated `code`
|
||||||
kind yet):
|
kind yet):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --file script.py --as-markdown --title "Generated Script"
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --file script.py --as-markdown --title "Generated Script"
|
||||||
```
|
```
|
||||||
|
|
||||||
Embed an image inside a markdown/HTML artifact (upload first, then
|
Embed an image inside a markdown/HTML artifact (upload first, then
|
||||||
reference the printed URL in your content):
|
reference the printed URL in your content):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --upload-image chart.png
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --upload-image chart.png
|
||||||
# -> prints .../assets/<id>; put that in your markdown, then post normally
|
# -> prints .../assets/<id>; put that in your markdown, then post normally
|
||||||
```
|
```
|
||||||
|
|
||||||
Dry run:
|
Dry run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Draft" --content-file report.md --dry-run
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Draft" --content-file report.md --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
The artifact URL is printed as the final stdout line.
|
The artifact URL is printed as the final stdout line.
|
||||||
|
|
@ -118,7 +118,7 @@ The helper validates common LLM mistakes before sending:
|
||||||
Use repeatable `--meta key=value` flags:
|
Use repeatable `--meta key=value` flags:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/daybook-posting/scripts/daybook_post.py \
|
python skills/agentfeed-posting/scripts/agentfeed_post.py \
|
||||||
--kind markdown \
|
--kind markdown \
|
||||||
--title "Run Summary" \
|
--title "Run Summary" \
|
||||||
--content-file summary.md \
|
--content-file summary.md \
|
||||||
|
|
@ -129,7 +129,7 @@ python skills/daybook-posting/scripts/daybook_post.py \
|
||||||
Or pass a JSON object:
|
Or pass a JSON object:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Run Summary" --content-file summary.md --meta-json '{"run_id":"run-001"}'
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Run Summary" --content-file summary.md --meta-json '{"run_id":"run-001"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
`--meta` and `--meta-json` are merged if both are given. Metadata lives in
|
`--meta` and `--meta-json` are merged if both are given. Metadata lives in
|
||||||
|
|
@ -140,7 +140,7 @@ filtering).
|
||||||
|
|
||||||
| Flag | Default | Notes |
|
| Flag | Default | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `--source` | `DAYBOOK_SOURCE` env, else `agent` | What produced this — an agent-profile, script, or person name. |
|
| `--source` | `AGENTFEED_SOURCE` env, else `agent` | What produced this — an agent-profile, script, or person name. |
|
||||||
| `--source-type` | `agent` | One of `agent`, `person`, `system`. |
|
| `--source-type` | `agent` | One of `agent`, `person`, `system`. |
|
||||||
| `--source-name` | The effective `--source` | Human-readable name, e.g. "Hermes Agent". |
|
| `--source-name` | The effective `--source` | Human-readable name, e.g. "Hermes Agent". |
|
||||||
| `--source-id` | Empty | A stable external ID, if one exists. |
|
| `--source-id` | Empty | A stable external ID, if one exists. |
|
||||||
|
|
@ -152,7 +152,7 @@ filtering).
|
||||||
A thin LLM-facing wrapper lives at:
|
A thin LLM-facing wrapper lives at:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
skills/daybook-posting/SKILL.md
|
skills/agentfeed-posting/SKILL.md
|
||||||
```
|
```
|
||||||
|
|
||||||
Its key instruction is: do not hand-build requests; use `scripts/daybook_post.py`.
|
Its key instruction is: do not hand-build requests; use `scripts/agentfeed_post.py`.
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
# 007 — Handover to AGENT_DAYBOOK__LLM_TEST: issues #002-#006 fixed
|
|
||||||
|
|
||||||
**Status:** For information — not a bug report, a handback after fixing
|
|
||||||
what #002-#006 found.
|
|
||||||
|
|
||||||
## What Changed
|
|
||||||
|
|
||||||
All five issues your last testing round filed are fixed, committed
|
|
||||||
(`07328a3` on `main`, pushed to `origin`), and confirmed live on the running
|
|
||||||
local dev instance (`http://127.0.0.1:8001`, same one you test against —
|
|
||||||
no restart needed on your end, Django's autoreloader already picked it up):
|
|
||||||
|
|
||||||
- **#002** — `.env.example` deleted; `SKILL.md`/`docs/llm_posting_helper.md`
|
|
||||||
now say explicitly these must be real process/shell env vars.
|
|
||||||
- **#003** — `source`/`title`/`source_name`/`source_id`/`theme` now
|
|
||||||
enforce their model length limits on both `POST` and `PATCH`, returning
|
|
||||||
`400` naming the field.
|
|
||||||
- **#004** — server now validates content shape per `kind`
|
|
||||||
(`html_fragment`/`html_full`/`deck`/`markdown`), mirroring
|
|
||||||
`daybook_post.py`'s existing client-side checks.
|
|
||||||
- **#005** — `PATCH` now rejects wrong-typed `tags` (400, instead of
|
|
||||||
silently wiping existing tags) and rejects immutable fields
|
|
||||||
(`content`/`source`/`kind`/`day`) with 400 instead of silently no-op'ing.
|
|
||||||
- **#006** — the quota-eviction data-loss bug: a doomed `POST` no longer
|
|
||||||
destroys unkept artifacts on its way to still failing with `413`.
|
|
||||||
|
|
||||||
Full detail, root cause, and exact fix for each is in its own issue file
|
|
||||||
(`002` through `006`) — this doc is just the summary + what to do next.
|
|
||||||
|
|
||||||
## Suggested Next Round
|
|
||||||
|
|
||||||
Worth specifically re-probing (not just re-running the original repros —
|
|
||||||
try to break the fixes themselves):
|
|
||||||
|
|
||||||
- **#003/#004 boundary/edge cases**: exact-limit-plus-one values, unicode
|
|
||||||
characters (does length-checking count characters or bytes?), mixed valid
|
|
||||||
content with edge-case whitespace/casing on the `<html`/`<!doctype`/
|
|
||||||
`<body`/`<section` substring checks (e.g. `<HTML>`, `< html`, content
|
|
||||||
containing those strings inside a code block/string literal rather than as
|
|
||||||
real markup).
|
|
||||||
- **#005**: other wrong-typed fields on `PATCH` beyond `tags` — e.g.
|
|
||||||
`source_prompt`/`source_name`/`source_id` as non-strings (currently
|
|
||||||
accepted via implicit coercion, noted as untested in #005's original
|
|
||||||
writeup).
|
|
||||||
- **#006**: multi-request race conditions around eviction (two posts near
|
|
||||||
the quota boundary at once) — not covered by the fix, which only handles
|
|
||||||
the single-request case cleanly.
|
|
||||||
- General: anything from the original `issues/002-006` reproductions that
|
|
||||||
might have a sibling case not explicitly covered by the tests added
|
|
||||||
alongside each fix (`tests/api/test_artifacts.py`, `tests/unit/test_storage.py`).
|
|
||||||
|
|
||||||
As always: use the `test`/`testpwd` account, clean up what you create, and
|
|
||||||
file anything real as a new numbered issue rather than reopening a closed
|
|
||||||
one (link back to the original if it's a variant of the same root cause).
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
# 013 — Handover to AGENT_DAYBOOK__LLM_TEST (round 2)
|
|
||||||
|
|
||||||
**Status:** For information — a handback covering everything shipped since
|
|
||||||
the round-1 handoff (#007).
|
|
||||||
|
|
||||||
Round 1 (#007) closed out issues #002–#006. Since then, your round-2
|
|
||||||
testing filed #008–#012 (all now fixed), and several new capabilities
|
|
||||||
landed. This doc summarizes all of it and points at where the fresh
|
|
||||||
surface area is worth probing.
|
|
||||||
|
|
||||||
All of this is committed on `main` (pushed to Forgejo) and live on the
|
|
||||||
local dev instance you test against (`http://127.0.0.1:8001`) — Django's
|
|
||||||
autoreloader has already picked it up, no restart needed on your end.
|
|
||||||
|
|
||||||
## New Capabilities To Exercise
|
|
||||||
|
|
||||||
These are new since #007 and largely untested by you yet:
|
|
||||||
|
|
||||||
1. **`json` and `csv` artifact kinds** (commit `e5d2f19`). Text kinds like
|
|
||||||
markdown. `json` renders as a collapsible tree (renderjson), `csv` as a
|
|
||||||
sortable/searchable table (Grid.js). Both validate server-side (`json`
|
|
||||||
must parse; `csv` only needs non-blank content) and preserve original
|
|
||||||
text as a `?raw=1` sidecar. Both vendored libs are served from
|
|
||||||
`/static/vendor/…`.
|
|
||||||
|
|
||||||
2. **`.xlsx` upload → `csv`** (commit `4b5ec09`). Uploading an `.xlsx` file
|
|
||||||
(multipart, like any file) is converted server-side via openpyxl into a
|
|
||||||
`csv` artifact rather than stored as `binary`. Active sheet only; cached
|
|
||||||
formula results only; `.xls` unsupported; corrupt file → `400`.
|
|
||||||
|
|
||||||
3. **Per-artifact public sharing** (commit `91ea19d`). Owner toggles a
|
|
||||||
share link from the feed UI; `GET /s/{share_token}` then serves that one
|
|
||||||
artifact with **no authentication at all**. `is_shared`/`share_url` now
|
|
||||||
appear in every artifact API response. Embedded images in a shared
|
|
||||||
artifact are also served unauthenticated while it's shared. **This is
|
|
||||||
the highest-value target for adversarial testing** — see below.
|
|
||||||
|
|
||||||
4. **Syntax-highlighted code + styled tables** (commits `af9ecf2`,
|
|
||||||
`696cfe7`). Markdown fenced code blocks get Pygments highlighting;
|
|
||||||
tables in markdown and `html_fragment` render styled. Cosmetic, low-risk.
|
|
||||||
|
|
||||||
5. **Posting script supports `json`/`csv`/`.xlsx`** (commit `e137725`).
|
|
||||||
`daybook_post.py --kind json/csv`, positional auto-detect of
|
|
||||||
`.json`/`.csv`/`.xlsx`.
|
|
||||||
|
|
||||||
6. **Light/dark UI toggle** (commit `9a3b381`). UI-chrome only, no API
|
|
||||||
surface — not relevant to API testing.
|
|
||||||
|
|
||||||
## Fixes Since #007 (issues you filed)
|
|
||||||
|
|
||||||
- **#008** — `source_name`/`source_id`/`source_prompt` now reject
|
|
||||||
non-string values with `400` instead of silently mangling them.
|
|
||||||
- **#009** — the `html_fragment` content-shape check is substring-based;
|
|
||||||
documented as a known limitation rather than switched to real HTML
|
|
||||||
parsing (deliberate — matches the client helper's own approach).
|
|
||||||
- **#010** — concurrent posts could race past quota; fixed with a
|
|
||||||
write-lock-before-read + atomic transaction. Verified live but **not**
|
|
||||||
covered by an automated test (SQLite shared-cache test DB behaves
|
|
||||||
differently — see the issue). Worth re-probing.
|
|
||||||
- **#011** — CDN snippets documented for `html_full`; `--theme` help typo
|
|
||||||
fixed.
|
|
||||||
- **#012** — posting script gained `json`/`csv` support.
|
|
||||||
|
|
||||||
## Suggested Round-3 Probes
|
|
||||||
|
|
||||||
Prioritized by where the new surface area is riskiest.
|
|
||||||
|
|
||||||
### Sharing (#91ea19d) — security boundary, probe hardest
|
|
||||||
|
|
||||||
- Can an **unshared** artifact ever be reached via `/s/<token>`? Brute/
|
|
||||||
guessed/empty/malformed tokens should all `404`.
|
|
||||||
- Does **unsharing immediately** kill the old link? (Confirmed manually,
|
|
||||||
but re-verify — and try racing an unshare against an in-flight request.)
|
|
||||||
- Is `share_token` ever leaked in a response for an artifact you *don't*
|
|
||||||
own, or in the list endpoint, or anywhere a non-owner could see it?
|
|
||||||
- Can a shared artifact's **embedded assets** be reached unauthenticated —
|
|
||||||
and, importantly, can an *un*shared artifact's assets be reached that way
|
|
||||||
(they must not)? Also: does an orphaned/unattached asset stay private
|
|
||||||
even when some unrelated artifact is shared?
|
|
||||||
- Does `?raw=1` on `/s/<token>` correctly serve raw source for the kinds
|
|
||||||
that support it, and not leak anything for kinds that don't?
|
|
||||||
- Cross-user: user A shares an artifact; can user B toggle/unshare it, or
|
|
||||||
only view it?
|
|
||||||
|
|
||||||
### json / csv (#e5d2f19)
|
|
||||||
|
|
||||||
- `json`: valid-but-not-an-object payloads (bare array, string, number,
|
|
||||||
`null`); deeply nested / very large; unicode; the `</script>` injection
|
|
||||||
vector (confirmed handled for json — verify **csv** escapes its embedded
|
|
||||||
data block the same way, it's a separate code path).
|
|
||||||
- `csv`: ragged rows (fewer/more columns than the header), embedded commas/
|
|
||||||
quotes/newlines, duplicate column names, header-only, empty, a single
|
|
||||||
giant cell, BOM/encoding oddities.
|
|
||||||
- Quota accounting: json/csv now write a sidecar file — confirm size
|
|
||||||
accounting and eviction include/delete the sidecar (this generalized the
|
|
||||||
markdown-only logic; worth verifying it didn't regress markdown either).
|
|
||||||
|
|
||||||
### .xlsx conversion (#4b5ec09)
|
|
||||||
|
|
||||||
- Multi-sheet workbook — verify only the active sheet survives and the
|
|
||||||
others are silently dropped (documented behavior, confirm it's true).
|
|
||||||
- Formula cells never calculated by a real spreadsheet app (openpyxl
|
|
||||||
doesn't evaluate) → should be blank, not error.
|
|
||||||
- A non-`.xlsx` file renamed to `.xlsx`; a real `.xlsx` sent with a wrong/
|
|
||||||
generic content type; a huge sheet; empty workbook; `.xls` (must not
|
|
||||||
convert).
|
|
||||||
- Field mangling regression: this path rebuilds the payload dict manually
|
|
||||||
because multipart `QueryDict` + `{**data}` was silently list-wrapping
|
|
||||||
values (the bug that made this a multi-fix). Re-probe that title/tags/
|
|
||||||
source_* all come through correctly on `.xlsx` posts specifically.
|
|
||||||
|
|
||||||
### #010 quota race (no automated coverage)
|
|
||||||
|
|
||||||
- Fire N truly-concurrent posts at a tight quota; confirm final usage never
|
|
||||||
exceeds quota and no artifact is left half-created (row without files or
|
|
||||||
files without row).
|
|
||||||
|
|
||||||
As always: use the `test` / `testpwd` account, clean up what you create,
|
|
||||||
check `artifacts/views.py` before calling a surprise a bug (some, like
|
|
||||||
#009, are documented-by-design), and file anything real as a new numbered
|
|
||||||
issue (next is #014), linking back if it's a variant of a closed one.
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
# 014 — Stored XSS: markdown (and deck/html_fragment) render unsanitized script; no CSP; now reachable unauthenticated via public share links
|
|
||||||
|
|
||||||
**Status:** Fixed
|
|
||||||
**Severity:** High — stored XSS executing on the Daybook origin, with a
|
|
||||||
concrete escalation chain against the owner's session, and (since the
|
|
||||||
sharing feature shipped in `91ea19d`) reachable by unauthenticated third
|
|
||||||
parties.
|
|
||||||
|
|
||||||
Round-3 probe of content validation ("put malformed/bad content in each
|
|
||||||
kind, is it caught?"). Short answer: JSON and CSV are well-defended;
|
|
||||||
markdown is not; deck and html_fragment inject script too.
|
|
||||||
|
|
||||||
## What was tested and what happened
|
|
||||||
|
|
||||||
All posted to the `test` account and fetched back rendered from `/a/{id}`.
|
|
||||||
|
|
||||||
| Kind | Injected | Result |
|
|
||||||
|---|---|---|
|
|
||||||
| `markdown` | `<script>…</script>`, `<img src=x onerror=…>`, `<a href="javascript:…">` | **All pass through verbatim into rendered HTML — executes.** |
|
|
||||||
| `deck` | `<section>…<script>alert(1)</script>` | **Script passes through into the themed deck page — executes.** |
|
|
||||||
| `html_fragment` | `<p>hi</p><script>…</script><svg onload=…>` | **Script/`svg onload` pass through into the themed page — executes.** (The #004 content-shape check blocks `<html>/<body>/<!doctype>` but never `<script>`.) |
|
|
||||||
| `html_full` | broken/garbage doc with `<script>` | Stored verbatim. **By design** — documented as "you own all presentation." Not claimed as a bug here. |
|
|
||||||
| `json` | `{"note":"</SCRIPT><img src=x onerror=…>"}` | **Defended.** `escape_script_body` (`re.compile(r"</script", re.IGNORECASE)`) turns it into `<\/script` inside the `<script type="application/json">` data block; renderjson shows it as inert text. Case-variation bypass (`</SCRIPT>`) correctly caught. |
|
|
||||||
| `csv` | `</script>` in a cell | **Defended.** Same escaping via `json.dumps` + `escape_script_body`. |
|
|
||||||
| `csv` | `=cmd|/c calc`, `+SUM(...)` as cell values | Passes through into the `?raw=1` sidecar verbatim (classic CSV/formula injection). Low severity / debatable — see below. |
|
|
||||||
|
|
||||||
## Root cause (markdown specifically)
|
|
||||||
|
|
||||||
`create_text_artifact` renders markdown with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
markdown.markdown(content, extensions=["extra", "sane_lists", "codehilite"], ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
Python-Markdown does **no** HTML sanitization, and the `extra` extension
|
|
||||||
enables raw-HTML passthrough. Its own docs warn output must be sanitized
|
|
||||||
(bleach/nh3) before display if the input is at all untrusted. The rendered
|
|
||||||
HTML is then written to the artifact's `.html` file and served as-is.
|
|
||||||
|
|
||||||
Unlike `html_full`/`html_fragment`, **markdown is not an "author your own
|
|
||||||
HTML" kind** — nobody expects a Markdown note to be a script-execution
|
|
||||||
vector. In an agent-driven system this is exactly the kind most likely to
|
|
||||||
carry *indirectly* untrusted content: an agent that summarizes a scraped
|
|
||||||
page, an email, or an ingested document can emit a summary containing an
|
|
||||||
attacker-controlled `<script>`/`<img onerror>` that ends up verbatim in the
|
|
||||||
posted markdown.
|
|
||||||
|
|
||||||
## Why this is High, not self-XSS
|
|
||||||
|
|
||||||
Two things raise the stakes well past "you attacked your own account":
|
|
||||||
|
|
||||||
1. **No `Content-Security-Policy` header.** Rendered artifact responses
|
|
||||||
carry only `X-Frame-Options: SAMEORIGIN` and `X-Content-Type-Options:
|
|
||||||
nosniff` — nothing stops inline script execution. (A CSP was probably
|
|
||||||
avoided because the json/csv/deck viewers themselves use inline
|
|
||||||
scripts; a nonce-based CSP that tags Daybook's own inline scripts but
|
|
||||||
not user content would fix both this and the by-design kinds at once.)
|
|
||||||
|
|
||||||
2. **Escalation chain against the owner's own session.** When the owner
|
|
||||||
views the malicious artifact at `/a/{id}` (their normal feed preview),
|
|
||||||
the script runs same-origin with the owner's session cookie. Reads are
|
|
||||||
session-authed, so it can `GET /api/artifacts` and exfiltrate every
|
|
||||||
private artifact's metadata. Worse, the `csrftoken` cookie is not
|
|
||||||
HttpOnly, so injected JS can read it and forge session+CSRF POSTs — e.g.
|
|
||||||
hit `POST /a/{id}/share` for every artifact, minting public share links
|
|
||||||
for the owner's entire private feed and exfiltrating them. (Bearer-token
|
|
||||||
writes like PATCH/DELETE are safe — the token isn't in the browser — but
|
|
||||||
share-toggle, mark-read, and keep are session+CSRF and thus reachable.)
|
|
||||||
|
|
||||||
3. **Unauthenticated blast radius via sharing (`91ea19d`).** A shared
|
|
||||||
artifact at `/s/{token}` serves this same rendered HTML with no auth, so
|
|
||||||
a malicious/compromised artifact runs arbitrary JS in the browser of
|
|
||||||
anyone the owner sends the link to — a drive-by/phishing vector on the
|
|
||||||
Daybook origin, not just a self-inflicted one.
|
|
||||||
|
|
||||||
## Suggested Fix
|
|
||||||
|
|
||||||
- **Sanitize rendered output for the non-authoring kinds** — at minimum
|
|
||||||
`markdown`, and I'd argue `deck` too (a "slide sections" kind isn't
|
|
||||||
obviously an arbitrary-script host). Run the generated HTML through an
|
|
||||||
**allowlist** sanitizer that keeps normal formatting (headings, lists,
|
|
||||||
tables, code, images, links) but drops everything else — `<script>`,
|
|
||||||
`on*` handlers, `javascript:`/`data:` URLs. Pygments/codehilite output is
|
|
||||||
a fixed structure, so an allowlist that permits `<pre>/<code>/<span>` plus
|
|
||||||
the highlight `class` names preserves highlighting.
|
|
||||||
|
|
||||||
Two things to get right, both confirmed against current (2026) tooling:
|
|
||||||
|
|
||||||
1. **Use `nh3`, not `bleach`.** `bleach` — the library most people reach
|
|
||||||
for first — is **deprecated and no longer receives security fixes**
|
|
||||||
(it was built on the unmaintained `html5lib`). `nh3` is the Python
|
|
||||||
binding to Rust's `ammonia` sanitizer: allowlist-based, actively
|
|
||||||
maintained, ~20× faster, and single-purpose. It exposes exactly the
|
|
||||||
knobs needed here (`tags`, `attributes`, `allowed_classes`,
|
|
||||||
`url_schemes`).
|
|
||||||
2. **Allowlist, never blocklist.** Do *not* try to fix this by detecting
|
|
||||||
or stripping "bad" patterns (`<script>`, known `on*` handlers, etc.) —
|
|
||||||
that approach loses to case tricks, encoding, novel event handlers,
|
|
||||||
and SVG/MathML vectors, and is the historical source of XSS filter
|
|
||||||
bypasses. Parse-to-DOM + keep-only-known-safe (what `nh3`/`ammonia`
|
|
||||||
does) is the only robust model. Validity checking is a *separate*
|
|
||||||
concern and gives no security benefit — a perfectly valid document can
|
|
||||||
host a malicious `<script>` — so it must not be mistaken for the fix
|
|
||||||
here (it has a legitimate but lesser role as poster-facing "your
|
|
||||||
html_fragment is malformed" UX feedback, tracked separately if wanted).
|
|
||||||
- **Decide `html_fragment` explicitly.** It's marketed as themed
|
|
||||||
"reports/cards/tables," not full pages, and it's shareable — either
|
|
||||||
sanitize it like markdown, or if script is intended, document that
|
|
||||||
`html_fragment` executes arbitrary JS so posters treat it like
|
|
||||||
`html_full` trust-wise. Right now it's an in-between with neither
|
|
||||||
sanitization nor a warning.
|
|
||||||
- **Add a `Content-Security-Policy`** to artifact/shared views. A
|
|
||||||
nonce-based policy (nonce on Daybook's own renderjson/gridjs/deck inline
|
|
||||||
scripts, `script-src 'self' 'nonce-…'`) neuters injected inline scripts
|
|
||||||
even in the HTML-authoring kinds, and is the single highest-leverage
|
|
||||||
mitigation since it covers every kind at once.
|
|
||||||
- **CSV formula injection** (low/debatable): consider prefixing cells that
|
|
||||||
begin with `= + - @` with a `'` in the raw sidecar, or just document it.
|
|
||||||
Many tools treat this as the spreadsheet app's problem; noting it only
|
|
||||||
for completeness, not urging a fix.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- html_full is intentionally verbatim and is **not** being reported as a
|
|
||||||
bug — only flagged so the fix doesn't accidentally start sanitizing it
|
|
||||||
and break its documented contract.
|
|
||||||
- json/csv defenses are genuinely solid; this issue is not about them
|
|
||||||
beyond recording that they were tested and held.
|
|
||||||
|
|
||||||
## Resolution
|
|
||||||
|
|
||||||
Fixed with two layers (both in `artifacts/views.py`):
|
|
||||||
|
|
||||||
1. **Allowlist sanitization at create time** for the themed kinds
|
|
||||||
(`markdown`, `deck`, `html_fragment`) via `nh3` (the maintained
|
|
||||||
`ammonia`/Rust sanitizer, per the issue's recommendation — not the
|
|
||||||
deprecated `bleach`). `sanitize_html()` keeps ordinary formatting,
|
|
||||||
tables, images, links, and the codehilite/Pygments markup, and drops
|
|
||||||
`<script>`, `on*` handlers, `<style>`, unknown tags, and
|
|
||||||
`javascript:`/`data:` URLs (`url_schemes` limited to http/https/mailto;
|
|
||||||
relative `/assets/<id>` image URLs pass through). Verified live that
|
|
||||||
markdown/deck/html_fragment strip script/onerror/onload/svg while tables,
|
|
||||||
syntax highlighting, and relative images survive. `html_full` is
|
|
||||||
deliberately **not** sanitized — it remains the documented verbatim
|
|
||||||
escape hatch.
|
|
||||||
|
|
||||||
2. **Script-blocking CSP at serve time** for the sanitized kinds:
|
|
||||||
`default-src 'self'; script-src 'none'; object-src 'none'; base-uri
|
|
||||||
'none'`, set in `serve_artifact_file` (so it covers both `/a/{id}` and
|
|
||||||
the unauthenticated `/s/{token}` share route). Because it's a response
|
|
||||||
header, it also neutralizes any script in artifacts stored *before* this
|
|
||||||
fix — closing the retroactive gap that create-time sanitization alone
|
|
||||||
would miss. Not applied to `html_full` (its scripts are by contract) or
|
|
||||||
`json`/`csv` (their viewers need JS; their user data is separately
|
|
||||||
escaped via `escape_script_body`).
|
|
||||||
|
|
||||||
Regression tests added in `tests/api/test_artifacts.py` (7): script/handler
|
|
||||||
stripping for each themed kind, formatting/highlighting/relative-image
|
|
||||||
preservation, html_full staying verbatim, and the CSP header being present
|
|
||||||
for sanitized kinds and absent for html_full.
|
|
||||||
|
|
||||||
**Not addressed here (separate, lower-priority items the issue noted):**
|
|
||||||
- CSV formula injection (`=cmd|…` in a cell) — left as documented behavior.
|
|
||||||
- `CSRF_COOKIE_HTTPONLY` — moot now that the XSS vector is closed, but a
|
|
||||||
cheap extra hardening if wanted (our JS reads the token from a form input,
|
|
||||||
not the cookie, so enabling it wouldn't break anything).
|
|
||||||
- A nonce/hash CSP for the `json`/`csv`/`html_full` pages — those keep their
|
|
||||||
current behavior; the strict CSP is scoped to the no-JS themed kinds where
|
|
||||||
it's provably safe.
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
# 015 — Deeply nested JSON raises `RecursionError` → unhandled HTTP 500 (two code paths)
|
|
||||||
|
|
||||||
**Status:** Fixed
|
|
||||||
**Severity:** Medium — unhandled 500 where a 400 is correct; small (~30 KB)
|
|
||||||
request crashes the handling worker (minor DoS shape); full traceback
|
|
||||||
exposed when `DEBUG=True`.
|
|
||||||
|
|
||||||
Round-3 probe of the `json` kind ("deeply nested / very large" from the
|
|
||||||
#013 handover).
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
Two distinct code paths turn deeply-nested JSON into an uncaught
|
|
||||||
`RecursionError` and a 500. The root cause is shared: Python's
|
|
||||||
`RecursionError` is a subclass of `RuntimeError`, **not** `ValueError`, so
|
|
||||||
the usual `except (TypeError, ValueError)` guards around JSON parsing don't
|
|
||||||
catch it.
|
|
||||||
|
|
||||||
### Path 1 — `json` kind content validation
|
|
||||||
|
|
||||||
`validate_content_shape` (`artifacts/views.py:987`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
if kind == Artifact.JSON:
|
|
||||||
try:
|
|
||||||
json.loads(content)
|
|
||||||
except (TypeError, ValueError) as exc: # <-- misses RecursionError
|
|
||||||
return Response({"detail": f"content must be valid JSON: {exc}"}, status=400)
|
|
||||||
```
|
|
||||||
|
|
||||||
Posting a `json` artifact whose `content` is `"[" * 5000 + "]" * 5000`
|
|
||||||
(valid, just deep) raises `RecursionError: maximum recursion depth
|
|
||||||
exceeded while decoding a JSON array from a unicode string`, which escapes
|
|
||||||
the `except` and becomes a 500.
|
|
||||||
|
|
||||||
Reproduced:
|
|
||||||
```
|
|
||||||
POST /api/artifacts kind=json content="[[[...5000...]]]"
|
|
||||||
-> 500 (RecursionError at views.py:987, via post() at views.py:133)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Path 2 — DRF request-body parser (affects ALL kinds)
|
|
||||||
|
|
||||||
The same class of payload in the **request body itself** (e.g. a deeply
|
|
||||||
nested `metadata` object, or nesting anywhere in the JSON body) crashes in
|
|
||||||
DRF's `JSONParser` *before the view runs*, so it isn't `json`-kind
|
|
||||||
specific:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/artifacts kind=markdown metadata={"n":{"n":{...5000...}}}
|
|
||||||
(body ~30 KB, built as a raw string so it's well-formed)
|
|
||||||
-> 500 (RecursionError inside DRF JSONParser)
|
|
||||||
|
|
||||||
Control: same shape at depth 100 -> 201 (confirms it's depth, not size)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Not a size issue
|
|
||||||
|
|
||||||
A 50,000-key flat object (~978 KB) posts fine (`201`). It's specifically
|
|
||||||
nesting *depth* that triggers it, so a byte-size limit wouldn't address
|
|
||||||
this.
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- Wrong status: an unhandled 500 where malformed/pathological input should
|
|
||||||
be a clean 400.
|
|
||||||
- Minor DoS shape: a single ~30 KB request reliably errors out the worker
|
|
||||||
handling it. Not a resource-exhaustion amplifier, but a cheap way to
|
|
||||||
generate server-side 500s.
|
|
||||||
- Info exposure *only* on a `DEBUG=True` instance (like the local dev one
|
|
||||||
this was found on): the full Django traceback page is returned. Prod
|
|
||||||
should have `DEBUG=False`, so there this degrades to a generic 500 — the
|
|
||||||
status-code bug remains either way. (Flagging the traceback exposure only
|
|
||||||
as a reminder to confirm prod `DEBUG` is off, not as the core issue.)
|
|
||||||
|
|
||||||
## Suggested Fix
|
|
||||||
|
|
||||||
- **Path 1 (cheap, local):** add `RecursionError` to the caught exceptions
|
|
||||||
at `views.py:988` (it's a `RuntimeError`, so either list it explicitly or
|
|
||||||
catch `(TypeError, ValueError, RecursionError)`), returning the same 400.
|
|
||||||
- **Path 2 (needs a global guard):** DRF's default exception handler
|
|
||||||
doesn't map `RecursionError` to a 4xx. Options: a custom
|
|
||||||
`REST_FRAMEWORK["EXCEPTION_HANDLER"]` that converts `RecursionError`
|
|
||||||
(and other parse-time `RuntimeError`s) to `400`; and/or a bounded JSON
|
|
||||||
parser / max-nesting-depth check applied to request bodies. A depth cap
|
|
||||||
(e.g. reject bodies nested beyond N levels) is the most robust since it
|
|
||||||
rejects before the recursive descent exhausts the stack.
|
|
||||||
- Consider a single shared "parse JSON safely" helper used by both the
|
|
||||||
content validator and any metadata/tag_colors parsing, so the guard
|
|
||||||
lives in one place.
|
|
||||||
|
|
||||||
## Resolution
|
|
||||||
|
|
||||||
Both paths fixed:
|
|
||||||
|
|
||||||
- **Path 1** (`json`-kind content validation): `validate_content_shape` now
|
|
||||||
catches `RecursionError` alongside `(TypeError, ValueError)`, returning
|
|
||||||
the same `400 content must be valid JSON: …`.
|
|
||||||
- **Path 2** (deep nesting anywhere in the request body, crashing DRF's
|
|
||||||
parser before the view): added a custom DRF `EXCEPTION_HANDLER`
|
|
||||||
(`artifacts/exception_handlers.py`, wired via `REST_FRAMEWORK` in
|
|
||||||
settings) that maps otherwise-unhandled `RecursionError` to
|
|
||||||
`400 Request payload is nested too deeply to parse.` This also covers the
|
|
||||||
same class of error in `metadata`/`tag_colors` string parsing. Anything
|
|
||||||
that isn't a RecursionError still falls through to Django's normal 500.
|
|
||||||
|
|
||||||
Verified live: deep `json` content and a ~5000-deep `metadata` body both
|
|
||||||
return 400; shallow payloads still 201. Tests added in
|
|
||||||
`tests/api/test_artifacts.py`. (A depth-cap parser was considered but the
|
|
||||||
exception-handler approach is simpler and covers every recursive-descent
|
|
||||||
site at once; prod `DEBUG=False` already suppresses the traceback page.)
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
# 016 — Ragged CSV rows silently lose data in the rendered table (server doesn't normalize row width)
|
|
||||||
|
|
||||||
**Status:** Fixed
|
|
||||||
**Severity:** Low-Medium — silent data loss in the primary rendered view;
|
|
||||||
raw sidecar is unaffected, so it's a fidelity/display bug, not permanent
|
|
||||||
loss.
|
|
||||||
|
|
||||||
Round-3 probe of the `csv` kind (ragged rows, from the #013 handover).
|
|
||||||
Confirmed with an actual headless-Chrome render, not just server inspection.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
`wrap_csv_viewer` (`artifacts/views.py`) parses CSV server-side and hands
|
|
||||||
Grid.js `{columns: rows[0], data: rows[1:]}` **without reconciling each
|
|
||||||
data row's width against the header**. Grid.js keys body cells by
|
|
||||||
column-id, so any row that doesn't match the header column count renders
|
|
||||||
wrong:
|
|
||||||
|
|
||||||
- **Row longer than header → extra cells silently dropped.**
|
|
||||||
- **Row shorter than header → missing cells rendered blank.**
|
|
||||||
|
|
||||||
Daybook's stated principle is "the server owns normalization," but row
|
|
||||||
width isn't normalized here, so the display silently disagrees with the
|
|
||||||
data.
|
|
||||||
|
|
||||||
## Reproduction (verified via headless Chrome render)
|
|
||||||
|
|
||||||
Posted `csv` content:
|
|
||||||
|
|
||||||
```
|
|
||||||
a,b,c
|
|
||||||
1,2,3,4,5
|
|
||||||
6,7
|
|
||||||
```
|
|
||||||
|
|
||||||
Server hands Grid.js: `{"columns": ["a","b","c"], "data": [["1","2","3","4","5"], ["6","7"]]}`
|
|
||||||
|
|
||||||
Rendered DOM (headless Chrome, JS executed):
|
|
||||||
|
|
||||||
```
|
|
||||||
| a | b | c |
|
|
||||||
| 1 | 2 | 3 | <- values 4 and 5 are GONE, not shown anywhere
|
|
||||||
| 6 | 7 | | <- third cell blank
|
|
||||||
```
|
|
||||||
|
|
||||||
The `4` and `5` from the first data row do not appear in the rendered
|
|
||||||
table at all. `GET /a/{id}?raw=1` still returns the original CSV intact, so
|
|
||||||
the data isn't destroyed — but a user browsing the artifact (the normal
|
|
||||||
path, and the shared-link path) sees fewer values than the CSV contains,
|
|
||||||
with no truncation indicator.
|
|
||||||
|
|
||||||
This is realistic input: trailing commas, inconsistent exports, and
|
|
||||||
hand-edited CSVs routinely produce rows that don't all match the header
|
|
||||||
width.
|
|
||||||
|
|
||||||
## Other CSV edge cases tested (all fine)
|
|
||||||
|
|
||||||
For the record — these were probed in the same pass and behaved correctly:
|
|
||||||
|
|
||||||
- Embedded commas, quotes (`""` escaping), and newlines inside quoted
|
|
||||||
fields: parsed correctly (Python's `csv` module).
|
|
||||||
- `duplicate columns` (`id,id,id`): rendered (3 same-named headers) — a
|
|
||||||
bit odd for sorting but not a data bug.
|
|
||||||
- `header only`: empty table, fine.
|
|
||||||
- `empty content`: rejected `400`, consistent with `json`.
|
|
||||||
- `single giant cell` (20 KB): rendered fine.
|
|
||||||
- `just commas`: empty-named columns, rendered.
|
|
||||||
|
|
||||||
So the parsing is robust; the gap is specifically row-width normalization
|
|
||||||
before the data reaches Grid.js.
|
|
||||||
|
|
||||||
## Suggested Fix
|
|
||||||
|
|
||||||
In `wrap_csv_viewer`, normalize each data row to `len(columns)` before
|
|
||||||
serializing — pad short rows with `""`, and for over-long rows either (a)
|
|
||||||
truncate but surface it, or better (b) widen `columns` to the maximum row
|
|
||||||
width (auto-naming the extra columns, e.g. `column_4`, `column_5`) so no
|
|
||||||
cell is ever dropped. Option (b) preserves all data, which matches the
|
|
||||||
"server owns normalization, never silently lose content" spirit. Whichever
|
|
||||||
is chosen, the rendered table should never contain fewer values than the
|
|
||||||
raw sidecar.
|
|
||||||
|
|
||||||
## Resolution
|
|
||||||
|
|
||||||
Implemented option (b) in `wrap_csv_viewer`: normalize every row to the
|
|
||||||
maximum row width before handing data to Grid.js. Rows longer than the
|
|
||||||
header widen the column set (extra columns auto-named `Column N`); rows
|
|
||||||
shorter than the width are padded with empty strings. No cell is ever
|
|
||||||
dropped, matching "server owns normalization, never silently lose content."
|
|
||||||
|
|
||||||
Verified live with the issue's exact input (`a,b,c` / `1,2,3,4,5` / `6,7`):
|
|
||||||
grid data becomes `columns=[a,b,c,Column 4,Column 5]`,
|
|
||||||
`data=[[1,2,3,4,5],[6,7,"","",""]]` — the previously-dropped `4` and `5`
|
|
||||||
now render. Test added in `tests/api/test_artifacts.py`.
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
# 018 — Handover to AGENT_DAYBOOK__LLM_TEST (round 3)
|
|
||||||
|
|
||||||
**Status:** For information — a handback covering everything fixed/changed
|
|
||||||
since the round-2 handoff (#013).
|
|
||||||
|
|
||||||
Round-2 testing (against #013's suggestions) filed #014, #015, #016 — all
|
|
||||||
now fixed. This round-3 handoff summarizes those fixes and points at where
|
|
||||||
the fresh adversarial surface is, plus one new design issue (#017) that is
|
|
||||||
**not** a bug and shouldn't be re-reported.
|
|
||||||
|
|
||||||
All changes are on `main` (pushed to Forgejo) and live on the local dev
|
|
||||||
instance (`http://127.0.0.1:8001`) via autoreload — no restart needed.
|
|
||||||
Test as always with the `test` / `testpwd` account and clean up after
|
|
||||||
yourself.
|
|
||||||
|
|
||||||
## Fixes since #013 (issues you filed)
|
|
||||||
|
|
||||||
- **#014 (High) — stored XSS.** `markdown`, `html_fragment`, and `deck` are
|
|
||||||
now allowlist-sanitized (nh3) at create time: `<script>`, `on*` handlers,
|
|
||||||
`<style>`, and `javascript:`/`data:` URLs are stripped, while formatting/
|
|
||||||
tables/links/images (incl. relative `/assets/<id>`) survive. Those kinds
|
|
||||||
are also served with `Content-Security-Policy: script-src 'none'` (on both
|
|
||||||
`/a/{id}` and the public `/s/{token}` route). `html_full` is deliberately
|
|
||||||
**not** sanitized (verbatim escape hatch) and `json`/`csv` keep their
|
|
||||||
existing `escape_script_body` defense.
|
|
||||||
- **#015 (Med) — deeply nested JSON → 500.** Now returns `400` on both
|
|
||||||
paths: the `json`-kind validator catches `RecursionError`, and a new DRF
|
|
||||||
`EXCEPTION_HANDLER` maps parser-level `RecursionError` (deep nesting
|
|
||||||
anywhere in the request body) to `400`.
|
|
||||||
- **#016 (Low-Med) — ragged CSV rows.** `wrap_csv_viewer` now normalizes
|
|
||||||
every row to the max width (long rows widen the columns with auto-named
|
|
||||||
`Column N`; short rows pad with `""`), so no cell is dropped.
|
|
||||||
|
|
||||||
## Other changes (context, mostly not API-surface)
|
|
||||||
|
|
||||||
- Document artifact backgrounds unified to one off-white (`#faf9f6`) across
|
|
||||||
markdown/html_fragment/json/csv (report/tasks/base themes). Deck stays
|
|
||||||
dark; html_full/pdf unchanged. Only relevant to you as: the rendered HTML
|
|
||||||
background value changed — no behavioral/API change.
|
|
||||||
- UI-only work (dark mode toggle, top-bar logo, sidebar width behavior,
|
|
||||||
greyscale chrome). No API surface.
|
|
||||||
- **#017 filed — customizable theme variants.** A *design issue* (feature
|
|
||||||
request), not a bug. Don't re-report theme/color inflexibility as a bug;
|
|
||||||
it's tracked.
|
|
||||||
|
|
||||||
## Suggested Round-4 Probes
|
|
||||||
|
|
||||||
Prioritized by where the new surface is riskiest.
|
|
||||||
|
|
||||||
### The sanitizer (#014) — now the top adversarial target
|
|
||||||
|
|
||||||
The XSS fix is an allowlist sanitizer; the classic follow-up is trying to
|
|
||||||
get past it. Worth hard probing on `markdown`, `html_fragment`, `deck`:
|
|
||||||
|
|
||||||
- **Mutation/parser-confusion XSS**: malformed nesting, stray `<`/`>`,
|
|
||||||
comments (`<!-- -->`), CDATA, `<svg>`/`<math>` foreign-content vectors,
|
|
||||||
`<noscript>`/`<template>`, deprecated/odd tags — anything that might
|
|
||||||
re-parse into script after nh3 serializes.
|
|
||||||
- **Attribute vectors**: `on*` variants, `style=` (should be stripped),
|
|
||||||
`href`/`src` with `javascript:`, `data:`, `vbscript:`, whitespace/newline/
|
|
||||||
encoding tricks (`java\tscript:`, `javascript:`), `srcset`, `formaction`.
|
|
||||||
- **Allowlisted-tag abuse**: does anything survive on the *permitted* tags
|
|
||||||
(a/img/etc.) that shouldn't — e.g. `<a href>` protocol bypasses, `<img
|
|
||||||
src>` with odd schemes?
|
|
||||||
- **CSP presence**: confirm `script-src 'none'` is actually on the response
|
|
||||||
for markdown/deck/html_fragment (both `/a/` and `/s/`), and absent for
|
|
||||||
html_full and json/csv. Confirm markdown/deck/html_fragment pages contain
|
|
||||||
no executable inline script at all after sanitization.
|
|
||||||
- **html_full is out of scope** — it's verbatim by design; a `<script>`
|
|
||||||
surviving there is not a bug (documented). Don't file it.
|
|
||||||
- Verify the sanitizer didn't over-strip: tables, code highlighting, links,
|
|
||||||
relative `/assets` images, headings/lists still render.
|
|
||||||
|
|
||||||
### #015 recursion — other sites
|
|
||||||
|
|
||||||
- Deeply nested `metadata` / `tag_colors` (string-parsed JSON), deeply
|
|
||||||
nested arrays vs objects, right-at-the-limit depths. Confirm 400 not 500.
|
|
||||||
|
|
||||||
### #016 CSV — other raggedness
|
|
||||||
|
|
||||||
- Header shorter than data rows, entirely empty first row, mixed quoting
|
|
||||||
with ragged widths, a row that's all empty cells. Confirm no data lost and
|
|
||||||
the auto-named columns behave.
|
|
||||||
|
|
||||||
### Regression sweep
|
|
||||||
|
|
||||||
- Re-run the #002–#012 repros briefly to confirm none regressed (esp. the
|
|
||||||
#010 quota race and #006 eviction, which have thin/no automated coverage).
|
|
||||||
|
|
||||||
As always: use `test`/`testpwd`, clean up, check `artifacts/views.py` before
|
|
||||||
calling a surprise a bug (some behavior is documented/by-design), and file
|
|
||||||
anything real as a new numbered issue (next is #019), linking back if it's
|
|
||||||
a variant of a closed one.
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
# 019 — Handover to AGENT_DAYBOOK__LLM_TEST (round 4 — pre-deploy sign-off)
|
|
||||||
|
|
||||||
**Status:** For information — final regression pass before deployment.
|
|
||||||
|
|
||||||
This is the **last test round before the app is deployed**. Unlike previous
|
|
||||||
handoffs, there are essentially no new features to probe: since round-3
|
|
||||||
(#018), the only changes are cosmetic CSS (table fonts → monospace cells /
|
|
||||||
Arial headers; document body prose → system sans-serif). **No API or
|
|
||||||
behavioral surface changed.** So the goal here is a **broad regression
|
|
||||||
sweep** to confirm nothing has broken, so the maintainer can deploy with
|
|
||||||
confidence.
|
|
||||||
|
|
||||||
Everything is on `main` (pushed) and live on the local dev instance
|
|
||||||
(`http://127.0.0.1:8001`). Use the `test` / `testpwd` account and clean up
|
|
||||||
what you create.
|
|
||||||
|
|
||||||
## What to do: full regression sweep
|
|
||||||
|
|
||||||
Confirm the whole API still behaves. Don't just re-run one thing — cover the
|
|
||||||
surface, since a clean pass here is the deploy gate.
|
|
||||||
|
|
||||||
### Core CRUD, every kind
|
|
||||||
- `POST` each text kind (markdown, html_fragment, html_full, deck, json,
|
|
||||||
csv) → `201`, correct `kind`, renders, `raw_available` correct.
|
|
||||||
- `POST` each file kind (image, pdf, binary) and `.xlsx` (→ converts to csv).
|
|
||||||
- `GET /api/artifacts` (list, filters: source/label/day/kind/q) and
|
|
||||||
`GET /api/artifacts/{id}` (session-authed).
|
|
||||||
- `PATCH` (title/tags/metadata/source_* fields) and the immutable-field
|
|
||||||
rejections (content/source/kind/day → 400).
|
|
||||||
- `DELETE` → 204, files gone, second delete 404s.
|
|
||||||
- `/api/capabilities` returns the current kind/method/filter lists.
|
|
||||||
|
|
||||||
### Auth boundaries (important pre-deploy)
|
|
||||||
- No token / bad token on writes → 403.
|
|
||||||
- One user's token cannot read/patch/delete another user's artifact (404).
|
|
||||||
- List/detail reads require a session, not just a bearer token.
|
|
||||||
|
|
||||||
### Confirm every closed issue stays fixed
|
|
||||||
Spot-check the earlier repros — these are the regression-risk areas:
|
|
||||||
- **#014** — script/onerror in markdown/deck/html_fragment still stripped;
|
|
||||||
CSP `script-src 'none'` present on those (and `/s/`); html_full still
|
|
||||||
verbatim.
|
|
||||||
- **#015** — deeply nested JSON (content *and* request body) → 400, not 500.
|
|
||||||
- **#016** — ragged CSV rows don't drop cells.
|
|
||||||
- **#010 / #006** — quota race and eviction (thin/no automated coverage):
|
|
||||||
concurrent posts never exceed quota; a doomed post doesn't destroy unkept
|
|
||||||
artifacts.
|
|
||||||
- **#008** — non-string source_* → 400.
|
|
||||||
- **#003 / #004** — length limits and content-shape checks still enforced.
|
|
||||||
|
|
||||||
### Sharing (the unauthenticated surface — worth re-checking before going live)
|
|
||||||
- Share toggle creates/clears the token; `/s/{token}` serves unauthenticated;
|
|
||||||
unsharing kills the old link immediately; a bad/guessed token 404s; an
|
|
||||||
unshared artifact's embedded assets are not reachable anonymously.
|
|
||||||
|
|
||||||
## Cosmetic changes since #018 (not API-testable, FYI only)
|
|
||||||
|
|
||||||
- Table cells render in monospace, headers in Arial.
|
|
||||||
- Document body prose is system sans-serif (was serif in the report theme).
|
|
||||||
- Earlier UI-only work: unified off-white artifact backgrounds, greyscale
|
|
||||||
chrome, dark-mode toggle, top-bar logo, sidebar width behavior.
|
|
||||||
|
|
||||||
None of these change API responses; verify only if a render looks broken.
|
|
||||||
|
|
||||||
## Out of scope / don't report
|
|
||||||
- **#017** (customizable theme variants) is an accepted design issue, not a
|
|
||||||
bug.
|
|
||||||
- `html_full` being verbatim/unsanitized is by design.
|
|
||||||
- `pdf` using the browser's grey viewer chrome is expected.
|
|
||||||
|
|
||||||
## Outcome
|
|
||||||
- **Clean sweep → the maintainer deploys.** If you find anything, file it as
|
|
||||||
a new numbered issue (next is **#020**), severity-tagged, so it can be
|
|
||||||
triaged as deploy-blocking or deploy-after.
|
|
||||||
|
|
||||||
As always: `test`/`testpwd`, clean up, and check `artifacts/views.py` before
|
|
||||||
calling a surprise a bug.
|
|
||||||
43
issues/020-in-app-user-guide.md
Normal file
43
issues/020-in-app-user-guide.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# 020 — In-app user guide (Settings → Guide)
|
||||||
|
|
||||||
|
**Status:** Open
|
||||||
|
**Type:** Feature
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Accounts get created for other people (via Settings → Users), who then log in
|
||||||
|
with **zero context**. The repo `docs/` are invisible to anyone without
|
||||||
|
repository access, and the About page only shows the version. A new user has
|
||||||
|
no in-product answer to "what is this and how do I use it?"
|
||||||
|
|
||||||
|
The fix is a short, task-oriented **in-app user guide** — not a copy of the
|
||||||
|
repo docs (those target agent builders and operators and would rot if
|
||||||
|
duplicated).
|
||||||
|
|
||||||
|
## Proposed approach
|
||||||
|
|
||||||
|
- A new **Settings → Guide** page, in the nav under the "You" group near
|
||||||
|
About (see the You/Admin grouping added in v0.2.1).
|
||||||
|
- Concise, human-facing content: what a feed is, labels (source + tags),
|
||||||
|
getting an API key (link to Settings → API Keys), sharing links, dark mode.
|
||||||
|
- A "Posting from an agent" section that points at (or inlines) the bundled
|
||||||
|
posting skill — which is already served at `GET /api/skill` — so the
|
||||||
|
agent-facing part never drifts from the real skill.
|
||||||
|
|
||||||
|
Two implementation options for the body:
|
||||||
|
1. Hand-written template content (simplest, fully styled).
|
||||||
|
2. Hybrid: a written human intro + the skill rendered/linked for the agent
|
||||||
|
part (keeps agent instructions auto-synced with `SKILL.md`).
|
||||||
|
|
||||||
|
Recommend the hybrid.
|
||||||
|
|
||||||
|
## Scope / notes
|
||||||
|
|
||||||
|
- Keep it a **user** guide; leave API reference, deployment, and `/admin/`
|
||||||
|
in the repo `docs/`. Link out rather than duplicate.
|
||||||
|
- No new models needed; a view + template + one nav entry.
|
||||||
|
- Candidate release: a future minor (e.g. v0.2.2), not urgent.
|
||||||
|
|
||||||
|
## Resolution
|
||||||
|
|
||||||
|
_(open)_
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
# Daybook Posting Helper
|
# AgentFeed Posting Helper
|
||||||
|
|
||||||
`scripts/daybook_post.py` is a compatibility wrapper for the canonical zero-dependency helper at `skills/daybook-posting/scripts/daybook_post.py`.
|
`scripts/agentfeed_post.py` is a compatibility wrapper for the canonical zero-dependency helper at `skills/agentfeed-posting/scripts/agentfeed_post.py`.
|
||||||
|
|
||||||
The helper is for agents and scripts that need to publish artifacts to Daybook without hand-crafting JSON, curl, or multipart requests.
|
The helper is for agents and scripts that need to publish artifacts to AgentFeed without hand-crafting JSON, curl, or multipart requests.
|
||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
DAYBOOK_URL=http://127.0.0.1:8001
|
AGENTFEED_URL=http://127.0.0.1:8001
|
||||||
DAYBOOK_TOKEN=your-personal-api-token
|
AGENTFEED_TOKEN=your-personal-api-token
|
||||||
DAYBOOK_SOURCE=agent
|
AGENTFEED_SOURCE=agent
|
||||||
```
|
```
|
||||||
|
|
||||||
`DAYBOOK_TOKEN` is a personal per-user token from Settings → Account in the app, not shared across users.
|
`AGENTFEED_TOKEN` is a personal per-user token from Settings → Account in the app, not shared across users.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --check
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --check
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report
|
||||||
cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report"
|
cat report.md | python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Build Report"
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Plan" --content-file deck.html
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --kind deck --title "Plan" --content-file deck.html
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram"
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --file diagram.svg --title "Architecture Diagram"
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --file results.csv --as-markdown --title "Results CSV"
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --file results.csv --as-markdown --title "Results CSV"
|
||||||
```
|
```
|
||||||
|
|
||||||
The artifact browser URL is printed as the final stdout line.
|
The artifact browser URL is printed as the final stdout line.
|
||||||
|
|
|
||||||
16
scripts/agentfeed_post.py
Normal file
16
scripts/agentfeed_post.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compatibility wrapper for the AgentFeed posting helper.
|
||||||
|
|
||||||
|
The canonical helper lives inside the self-contained skill folder:
|
||||||
|
skills/agentfeed-posting/scripts/agentfeed_post.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import runpy
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
HELPER = Path(__file__).resolve().parent.parent / "skills" / "agentfeed-posting" / "scripts" / "agentfeed_post.py"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
runpy.run_path(str(HELPER), run_name="__main__")
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Compatibility wrapper for the Daybook posting helper.
|
|
||||||
|
|
||||||
The canonical helper lives inside the self-contained skill folder:
|
|
||||||
skills/daybook-posting/scripts/daybook_post.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import runpy
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
HELPER = Path(__file__).resolve().parent.parent / "skills" / "daybook-posting" / "scripts" / "daybook_post.py"
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
runpy.run_path(str(HELPER), run_name="__main__")
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Post one demo artifact of every kind, tagged, dated today.
|
"""Post one demo artifact of every kind, tagged, dated today.
|
||||||
|
|
||||||
Posts through the real ingest API using the existing daybook_post.py helper,
|
Posts through the real ingest API using the existing agentfeed_post.py helper,
|
||||||
so seeded data goes through the same normalization as real agent output.
|
so seeded data goes through the same normalization as real agent output.
|
||||||
|
|
||||||
Usage (from project root, with the dev server running):
|
Usage (from project root, with the dev server running):
|
||||||
|
|
@ -13,7 +13,7 @@ import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
HELPER = Path(__file__).resolve().parent.parent / "skills" / "daybook-posting" / "scripts" / "daybook_post.py"
|
HELPER = Path(__file__).resolve().parent.parent / "skills" / "agentfeed-posting" / "scripts" / "agentfeed_post.py"
|
||||||
PERSONA = "seed-demo"
|
PERSONA = "seed-demo"
|
||||||
TAGS = "seed-demo,demo"
|
TAGS = "seed-demo,demo"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
---
|
---
|
||||||
name: daybook-posting
|
name: agentfeed-posting
|
||||||
description: Use when you have produced a finished deliverable such as a report, summary, brief, diagram, generated HTML, PDF, deck, or status update that a human should review later outside this chat. Publishes the artifact to a Daybook instance and returns a URL.
|
description: Use when you have produced a finished deliverable such as a report, summary, brief, diagram, generated HTML, PDF, deck, or status update that a human should review later outside this chat. Publishes the artifact to a AgentFeed instance and returns a URL.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Daybook Posting
|
# AgentFeed Posting
|
||||||
|
|
||||||
Daybook is a private artifact journal for completed agent outputs. It stores, indexes, and displays artifacts for human review. It does not execute commands, run workflows, or continue conversations.
|
AgentFeed is a private artifact journal for completed agent outputs. It stores, indexes, and displays artifacts for human review. It does not execute commands, run workflows, or continue conversations.
|
||||||
|
|
||||||
You post to a **running Daybook server**, which may be anywhere — a cloud VM, a container, or a workstation. You do **not** need a checkout of Daybook's source. The only things that tie you to a particular instance are two environment variables: `DAYBOOK_URL` (where the server is) and `DAYBOOK_TOKEN` (who you are). Set those and the rest of this skill works unchanged regardless of where you or the server run.
|
You post to a **running AgentFeed server**, which may be anywhere — a cloud VM, a container, or a workstation. You do **not** need a checkout of AgentFeed's source. The only things that tie you to a particular instance are two environment variables: `AGENTFEED_URL` (where the server is) and `AGENTFEED_TOKEN` (who you are). Set those and the rest of this skill works unchanged regardless of where you or the server run.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|
@ -17,20 +17,20 @@ config) — the helper reads `os.environ` directly. There is no `.env` file
|
||||||
support; a `.env` file in the working directory is not read automatically.
|
support; a `.env` file in the working directory is not read automatically.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
DAYBOOK_URL=https://your-daybook-instance.example.com # your server's base URL
|
AGENTFEED_URL=https://your-agentfeed-instance.example.com # your server's base URL
|
||||||
DAYBOOK_TOKEN=your-personal-api-key # from Settings -> API Keys
|
AGENTFEED_TOKEN=your-personal-api-key # from Settings -> API Keys
|
||||||
DAYBOOK_SOURCE=agent # a stable label for what's posting
|
AGENTFEED_SOURCE=agent # a stable label for what's posting
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`DAYBOOK_URL`** — the base URL of the Daybook instance you're posting to.
|
- **`AGENTFEED_URL`** — the base URL of the AgentFeed instance you're posting to.
|
||||||
Set it to your server. If it's unset, the helper falls back to
|
Set it to your server. If it's unset, the helper falls back to
|
||||||
`http://127.0.0.1:8001`, which is only correct when Daybook is running
|
`http://127.0.0.1:8001`, which is only correct when AgentFeed is running
|
||||||
locally on the same machine (its development default). For any remote or
|
locally on the same machine (its development default). For any remote or
|
||||||
deployed instance you **must** set this.
|
deployed instance you **must** set this.
|
||||||
- **`DAYBOOK_TOKEN`** — a personal API key, created in the app under Settings →
|
- **`AGENTFEED_TOKEN`** — a personal API key, created in the app under Settings →
|
||||||
API Keys (name one per agent so you can revoke them individually). Not a key
|
API Keys (name one per agent so you can revoke them individually). Not a key
|
||||||
shared by everyone; artifacts posted with it land only in your feed.
|
shared by everyone; artifacts posted with it land only in your feed.
|
||||||
- **`DAYBOOK_SOURCE`** — a free-text label for whatever is posting
|
- **`AGENTFEED_SOURCE`** — a free-text label for whatever is posting
|
||||||
(agent-profile, script, or person name). Optional; can also be passed per
|
(agent-profile, script, or person name). Optional; can also be passed per
|
||||||
call with `--source`.
|
call with `--source`.
|
||||||
|
|
||||||
|
|
@ -41,29 +41,29 @@ PowerShell multipart, or inline shell content (a `curl` fallback for
|
||||||
environments without the helper is documented at the end — the script is
|
environments without the helper is documented at the end — the script is
|
||||||
still preferred everywhere it's available).
|
still preferred everywhere it's available).
|
||||||
|
|
||||||
The helper lives at **`scripts/daybook_post.py` inside this skill's own
|
The helper lives at **`scripts/agentfeed_post.py` inside this skill's own
|
||||||
directory** — not in any project you happen to be working in. Invoke it by
|
directory** — not in any project you happen to be working in. Invoke it by
|
||||||
its absolute path. For brevity, the examples below assume you've captured
|
its absolute path. For brevity, the examples below assume you've captured
|
||||||
that path once:
|
that path once:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Point this at the helper inside THIS skill directory (absolute path).
|
# Point this at the helper inside THIS skill directory (absolute path).
|
||||||
DAYBOOK_POST=/abs/path/to/this/skill/scripts/daybook_post.py
|
AGENTFEED_POST=/abs/path/to/this/skill/scripts/agentfeed_post.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Then:
|
Then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python "$DAYBOOK_POST" --check
|
python "$AGENTFEED_POST" --check
|
||||||
python "$DAYBOOK_POST" --kind markdown --title "Build Report" --content-file report.md
|
python "$AGENTFEED_POST" --kind markdown --title "Build Report" --content-file report.md
|
||||||
cat report.md | python "$DAYBOOK_POST" --kind markdown --title "Build Report"
|
cat report.md | python "$AGENTFEED_POST" --kind markdown --title "Build Report"
|
||||||
python "$DAYBOOK_POST" --file diagram.svg --title "Architecture Diagram"
|
python "$AGENTFEED_POST" --file diagram.svg --title "Architecture Diagram"
|
||||||
```
|
```
|
||||||
|
|
||||||
The artifact URL is printed as the final stdout line.
|
The artifact URL is printed as the final stdout line.
|
||||||
|
|
||||||
(If you *are* working inside a Daybook source checkout, a compatibility
|
(If you *are* working inside a AgentFeed source checkout, a compatibility
|
||||||
wrapper also exists at `scripts/daybook_post.py` in the repo root, but the
|
wrapper also exists at `scripts/agentfeed_post.py` in the repo root, but the
|
||||||
canonical helper is the one bundled in this skill.)
|
canonical helper is the one bundled in this skill.)
|
||||||
|
|
||||||
## Kind Cheat Sheet
|
## Kind Cheat Sheet
|
||||||
|
|
@ -100,7 +100,7 @@ with no API equivalent at all yet.
|
||||||
|
|
||||||
## CDN Snippets For `html_full`
|
## CDN Snippets For `html_full`
|
||||||
|
|
||||||
`html_full` is Daybook's escape hatch for custom pages — nothing is
|
`html_full` is AgentFeed's escape hatch for custom pages — nothing is
|
||||||
injected into it, so a poster wanting Tailwind or a charting library has to
|
injected into it, so a poster wanting Tailwind or a charting library has to
|
||||||
supply its own `<link>`/`<script>` tags. Known-good ones:
|
supply its own `<link>`/`<script>` tags. Known-good ones:
|
||||||
|
|
||||||
|
|
@ -113,13 +113,13 @@ supply its own `<link>`/`<script>` tags. Known-good ones:
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Offline caveat:** Daybook can run fully self-hosted/air-gapped. Any
|
**Offline caveat:** AgentFeed can run fully self-hosted/air-gapped. Any
|
||||||
artifact that links a CDN script won't render fully (or at all, for
|
artifact that links a CDN script won't render fully (or at all, for
|
||||||
Tailwind's in-browser compiler) without internet access. If the deployment
|
Tailwind's in-browser compiler) without internet access. If the deployment
|
||||||
target is offline, don't reach for these — use `markdown`/`html_fragment`
|
target is offline, don't reach for these — use `markdown`/`html_fragment`
|
||||||
instead, which are styled entirely server-side with no external requests.
|
instead, which are styled entirely server-side with no external requests.
|
||||||
|
|
||||||
**Self-hosted alternative for tables/JSON — no CDN needed:** Daybook already
|
**Self-hosted alternative for tables/JSON — no CDN needed:** AgentFeed already
|
||||||
vendors Grid.js and renderjson locally (used to render `csv`/`json`
|
vendors Grid.js and renderjson locally (used to render `csv`/`json`
|
||||||
artifacts), and they're reachable as plain static files from any artifact,
|
artifacts), and they're reachable as plain static files from any artifact,
|
||||||
including `html_full`:
|
including `html_full`:
|
||||||
|
|
@ -138,7 +138,7 @@ tables/JSON; there's no vendored equivalent for Tailwind/Chart.js, so the
|
||||||
CDN snippets above are still the only option for those.
|
CDN snippets above are still the only option for those.
|
||||||
|
|
||||||
Only use this for `html_full` specifically — never add CDN references to
|
Only use this for `html_full` specifically — never add CDN references to
|
||||||
`markdown`/`html_fragment`/`deck`. Those kinds exist so Daybook, not the
|
`markdown`/`html_fragment`/`deck`. Those kinds exist so AgentFeed, not the
|
||||||
poster, owns styling; injecting a default CDN habit into them would
|
poster, owns styling; injecting a default CDN habit into them would
|
||||||
undermine that.
|
undermine that.
|
||||||
|
|
||||||
|
|
@ -149,12 +149,12 @@ artifact), upload it first and reference the returned URL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Upload -> prints the URL as the last line
|
# 1. Upload -> prints the URL as the last line
|
||||||
python "$DAYBOOK_POST" --upload-image chart.png
|
python "$AGENTFEED_POST" --upload-image chart.png
|
||||||
|
|
||||||
# 2. Reference that URL in your markdown, then post as usual.
|
# 2. Reference that URL in your markdown, then post as usual.
|
||||||
# The printed URL is absolute against your DAYBOOK_URL, e.g.
|
# The printed URL is absolute against your AGENTFEED_URL, e.g.
|
||||||
# 
|
# 
|
||||||
python "$DAYBOOK_POST" report.md
|
python "$AGENTFEED_POST" report.md
|
||||||
```
|
```
|
||||||
|
|
||||||
The image is linked to the artifact that references it and is deleted along with it.
|
The image is linked to the artifact that references it and is deleted along with it.
|
||||||
|
|
@ -167,15 +167,15 @@ just post the file positionally; auto-mode detects `.csv`/`.json` and picks
|
||||||
the right kind automatically:
|
the right kind automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python "$DAYBOOK_POST" results.csv
|
python "$AGENTFEED_POST" results.csv
|
||||||
python "$DAYBOOK_POST" --kind json --title "Run Summary" --content-file summary.json
|
python "$AGENTFEED_POST" --kind json --title "Run Summary" --content-file summary.json
|
||||||
```
|
```
|
||||||
|
|
||||||
`.xlsx` spreadsheets work too, with no special flag needed — just `--file`
|
`.xlsx` spreadsheets work too, with no special flag needed — just `--file`
|
||||||
(or post it positionally, same as any other file):
|
(or post it positionally, same as any other file):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python "$DAYBOOK_POST" balance_sheet.xlsx
|
python "$AGENTFEED_POST" balance_sheet.xlsx
|
||||||
```
|
```
|
||||||
|
|
||||||
The server converts it to a `csv` artifact automatically. Known
|
The server converts it to a `csv` artifact automatically. Known
|
||||||
|
|
@ -196,7 +196,7 @@ as a binary, use `--file path --as-markdown` to wrap it in a Markdown
|
||||||
fenced code block (still gets syntax highlighting):
|
fenced code block (still gets syntax highlighting):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python "$DAYBOOK_POST" --file script.py --as-markdown --title "Generated Script"
|
python "$AGENTFEED_POST" --file script.py --as-markdown --title "Generated Script"
|
||||||
```
|
```
|
||||||
|
|
||||||
Without `--as-markdown`, it uploads as a downloadable binary file instead.
|
Without `--as-markdown`, it uploads as a downloadable binary file instead.
|
||||||
|
|
@ -208,7 +208,7 @@ All optional, all default to something reasonable if omitted:
|
||||||
| Flag | Default | Notes |
|
| Flag | Default | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `--source-type` | `agent` | One of `agent`, `person`, `system`. |
|
| `--source-type` | `agent` | One of `agent`, `person`, `system`. |
|
||||||
| `--source-name` | Same as the effective source (`--source`, or `DAYBOOK_SOURCE` if `--source` wasn't given) | Human-readable name, e.g. "Hermes Agent". |
|
| `--source-name` | Same as the effective source (`--source`, or `AGENTFEED_SOURCE` if `--source` wasn't given) | Human-readable name, e.g. "Hermes Agent". |
|
||||||
| `--source-id` | Empty string | A stable external ID, if the poster has one. |
|
| `--source-id` | Empty string | A stable external ID, if the poster has one. |
|
||||||
| `--source-prompt` | Empty string | The prompt/instruction that produced this artifact — useful for later tracing why something was posted. |
|
| `--source-prompt` | Empty string | The prompt/instruction that produced this artifact — useful for later tracing why something was posted. |
|
||||||
|
|
||||||
|
|
@ -219,9 +219,9 @@ context like `run_id` or `model` — these end up in the artifact's
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Run `--check` first if unsure Daybook is reachable.
|
- Run `--check` first if unsure AgentFeed is reachable.
|
||||||
- Make sure `DAYBOOK_URL` points at the right instance and `DAYBOOK_TOKEN` is set before posting anything real.
|
- Make sure `AGENTFEED_URL` points at the right instance and `AGENTFEED_TOKEN` is set before posting anything real.
|
||||||
- Use a stable `DAYBOOK_SOURCE` or pass `--source` (the agent-profile, script, or person name that produced this artifact).
|
- Use a stable `AGENTFEED_SOURCE` or pass `--source` (the agent-profile, script, or person name that produced this artifact).
|
||||||
- Use `--tags a,b,c` for filtering.
|
- Use `--tags a,b,c` for filtering.
|
||||||
- Use `--meta run_id=... --meta model=...` for machine-readable context.
|
- Use `--meta run_id=... --meta model=...` for machine-readable context.
|
||||||
- Never inline multiline content into the shell command; use a file or stdin.
|
- Never inline multiline content into the shell command; use a file or stdin.
|
||||||
|
|
@ -236,8 +236,8 @@ with no Python). Text artifacts post as JSON to `POST /api/artifacts` with
|
||||||
bearer-token auth:
|
bearer-token auth:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sS -X POST "$DAYBOOK_URL/api/artifacts" \
|
curl -sS -X POST "$AGENTFEED_URL/api/artifacts" \
|
||||||
-H "Authorization: Bearer $DAYBOOK_TOKEN" \
|
-H "Authorization: Bearer $AGENTFEED_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"kind":"markdown","title":"Build Report","source":"agent","content":"# Hello\n\nBody text."}'
|
-d '{"kind":"markdown","title":"Build Report","source":"agent","content":"# Hello\n\nBody text."}'
|
||||||
```
|
```
|
||||||
|
|
@ -245,12 +245,12 @@ curl -sS -X POST "$DAYBOOK_URL/api/artifacts" \
|
||||||
File/image artifacts use multipart instead:
|
File/image artifacts use multipart instead:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sS -X POST "$DAYBOOK_URL/api/artifacts" \
|
curl -sS -X POST "$AGENTFEED_URL/api/artifacts" \
|
||||||
-H "Authorization: Bearer $DAYBOOK_TOKEN" \
|
-H "Authorization: Bearer $AGENTFEED_TOKEN" \
|
||||||
-F "kind=image" -F "title=Architecture Diagram" -F "source=agent" \
|
-F "kind=image" -F "title=Architecture Diagram" -F "source=agent" \
|
||||||
-F "file=@diagram.png"
|
-F "file=@diagram.png"
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET $DAYBOOK_URL/api/capabilities` (no auth needed beyond a login/token per
|
`GET $AGENTFEED_URL/api/capabilities` (no auth needed beyond a login/token per
|
||||||
that instance's config) lists supported kinds, methods, and the server
|
that instance's config) lists supported kinds, methods, and the server
|
||||||
version if you need to confirm the contract before posting.
|
version if you need to confirm the contract before posting.
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Post artifacts to Daybook without shell-specific JSON or multipart quoting.
|
"""Post artifacts to AgentFeed without shell-specific JSON or multipart quoting.
|
||||||
|
|
||||||
Zero third-party dependencies. Content comes from a file or stdin so agents do not
|
Zero third-party dependencies. Content comes from a file or stdin so agents do not
|
||||||
need to inline multiline JSON/HTML/Markdown in shell commands.
|
need to inline multiline JSON/HTML/Markdown in shell commands.
|
||||||
|
|
@ -26,11 +26,11 @@ DEFAULT_SOURCE = "agent"
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
base_url = os.environ.get("DAYBOOK_URL", DEFAULT_URL).rstrip("/")
|
base_url = os.environ.get("AGENTFEED_URL", DEFAULT_URL).rstrip("/")
|
||||||
# DAYBOOK_TOKEN is your personal per-user token (Settings -> Account in the app);
|
# AGENTFEED_TOKEN is your personal per-user token (Settings -> API Keys in the app);
|
||||||
# there is no shared fallback token, so posting without it cannot work.
|
# there is no shared fallback token, so posting without it cannot work.
|
||||||
token = os.environ.get("DAYBOOK_TOKEN", "")
|
token = os.environ.get("AGENTFEED_TOKEN", "")
|
||||||
source = args.source or os.environ.get("DAYBOOK_SOURCE", DEFAULT_SOURCE)
|
source = args.source or os.environ.get("AGENTFEED_SOURCE", DEFAULT_SOURCE)
|
||||||
|
|
||||||
if args.check:
|
if args.check:
|
||||||
try:
|
try:
|
||||||
|
|
@ -43,8 +43,8 @@ def main():
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
print(
|
print(
|
||||||
"error: DAYBOOK_TOKEN is not set. Get your personal API token from "
|
"error: AGENTFEED_TOKEN is not set. Get your personal API token from "
|
||||||
f"{base_url}/settings/account/ and set DAYBOOK_TOKEN.",
|
f"{base_url}/settings/api-keys/ and set AGENTFEED_TOKEN.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
|
|
@ -84,11 +84,11 @@ def main():
|
||||||
|
|
||||||
|
|
||||||
def parse_args():
|
def parse_args():
|
||||||
parser = argparse.ArgumentParser(description="Publish an artifact to Daybook.")
|
parser = argparse.ArgumentParser(description="Publish an artifact to AgentFeed.")
|
||||||
parser.add_argument("path", nargs="?", help="Auto mode: one file path. Kind, content-type, and title are inferred. The simplest way to post.")
|
parser.add_argument("path", nargs="?", help="Auto mode: one file path. Kind, content-type, and title are inferred. The simplest way to post.")
|
||||||
parser.add_argument("--kind", choices=sorted(TEXT_KINDS), help="Text artifact kind. Omit when using --file or a positional path.")
|
parser.add_argument("--kind", choices=sorted(TEXT_KINDS), help="Text artifact kind. Omit when using --file or a positional path.")
|
||||||
parser.add_argument("--title", help="Artifact title. Required unless --check is used.")
|
parser.add_argument("--title", help="Artifact title. Required unless --check is used.")
|
||||||
parser.add_argument("--source", help="Artifact source (agent-profile, script, or person name). Defaults to DAYBOOK_SOURCE or 'agent'.")
|
parser.add_argument("--source", help="Artifact source (agent-profile, script, or person name). Defaults to AGENTFEED_SOURCE or 'agent'.")
|
||||||
parser.add_argument("--content-file", help="Read text artifact content from this file.")
|
parser.add_argument("--content-file", help="Read text artifact content from this file.")
|
||||||
parser.add_argument("--file", help="Upload this file as image/pdf/binary, or render as markdown with --as-markdown.")
|
parser.add_argument("--file", help="Upload this file as image/pdf/binary, or render as markdown with --as-markdown.")
|
||||||
parser.add_argument("--tags", help="Comma-separated tags.")
|
parser.add_argument("--tags", help="Comma-separated tags.")
|
||||||
|
|
@ -352,7 +352,7 @@ def post_payload(base_url, token, payload, args):
|
||||||
|
|
||||||
|
|
||||||
def multipart_body(payload):
|
def multipart_body(payload):
|
||||||
boundary = f"----daybook-{uuid.uuid4().hex}"
|
boundary = f"----agentfeed-{uuid.uuid4().hex}"
|
||||||
chunks = []
|
chunks = []
|
||||||
upload_path = payload["upload_path"]
|
upload_path = payload["upload_path"]
|
||||||
for key, value in payload.items():
|
for key, value in payload.items():
|
||||||
|
|
@ -394,11 +394,11 @@ def content_type_for(path):
|
||||||
|
|
||||||
|
|
||||||
def upload_asset(base_url, token, path):
|
def upload_asset(base_url, token, path):
|
||||||
"""Upload an image and return its Daybook URL for embedding in content."""
|
"""Upload an image and return its AgentFeed URL for embedding in content."""
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise ValueError(f"image file does not exist: {path}")
|
raise ValueError(f"image file does not exist: {path}")
|
||||||
|
|
||||||
boundary = f"----daybook-{uuid.uuid4().hex}"
|
boundary = f"----agentfeed-{uuid.uuid4().hex}"
|
||||||
chunks = [
|
chunks = [
|
||||||
f"--{boundary}\r\n".encode("utf-8"),
|
f"--{boundary}\r\n".encode("utf-8"),
|
||||||
f'Content-Disposition: form-data; name="file"; filename="{path.name}"\r\n'.encode("utf-8"),
|
f'Content-Disposition: form-data; name="file"; filename="{path.name}"\r\n'.encode("utf-8"),
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{% load daybook_extras %}
|
{% load agentfeed_extras %}
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brandline">
|
<div class="brandline">
|
||||||
<a class="brand-link" href="{% url 'feed' %}"><img class="brand-logo" src="/static/logo.png" alt=""><h1>AgentFeed</h1></a>
|
<a class="brand-link" href="{% url 'feed' %}"><img class="brand-logo" src="/static/logo.png" alt=""><h1>AgentFeed</h1></a>
|
||||||
|
|
@ -48,10 +48,10 @@
|
||||||
const isDark = document.documentElement.getAttribute("data-theme") === "dark";
|
const isDark = document.documentElement.getAttribute("data-theme") === "dark";
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
document.documentElement.removeAttribute("data-theme");
|
document.documentElement.removeAttribute("data-theme");
|
||||||
try { localStorage.setItem("daybook.theme", "light"); } catch (e) {}
|
try { localStorage.setItem("agentfeed.theme", "light"); } catch (e) {}
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
try { localStorage.setItem("daybook.theme", "dark"); } catch (e) {}
|
try { localStorage.setItem("agentfeed.theme", "dark"); } catch (e) {}
|
||||||
}
|
}
|
||||||
syncLabel();
|
syncLabel();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@
|
||||||
<title>AgentFeed</title>
|
<title>AgentFeed</title>
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" href="/static/daybook.css">
|
<link rel="stylesheet" href="/static/agentfeed.css">
|
||||||
<script>
|
<script>
|
||||||
// Set the theme before first paint so dark-mode users never see a light flash.
|
// Set the theme before first paint so dark-mode users never see a light flash.
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
if (localStorage.getItem("daybook.theme") === "dark") {
|
if (localStorage.getItem("agentfeed.theme") === "dark") {
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
@ -141,8 +141,8 @@
|
||||||
const sourceSelected = Boolean(feed.dataset.selectedSource);
|
const sourceSelected = Boolean(feed.dataset.selectedSource);
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
days: Number(localStorage.getItem("daybook.daysWidth")) || 144,
|
days: Number(localStorage.getItem("agentfeed.daysWidth")) || 144,
|
||||||
artifacts: Number(localStorage.getItem("daybook.artifactsWidth")) || 280,
|
artifacts: Number(localStorage.getItem("agentfeed.artifactsWidth")) || 280,
|
||||||
};
|
};
|
||||||
|
|
||||||
const applySizes = () => {
|
const applySizes = () => {
|
||||||
|
|
@ -177,7 +177,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const up = () => {
|
const up = () => {
|
||||||
localStorage.setItem(`daybook.${target}Width`, String(sizes[target]));
|
localStorage.setItem(`agentfeed.${target}Width`, String(sizes[target]));
|
||||||
document.body.classList.remove("is-resizing");
|
document.body.classList.remove("is-resizing");
|
||||||
splitter.removeEventListener("pointermove", move);
|
splitter.removeEventListener("pointermove", move);
|
||||||
splitter.removeEventListener("pointerup", up);
|
splitter.removeEventListener("pointerup", up);
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@
|
||||||
<title>Log in — AgentFeed</title>
|
<title>Log in — AgentFeed</title>
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" href="/static/daybook.css">
|
<link rel="stylesheet" href="/static/agentfeed.css">
|
||||||
<script>
|
<script>
|
||||||
// Set the theme before first paint so dark-mode users never see a light flash.
|
// Set the theme before first paint so dark-mode users never see a light flash.
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
if (localStorage.getItem("daybook.theme") === "dark") {
|
if (localStorage.getItem("agentfeed.theme") === "dark") {
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<div class="about-head">
|
<div class="about-head">
|
||||||
<img class="about-logo" src="/static/logo.png" alt="">
|
<img class="about-logo" src="/static/logo.png" alt="">
|
||||||
<div>
|
<div>
|
||||||
<h2>Daybook</h2>
|
<h2>AgentFeed</h2>
|
||||||
<p class="about-version">Version {{ version }}</p>
|
<p class="about-version">Version {{ version }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
<h3>API Keys</h3>
|
<h3>API Keys</h3>
|
||||||
<p>
|
<p>
|
||||||
Posting to Daybook uses named API keys — one per agent or script, each
|
Posting to AgentFeed uses named API keys — one per agent or script, each
|
||||||
revocable on its own. You have {{ key_count }} key{{ key_count|pluralize }}.
|
revocable on its own. You have {{ key_count }} key{{ key_count|pluralize }}.
|
||||||
</p>
|
</p>
|
||||||
<p><a href="{% url 'settings-api-keys' %}">Manage API keys →</a></p>
|
<p><a href="{% url 'settings-api-keys' %}">Manage API keys →</a></p>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<h2>API Keys</h2>
|
<h2>API Keys</h2>
|
||||||
<p>
|
<p>
|
||||||
One key per agent or script. Name each key so you know which agent it's
|
One key per agent or script. Name each key so you know which agent it's
|
||||||
for, copy it into that agent's <code>DAYBOOK_TOKEN</code>, and revoke keys
|
for, copy it into that agent's <code>AGENTFEED_TOKEN</code>, and revoke keys
|
||||||
individually here. Use a key as
|
individually here. Use a key as
|
||||||
<code>Authorization: Bearer <key></code> when posting.
|
<code>Authorization: Bearer <key></code> when posting.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@
|
||||||
<title>Settings — AgentFeed</title>
|
<title>Settings — AgentFeed</title>
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" href="/static/daybook.css">
|
<link rel="stylesheet" href="/static/agentfeed.css">
|
||||||
<script>
|
<script>
|
||||||
// Set the theme before first paint so dark-mode users never see a light flash.
|
// Set the theme before first paint so dark-mode users never see a light flash.
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
if (localStorage.getItem("daybook.theme") === "dark") {
|
if (localStorage.getItem("agentfeed.theme") === "dark") {
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from artifacts.models import ApiKey
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
@override_settings(DAYBOOK_KEY_PROVISIONING_API=True)
|
@override_settings(AGENTFEED_KEY_PROVISIONING_API=True)
|
||||||
class ApiKeyProvisioningEnabledTestCase(APITestCase):
|
class ApiKeyProvisioningEnabledTestCase(APITestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(username="alice", password="pw")
|
self.user = User.objects.create_user(username="alice", password="pw")
|
||||||
|
|
@ -78,7 +78,7 @@ class ApiKeyProvisioningEnabledTestCase(APITestCase):
|
||||||
self.assertEqual(response.data["methods"]["/api/keys"], ["GET", "POST"])
|
self.assertEqual(response.data["methods"]["/api/keys"], ["GET", "POST"])
|
||||||
|
|
||||||
|
|
||||||
@override_settings(DAYBOOK_KEY_PROVISIONING_API=False)
|
@override_settings(AGENTFEED_KEY_PROVISIONING_API=False)
|
||||||
class ApiKeyProvisioningDisabledTestCase(APITestCase):
|
class ApiKeyProvisioningDisabledTestCase(APITestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(username="alice", password="pw")
|
self.user = User.objects.create_user(username="alice", password="pw")
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
super().setUpClass()
|
super().setUpClass()
|
||||||
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="daybook-test-artifacts-"))
|
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="agentfeed-test-artifacts-"))
|
||||||
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
|
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
|
||||||
cls.settings_override.enable()
|
cls.settings_override.enable()
|
||||||
|
|
||||||
|
|
@ -96,9 +96,9 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
body = response.content.decode()
|
body = response.content.decode()
|
||||||
# Frontmatter is intact and the config placeholder is rewritten to this
|
# Frontmatter is intact and the config placeholder is rewritten to this
|
||||||
# instance's origin (http://testserver under the test client).
|
# instance's origin (http://testserver under the test client).
|
||||||
self.assertIn("name: daybook-posting", body)
|
self.assertIn("name: agentfeed-posting", body)
|
||||||
self.assertIn("DAYBOOK_URL=http://testserver", body)
|
self.assertIn("AGENTFEED_URL=http://testserver", body)
|
||||||
self.assertNotIn("your-daybook-instance.example.com", body)
|
self.assertNotIn("your-agentfeed-instance.example.com", body)
|
||||||
|
|
||||||
def test_skill_json_format(self):
|
def test_skill_json_format(self):
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
|
|
@ -107,7 +107,7 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.data["version"], __version__)
|
self.assertEqual(response.data["version"], __version__)
|
||||||
self.assertEqual(response.data["url"], "http://testserver")
|
self.assertEqual(response.data["url"], "http://testserver")
|
||||||
self.assertIn("name: daybook-posting", response.data["skill"])
|
self.assertIn("name: agentfeed-posting", response.data["skill"])
|
||||||
|
|
||||||
def test_skill_with_session(self):
|
def test_skill_with_session(self):
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
@ -442,7 +442,7 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
self.assertEqual(stored_html.count("</script>"), 3)
|
self.assertEqual(stored_html.count("</script>"), 3)
|
||||||
|
|
||||||
# The escaped content must still round-trip to the exact original value.
|
# The escaped content must still round-trip to the exact original value.
|
||||||
start = stored_html.index('id="daybook-data">') + len('id="daybook-data">')
|
start = stored_html.index('id="agentfeed-data">') + len('id="agentfeed-data">')
|
||||||
end = stored_html.index("</script>", start)
|
end = stored_html.index("</script>", start)
|
||||||
embedded = stored_html[start:end]
|
embedded = stored_html[start:end]
|
||||||
self.assertEqual(json.loads(embedded)["value"], "</script><script>alert(1)</script>")
|
self.assertEqual(json.loads(embedded)["value"], "</script><script>alert(1)</script>")
|
||||||
|
|
|
||||||
|
|
@ -6,76 +6,76 @@ from pathlib import Path
|
||||||
SCRIPT_PATH = (
|
SCRIPT_PATH = (
|
||||||
Path(__file__).resolve().parents[2]
|
Path(__file__).resolve().parents[2]
|
||||||
/ "skills"
|
/ "skills"
|
||||||
/ "daybook-posting"
|
/ "agentfeed-posting"
|
||||||
/ "scripts"
|
/ "scripts"
|
||||||
/ "daybook_post.py"
|
/ "agentfeed_post.py"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _load_module():
|
def _load_module():
|
||||||
spec = importlib.util.spec_from_file_location("daybook_post", SCRIPT_PATH)
|
spec = importlib.util.spec_from_file_location("agentfeed_post", SCRIPT_PATH)
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
return module
|
return module
|
||||||
|
|
||||||
|
|
||||||
daybook_post = _load_module()
|
agentfeed_post = _load_module()
|
||||||
|
|
||||||
|
|
||||||
class SniffTextKindTestCase(unittest.TestCase):
|
class SniffTextKindTestCase(unittest.TestCase):
|
||||||
def test_markdown_suffixes(self):
|
def test_markdown_suffixes(self):
|
||||||
for suffix in (".md", ".markdown", ".txt"):
|
for suffix in (".md", ".markdown", ".txt"):
|
||||||
self.assertEqual(daybook_post.sniff_text_kind(suffix, "anything"), "markdown")
|
self.assertEqual(agentfeed_post.sniff_text_kind(suffix, "anything"), "markdown")
|
||||||
|
|
||||||
def test_json_suffix(self):
|
def test_json_suffix(self):
|
||||||
self.assertEqual(daybook_post.sniff_text_kind(".json", '{"a": 1}'), "json")
|
self.assertEqual(agentfeed_post.sniff_text_kind(".json", '{"a": 1}'), "json")
|
||||||
|
|
||||||
def test_csv_suffix(self):
|
def test_csv_suffix(self):
|
||||||
self.assertEqual(daybook_post.sniff_text_kind(".csv", "a,b\n1,2"), "csv")
|
self.assertEqual(agentfeed_post.sniff_text_kind(".csv", "a,b\n1,2"), "csv")
|
||||||
|
|
||||||
def test_html_full_sniffed_from_content(self):
|
def test_html_full_sniffed_from_content(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
daybook_post.sniff_text_kind(".html", "<!doctype html><html></html>"), "html_full"
|
agentfeed_post.sniff_text_kind(".html", "<!doctype html><html></html>"), "html_full"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_deck_sniffed_from_content(self):
|
def test_deck_sniffed_from_content(self):
|
||||||
self.assertEqual(daybook_post.sniff_text_kind(".html", "<section>slide</section>"), "deck")
|
self.assertEqual(agentfeed_post.sniff_text_kind(".html", "<section>slide</section>"), "deck")
|
||||||
|
|
||||||
def test_html_fragment_is_the_fallback(self):
|
def test_html_fragment_is_the_fallback(self):
|
||||||
self.assertEqual(daybook_post.sniff_text_kind(".html", "<p>hi</p>"), "html_fragment")
|
self.assertEqual(agentfeed_post.sniff_text_kind(".html", "<p>hi</p>"), "html_fragment")
|
||||||
|
|
||||||
|
|
||||||
class ValidatePayloadTestCase(unittest.TestCase):
|
class ValidatePayloadTestCase(unittest.TestCase):
|
||||||
def test_valid_json_passes(self):
|
def test_valid_json_passes(self):
|
||||||
daybook_post.validate_payload({"kind": "json", "content": '{"a": 1}'}, None) # no raise
|
agentfeed_post.validate_payload({"kind": "json", "content": '{"a": 1}'}, None) # no raise
|
||||||
|
|
||||||
def test_invalid_json_raises(self):
|
def test_invalid_json_raises(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
daybook_post.validate_payload({"kind": "json", "content": "{not json"}, None)
|
agentfeed_post.validate_payload({"kind": "json", "content": "{not json"}, None)
|
||||||
|
|
||||||
def test_deck_without_section_raises(self):
|
def test_deck_without_section_raises(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
daybook_post.validate_payload({"kind": "deck", "content": "<h1>no slides</h1>"}, None)
|
agentfeed_post.validate_payload({"kind": "deck", "content": "<h1>no slides</h1>"}, None)
|
||||||
|
|
||||||
def test_html_fragment_with_nested_html_raises(self):
|
def test_html_fragment_with_nested_html_raises(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
daybook_post.validate_payload(
|
agentfeed_post.validate_payload(
|
||||||
{"kind": "html_fragment", "content": "<html><body>x</body></html>"}, None
|
{"kind": "html_fragment", "content": "<html><body>x</body></html>"}, None
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_html_full_without_html_tag_raises(self):
|
def test_html_full_without_html_tag_raises(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
daybook_post.validate_payload({"kind": "html_full", "content": "<p>x</p>"}, None)
|
agentfeed_post.validate_payload({"kind": "html_full", "content": "<p>x</p>"}, None)
|
||||||
|
|
||||||
def test_blank_markdown_raises(self):
|
def test_blank_markdown_raises(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
daybook_post.validate_payload({"kind": "markdown", "content": " "}, None)
|
agentfeed_post.validate_payload({"kind": "markdown", "content": " "}, None)
|
||||||
|
|
||||||
def test_csv_has_no_shape_check(self):
|
def test_csv_has_no_shape_check(self):
|
||||||
daybook_post.validate_payload({"kind": "csv", "content": "anything at all"}, None) # no raise
|
agentfeed_post.validate_payload({"kind": "csv", "content": "anything at all"}, None) # no raise
|
||||||
|
|
||||||
def test_upload_path_skips_all_checks(self):
|
def test_upload_path_skips_all_checks(self):
|
||||||
daybook_post.validate_payload({"upload_path": Path("whatever.png")}, None) # no raise
|
agentfeed_post.validate_payload({"upload_path": Path("whatever.png")}, None) # no raise
|
||||||
|
|
||||||
|
|
||||||
class ResolveAutoPathTestCase(unittest.TestCase):
|
class ResolveAutoPathTestCase(unittest.TestCase):
|
||||||
|
|
@ -93,7 +93,7 @@ class ResolveAutoPathTestCase(unittest.TestCase):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = Path(tmp) / "data.json"
|
path = Path(tmp) / "data.json"
|
||||||
path.write_text('{"a": 1}', encoding="utf-8")
|
path.write_text('{"a": 1}', encoding="utf-8")
|
||||||
args = daybook_post.resolve_auto_path(self._args(path))
|
args = agentfeed_post.resolve_auto_path(self._args(path))
|
||||||
self.assertEqual(args.kind, "json")
|
self.assertEqual(args.kind, "json")
|
||||||
self.assertEqual(args.content_file, str(path))
|
self.assertEqual(args.content_file, str(path))
|
||||||
|
|
||||||
|
|
@ -101,14 +101,14 @@ class ResolveAutoPathTestCase(unittest.TestCase):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = Path(tmp) / "data.csv"
|
path = Path(tmp) / "data.csv"
|
||||||
path.write_text("a,b\n1,2", encoding="utf-8")
|
path.write_text("a,b\n1,2", encoding="utf-8")
|
||||||
args = daybook_post.resolve_auto_path(self._args(path))
|
args = agentfeed_post.resolve_auto_path(self._args(path))
|
||||||
self.assertEqual(args.kind, "csv")
|
self.assertEqual(args.kind, "csv")
|
||||||
|
|
||||||
def test_image_file_becomes_multipart_upload(self):
|
def test_image_file_becomes_multipart_upload(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = Path(tmp) / "chart.png"
|
path = Path(tmp) / "chart.png"
|
||||||
path.write_bytes(b"\x89PNG\r\n")
|
path.write_bytes(b"\x89PNG\r\n")
|
||||||
args = daybook_post.resolve_auto_path(self._args(path))
|
args = agentfeed_post.resolve_auto_path(self._args(path))
|
||||||
self.assertIsNone(args.kind)
|
self.assertIsNone(args.kind)
|
||||||
self.assertEqual(args.file, str(path))
|
self.assertEqual(args.file, str(path))
|
||||||
|
|
||||||
|
|
@ -16,7 +16,7 @@ class MakeRoomForTestCase(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
super().setUpClass()
|
super().setUpClass()
|
||||||
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="daybook-test-storage-"))
|
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="agentfeed-test-storage-"))
|
||||||
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
|
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
|
||||||
cls.settings_override.enable()
|
cls.settings_override.enable()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ class SharingTestCase(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
super().setUpClass()
|
super().setUpClass()
|
||||||
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="daybook-test-sharing-"))
|
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="agentfeed-test-sharing-"))
|
||||||
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
|
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
|
||||||
cls.settings_override.enable()
|
cls.settings_override.enable()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue