Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 7 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ members = [
resolver = "2"

[workspace.package]
version = "3.50.7"
version = "3.51.0"
edition = "2024"
license-file = "LICENSE"
authors = ["PureTensor, Inc. <hal@puretensor.ai>"]
Expand Down Expand Up @@ -52,12 +52,12 @@ rmcp = { version = "3.3.0", features = ["server", "macros", "schemars", "transpo
futures-util = { version = "0.3", default-features = false }

# Internal crates
ptask-core = { path = "crates/ptask-core", version = "3.50.7" }
ptask-tui = { path = "crates/ptask-tui", version = "3.50.7" }
ptask-server = { path = "crates/ptask-server", version = "3.50.7" }
ptask-bot = { path = "crates/ptask-bot", version = "3.50.7" }
ptask-distill = { path = "crates/ptask-distill", version = "3.50.7" }
ptask-notify = { path = "crates/ptask-notify", version = "3.50.7" }
ptask-core = { path = "crates/ptask-core", version = "3.51.0" }
ptask-tui = { path = "crates/ptask-tui", version = "3.51.0" }
ptask-server = { path = "crates/ptask-server", version = "3.51.0" }
ptask-bot = { path = "crates/ptask-bot", version = "3.51.0" }
ptask-distill = { path = "crates/ptask-distill", version = "3.51.0" }
ptask-notify = { path = "crates/ptask-notify", version = "3.51.0" }

# HMAC for outbound webhook signing (v0.3.5).
sha2 = "0.10"
Expand Down
29 changes: 21 additions & 8 deletions dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ A visually compelling, on-brand, live **triage cockpit** for PureTensor Task
Intelligence (`pt`). Surfaces pending tasks and critical issues ranked by the
server's composite `priority_score`, with four runtime-switchable themes.

**Live:** https://ptask.tail07f9ef.ts.net (tailnet-gated; no login; approval decisions need the decide token)
**Live:** https://ptask.tail07f9ef.ts.net (tailnet-gated; no login; approval decisions and permanent deletes need the decide token)

## What it is

Expand Down Expand Up @@ -143,6 +143,7 @@ transaction.
| POST | `/api/tasks/<id>/snooze` `{days?}` | shells `pt snooze <id> "<days> days"` (v0.12) |
| POST | `/api/tasks/<id>/dismiss` | shells `pt dismiss [--note=…] <id>` (v0.12; optional `{"note"}` reason, v0.23) |
| POST | `/api/tasks/<id>/reopen` | shells `pt reopen <id>` (v0.12) |
| POST | `/api/tasks/<id>/delete` | superadmin: shells `pt rm --yes -- PT-N` for a task in any status (`<id>` is the PT-N or uuid; the sidecar looks up the PT-N, since `pt rm` reads anything else as an open-task title). Permanent, tombstoned for delta sync. Needs `X-PTask-Decide-Token` = `PTASK_DASH_DECIDE_TOKEN` (403 `decide_token_required` / `decide_disabled` otherwise); unknown task 404 (v0.26) |
| POST | `/api/tasks/<id>/edit` `{title?, description?, priority?, deadline?, labels_add?, labels_remove?}` | shells `pt edit` (+ `pt priority` for level); null deadline clears (v0.12) |
| POST | `/api/tasks` `{title, description?, priority?, deadline?}` | shells `pt add [--priority=] [--description=] [--deadline=] -- "<title>"` |
| POST | `/api/voice` (raw audio body) | Whisper STT → Bedrock Claude draft → `{transcript, fields:{title,description,priority,deadline,labels,domain,reason}}` to pre-fill the composer |
Expand Down Expand Up @@ -182,7 +183,7 @@ PTASK_DB=/tmp/tasks.dev.db PTASK_DASH_BIND=127.0.0.1:9519 python3 server.py
| `PTASK_ACTOR` | `dashboard` | actor stamped on dashboard-originated `pt` writes |
| `PTASK_WAL_AUTOCHECKPOINT` | `0` in `ptask-dashboard.service` | passed to the `pt` writers the sidecar spawns, so they leave WAL checkpoints to Litestream like every other writer; set `1000` in `.dashboard.env` on a host without Litestream |
| `PTASK_DASH_ALLOWED_HOSTS` | _(unset)_ | extra comma-separated `Host` names to serve besides IP literals, `localhost`, the machine's short hostname and `*.ts.net`; `.suffix` entries match the suffix |
| `PTASK_DASH_DECIDE_TOKEN` | _(unset)_ | secret (≥ 16 chars) required to approve/reject from the cockpit; unset disables approval decisions here |
| `PTASK_DASH_DECIDE_TOKEN` | _(unset)_ | superadmin secret (≥ 16 chars) required to approve/reject and to delete a task from the cockpit; unset disables both here and hides the drawer's Delete (`/api/config` → `superadmin: false`) |
| `PTASK_STT_URL` | `http://127.0.0.1:9000/transcribe` | voice STT endpoint (local Whisper); accepts `-F audio=@` |
| `PTASK_VOICE_MODEL` | `us.anthropic.claude-haiku-4-5-20251001-v1:0` | Bedrock model for voice→task extraction |
| `PTASK_VOICE_REGION` | `$AWS_DEFAULT_REGION` or `us-east-1` | Bedrock region (keyless, IAM via `~/.aws`) |
Expand Down Expand Up @@ -240,18 +241,21 @@ on the tailnet is the access decision for the board, with two limits:
writes included, to the internet. Remove any tunnel that predates v0.21.0.
The Host check refuses unknown public names; do not add them to
`PTASK_DASH_ALLOWED_HOSTS`.
- **Approval decisions need the decide token.** The tailnet carries the
fleet's agents too, and "only the operator decides" cannot rest on network
reach. Put a random secret in `~/puretensor-tasks/.dashboard.env`, restart,
and enter it once per browser when the cockpit asks:
- **Superadmin actions need the decide token.** Approval decisions and
permanent deletes are operator-only. The tailnet carries the fleet's agents
too, and "only the operator" cannot rest on network reach. Put a random
secret in `~/puretensor-tasks/.dashboard.env` (and in Vaultwarden as
`pTask cockpit superadmin token`), restart, and enter it once per browser or
home-screen app when the cockpit asks:

```bash
printf 'PTASK_DASH_DECIDE_TOKEN=%s\n' "$(openssl rand -hex 24)" >> ~/puretensor-tasks/.dashboard.env
systemctl --user restart ptask-dashboard
```

Without it, approve/reject answers 403 `decide_disabled`; decide at a
terminal with `pt approve|reject AP-n` instead.
Without it, approve/reject and delete answer 403 `decide_disabled` and the
drawer offers no Delete; use `pt approve|reject AP-n` or `pt rm PT-N` at a
terminal instead.

## Rollback

Expand All @@ -274,6 +278,15 @@ The canonical `pt serve` and `tasks.db` are never modified — nothing to revert

## Version

- **v0.26.0** (pt 3.51.0) — Superadmin delete. The task drawer, on desktop and
as the phone bottom sheet, has **Delete permanently** for a task in any
status (open, done or dismissed). It confirms first, then
`POST /api/tasks/<id>/delete` shells `pt rm --yes -- PT-N` (tombstoned, so
sync clients drop it). Like approval decisions it needs the decide token,
asked for once and kept in localStorage; `/api/config` reports
`superadmin` so the button only shows where the token is configured.
**Not** on the Rust `pt serve` dashboard, whose config carries no
`superadmin` (machine clients delete with `task_delete` on `/sync`).
- **v0.25.0** (pt 3.48.0) — Acceptance criteria. The task drawer shows a task's
definition of done as a checklist (checked / unchecked, who checked it),
folded from the full criteria trail in the journal (not only the newest 60
Expand Down
78 changes: 63 additions & 15 deletions dashboard/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
POST /api/tasks/<id>/note {text} -> shells `pt note <id> -- <text>`
POST /api/tasks/<id>/dismiss {note?} -> shells `pt dismiss [--note=] <id>`
POST /api/tasks/<id>/priority {level:1..5} -> shells `pt priority <id> <level>`
POST /api/tasks/<id>/delete -> shells `pt rm --yes PT-N`: permanent delete
of a task in any status (tombstoned for
delta sync). Superadmin-only: needs
X-PTask-Decide-Token (see below)
POST /api/tasks {title, description?, priority?, deadline?}
-> shells `pt add [--priority=] [--description=]
[--deadline=] -- "<title>"`
Expand All @@ -47,10 +51,12 @@
DNS-rebinding page could address the sidecar under the attacker's own
name and read or write as same-origin. State-changing POSTs also need a
same-origin Origin header when one is sent.
- Approval decisions are operator-only, and the tailnet does not separate
the operator from the fleet's agents. Approve/reject needs the
X-PTask-Decide-Token header to match PTASK_DASH_DECIDE_TOKEN; with the
variable unset (or shorter than 16 characters) decisions are refused.
- Approval decisions and permanent deletes are operator-only (superadmin),
and the tailnet does not separate the operator from the fleet's agents.
Both need the X-PTask-Decide-Token header to match
PTASK_DASH_DECIDE_TOKEN; with the variable unset (or shorter than 16
characters) they are refused. /api/config reports `superadmin` so the
cockpit only offers Delete where it can work.

Config (env)
------------
Expand All @@ -65,8 +71,9 @@
(default "dashboard")
PTASK_DASH_ALLOWED_HOSTS extra comma-separated Host names to serve
(".example.org" entries match the suffix)
PTASK_DASH_DECIDE_TOKEN secret the cockpit sends to approve/reject
(unset = approval decisions disabled here)
PTASK_DASH_DECIDE_TOKEN superadmin secret the cockpit sends to
approve/reject and to delete a task (unset =
decisions and deletes disabled here)
"""
from __future__ import annotations

Expand Down Expand Up @@ -140,7 +147,7 @@ def resolve_default_domain(domains: list[dict], requested: str | None) -> str |
# the dashboard exposes the same task data. Production sets PTASK_DASH_BIND.
BIND = os.environ.get("PTASK_DASH_BIND", "127.0.0.1:9510")

VERSION = "0.25.0"
VERSION = "0.26.0"


def _allowed_host_entry(entry: str) -> str:
Expand All @@ -159,9 +166,10 @@ def _allowed_host_entry(entry: str) -> str:
filter(None, (_allowed_host_entry(h)
for h in os.environ.get("PTASK_DASH_ALLOWED_HOSTS", "").split(",")))
)
# Shared secret for approval decisions. Shorter values are refused rather than
# trusted: a guessable secret would put decisions back within any tailnet
# agent's reach. Popped from the environment so no pt or aws child inherits it.
# Superadmin secret for approval decisions and permanent deletes. Shorter
# values are refused rather than trusted: a guessable secret would put both
# back within any tailnet agent's reach. Popped from the environment so no pt
# or aws child inherits it.
DECIDE_TOKEN_MIN = 16
DECIDE_TOKEN = os.environ.pop("PTASK_DASH_DECIDE_TOKEN", "").strip()
DASH_TITLE = os.environ.get("PTASK_DASH_TITLE", "PTASK")
Expand Down Expand Up @@ -712,6 +720,24 @@ def _event_dicts(rows):
return events


def q_task_ref(task_id: str) -> dict | None:
"""PT-N, title and status of one task in any status, by PT-N or uuid.

`pt rm` resolves a PT-N in any status but reads anything else as a title
substring of an open task, so a delete always hands it the PT-N.
"""
con = connect()
try:
r = con.execute(
"SELECT id, pt_id, title, status_v2 AS status FROM tasks"
" WHERE pt_id=? OR id=? LIMIT 1",
(task_id.upper(), task_id),
).fetchone()
return dict(r) if r else None
finally:
con.close()


def q_task_events(task_uuid: str, limit: int = 60):
"""Return attributed event history for the detail drawer, newest first.

Expand Down Expand Up @@ -1329,17 +1355,19 @@ def _redirect(self, location="/", code=302):
def _host_ok(self) -> bool:
return host_allowed(self.headers.get("Host"))

def _decide_refusal(self):
"""None when this request may decide an approval, else (code, message)."""
def _decide_refusal(self, what="approval decisions",
fallback="decide with `pt approve|reject AP-n` at a terminal"):
"""None when this request carries the superadmin (decide) token, else
(code, message). Gates approval decisions and permanent deletes."""
if len(DECIDE_TOKEN) < DECIDE_TOKEN_MIN:
return ("decide_disabled",
"approval decisions are disabled on this dashboard: set "
f"{what} are disabled on this dashboard: set "
f"PTASK_DASH_DECIDE_TOKEN (at least {DECIDE_TOKEN_MIN} characters), "
"or decide with `pt approve|reject AP-n` at a terminal")
f"or {fallback}")
presented = (self.headers.get("X-PTask-Decide-Token") or "").strip()
if presented and hmac.compare_digest(presented.encode(), DECIDE_TOKEN.encode()):
return None
return ("decide_token_required", "approval decisions need the decide token")
return ("decide_token_required", f"{what} need the decide token")

def _origin_ok(self) -> bool:
# CSRF for state-changing requests: a browser on the tailnet sending
Expand Down Expand Up @@ -1511,6 +1539,9 @@ def do_GET(self):
"domains": DASH_DOMAINS,
"default_domain": DASH_DEFAULT_DOMAIN,
"version": VERSION,
# Superadmin actions (permanent delete) are configured here;
# the token itself is still checked on every such request.
"superadmin": len(DECIDE_TOKEN) >= DECIDE_TOKEN_MIN,
})

try:
Expand Down Expand Up @@ -1662,6 +1693,23 @@ def do_POST(self):
ok, msg = pt_exec([verb, "--", tid])
return self._json({"ok": ok, "message": msg}, 200 if ok else 500)

m = re.match(r"^/api/tasks/([^/]+)/delete$", u.path)
if m:
tid = m.group(1)
if not _ID_RE.match(tid):
return self._json({"error": "bad id"}, 400)
refusal = self._decide_refusal(
"permanent deletes", "delete with `pt rm PT-N` at a terminal")
if refusal is not None:
code, message = refusal
return self._json({"ok": False, "code": code, "error": message}, 403)
task = q_task_ref(tid)
if task is None or not task["pt_id"]:
return self._json({"ok": False, "error": f"no task {tid}"}, 404)
ok, msg = pt_exec(["rm", "--yes", "--", task["pt_id"]])
return self._json({"ok": ok, "pt_id": task["pt_id"], "message": msg},
200 if ok else 500)

m = re.match(r"^/api/tasks/([^/]+)/note$", u.path)
if m:
tid = m.group(1)
Expand Down
Loading
Loading