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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ migration.
- **Agent home workspace.** An agent's files live in its home (`agents/agent-<b64>/` under the node mount) while a run is active, but agent-home directories are not browsed through daemon `workspace.list`/`workspace.read` commands anymore. Live workspace reads use the shared node/project root and the `workspace-read-shared` capability; generated file snapshots remain the durable record for offline browsing.
- **A task's workspace is derived, not stored.** A backlog task's rounds share `tasks/<taskId>/` under the node root, and a routine occurrence nests at `tasks/<sourceRoutineId>/<taskId>/` so a routine's runs are isolated but browsable together. `backend/relay/services/task_workspace.py` is the one seam that resolves it — never rebuild the path at a call site. A task dispatched into a project keeps the project workspace; the project always wins. The layout is chosen at dispatch against the already-chosen node, so a daemon without the `task-workspaces` capability degrades to the per-thread directory instead of failing the run (a project *is* its workspace, so its gate stays fatal). Because rounds now share a directory, the daemon's `workspaceRunGate` must key on the task workspace or concurrent rounds overwrite each other.
- **The waitlist is off by default and is not sign-up.** `RELAY_WAITLIST_ENABLED=1` mounts `/waitlist*` and `/admin/waitlist*` (`is_waitlist_route` in `backend/relay/api/contract.py`); the web learns it at runtime from `GET /auth/status` → `waitlistEnabled`, so there is no build flag. `deploy/deploy.sh` sets it to `1` for the hosted Railway deploy unless `deploy/.env` says `0`; the backend default stays off for every other install. `backend/relay/services/waitlist.py` is the one seam for transitions; `persistence/waitlist_store.py` is a plain row store (not an event log) whose conditional updates make an invite single-use — `claim_invite` before creating the account, `release_claim` if that fails. Public answers stay uniform: every valid join is `202` (new, known, declined, honeypot) and every dead token one `404`; the confirmation email is sent in a background task so its latency cannot reveal a new address. Invite tokens ride the URL fragment and POST bodies (`/waitlist/invites/lookup`, `/accept`) — never a path, so no access log keeps one — and only their hash is stored; batch invites return their links before sending (`queued`). Email goes through `backend/relay/notifications/mailer.py` (`CloudflareMailer` via the Cloudflare Email Service REST API, else `NullMailer`); a send never raises or changes an entry's status, and a partial `RELAY_MAIL_*` set fails startup. Plan: `docs/waitlist-design.md`.
- **A password reset proves the inbox and signs nobody in.** Asking for a link (`POST /auth/password-reset`) exists only while mail and a public origin are configured (`PasswordResetService.self_serve_enabled` → `GET /auth/status` `passwordResetEnabled`) — an emailed link takes its origin from `trusted_web_origin`, never the request's `Host`, which anyone can forge; redeeming one never needs mail, because `POST /admin/users/{id}/password-reset` always returns the link for an admin to hand over. `backend/relay/services/password_reset.py` decides who a request reaches and what is sent; `backend/relay/security/password_resets.py` keeps the records beside whichever auth store holds the accounts (`auth_password_resets`, or `password-resets.json` under the file store). The request answers `202` whatever matched and does its lookup and send in a background task; every dead link is one `404`. An Account's address is its own `email`, else its User's; an email shared by several Accounts sends each its own link, up to three per request. The public request is rate limited per IP only: a per-identifier bucket would let anyone lock a person out. Issuing a link retires the Account's older ones, a per-Account cooldown and hourly cap live in the store so they hold across replicas, and completion claims the link, applies `validate_password` (a refusal rolls the claim back), sets the hash, and deletes every session in one transaction — then emails a "password changed" notice and leaves sign-in to `/auth/login`. Tokens ride the URL fragment (`/reset-password#token=…`) and POST bodies; only their SHA-256 is stored, and no log line carries a token or an address. Plan: `docs/password-reset-design.md`.
- **Chat channels are off by default.** `RELAY_CHANNELS_ENABLED=1` is what mounts the chat routes (`/admin/chat-integrations`, `/internal/chat/*`) — `is_channel_route` in `backend/relay/api/contract.py` is the one seam that classifies them — and `NEXT_PUBLIC_RELAY_CHANNELS_ENABLED=1` (`web/src/lib/features.ts`) is what gives the web build its `/channels` route, palette command, and `G H` chord. Both must be set together. The chat store is still built and tested (the `relay-chat` gateway package was removed); a new chat route must sit under one of those two prefixes or it escapes the switch.
- **Assignment follows the computer, not the roster.** Anyone who shares a computer shares its workspace, so work there may go to any agent on it or any team whose whole roster is placed on it. A project task may name a non-member agent or a team on the project's computer (`project_task_assignment_error` in `backend/relay/services/project_runtime.py` is the one admission rule; a member the project *disabled* stays refused), and it still runs in the project workspace, and its thread stays the project's room — the team never becomes the thread's team. A started thread may address a team on its computer (`addressTeamId`) or, in a team thread, an agent outside the team (`CollaborationConductor._round_team_id`); the thread keeps its own team, and recovery plus the legacy `/agent-runs` path keep the strict roster. A project thread is the exception: it stays the project's room, so addressing a team there is refused (`project_team_conflict`) — route team work into a project through a task instead. The web mirrors the rule in `agentOnComputer` / `teamOnComputer` / `teamSharesOneComputer` (`web/src/lib/taskAssignment.ts`) and `threadRoundTeam` (`web/src/lib/messageRouting.ts`). Team setup enforces the same shape up front: a team picks its computer first and records `computerId`, and every member must be placed there (`backend/relay/services/team_computer.py` is the one seam; the web mirror is `web/src/lib/teamComputer.ts`) — the same rule a project's crew already follows through `validate_project_roster`.
- **A project's brief reaches every run; agents propose, owners apply.** `goal`/`context` ride the round's pinned project snapshot, and the backend renders them with the enabled crew into the `[Project]` prompt section (`project_context_brief` in `backend/relay/services/project_context.py`); the daemon mirrors it to `.relay/PROJECT.md`. Agent-drafted setup (`backend/relay/services/project_setup.py`, `POST /projects/{id}/setup`) is an ordinary daemon run whose drafter is admitted by the project-task rule, not roster membership; its proposal (`.relay/project-setup-<runId>.json`, reported as `projectSetup` behind the `project-setup` capability) is only stored — applying it is the normal project PATCH plus `setupResolution`, so roster rules run when it takes effect. Round-result `contextSuggestions` likewise wait on the project until the owner adopts or dismisses them (`resolveContextSuggestions`); nothing an agent writes changes the brief by itself. See ADR-022.
Expand Down
7 changes: 7 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ A single-use, expiring credential issued to one Waitlist Entry. Redeeming it
creates an Account; only its SHA-256 is stored, and re-inviting rotates it.
_Avoid_: Invitation code, access key

**Password Reset**:
A single-use, expiring credential issued to one Account that lets whoever holds
it set that Account's password once; redeeming it signs every session out and
signs nobody in. Only its SHA-256 is stored, and issuing a new one retires any
earlier one for the same Account.
_Avoid_: Recovery token, reset code, magic link

**Computer**:
The product-facing execution host on which agents are placed and collaborate.
_Avoid_: Machine, worker
Expand Down
55 changes: 55 additions & 0 deletions backend/migrations/versions/20261016_0097_auth_password_resets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Add password reset links.

One row per issued link; only the SHA-256 of the token is stored. A row is
spent (``used_at``) when it is redeemed or a newer link supersedes it.

Revision ID: 20261016_0097
Revises: 20261016_0096
"""

from __future__ import annotations

import sqlalchemy as sa
from alembic import op

revision = "20261016_0097"
down_revision = "20261016_0096"
branch_labels = None
depends_on = None

ID = sa.Uuid(as_uuid=False).with_variant(sa.Text(), "sqlite")


def upgrade() -> None:
op.create_table(
"auth_password_resets",
sa.Column("id", ID, primary_key=True),
sa.Column(
"account_id",
ID,
sa.ForeignKey("auth_accounts.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("token_hash", sa.Text(), nullable=False, unique=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(
"ix_auth_password_resets_account_id_created_at",
"auth_password_resets",
["account_id", "created_at"],
)
op.create_index(
"ix_auth_password_resets_expires_at",
"auth_password_resets",
["expires_at"],
)


def downgrade() -> None:
op.drop_index("ix_auth_password_resets_expires_at", table_name="auth_password_resets")
op.drop_index(
"ix_auth_password_resets_account_id_created_at", table_name="auth_password_resets"
)
op.drop_table("auth_password_resets")
2 changes: 2 additions & 0 deletions backend/relay/api/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ def auth_status(request: Request, ctx: AppContextDep) -> dict[str, Any]:
# Public, so the sign-in screen can offer "Request access" only when
# the waitlist routes are mounted.
"waitlistEnabled": bool(getattr(request.app.state, "waitlist_enabled", False)),
# Whether "Forgot password?" can deliver: self-serve reset needs mail.
"passwordResetEnabled": request.app.state.password_reset.self_serve_enabled,
}


Expand Down
2 changes: 2 additions & 0 deletions backend/relay/api/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@
"backlog",
"channels",
"computer",
"forgot-password",
"issues",
"join",
"login",
"projects",
"reset-password",
"routines",
"settings",
"skills",
Expand Down
118 changes: 118 additions & 0 deletions backend/relay/api/password_reset_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Password reset over HTTP: the public forgot/reset pages and the admin link.

The public answers are deliberately uniform: every well-formed request is a
202 whether or not an Account matched, and every dead link one 404. Tokens
travel in POST bodies, never a path, so no access log records one.

Only asking for a link needs email (``self_serve_enabled``); redeeming one does
not, because an admin can hand out a link on an install that has no mail.
"""

from __future__ import annotations

from datetime import datetime
from typing import Any

from fastapi import APIRouter, BackgroundTasks, HTTPException, Request

from ..core.deploy_config import public_web_url
from ..security.auth import require_admin_session
from ..security.edge import client_ip
from ..services.password_reset import (
PasswordResetService,
ResetNotLiveError,
normalize_reset_identifier,
trusted_web_origin,
)
from .deps import AppContextDep
from .helpers import JsonBodyDep, backend_base_url, string_field

router = APIRouter()

_RESET_NOT_LIVE = "This reset link is invalid or has expired."


def _service(request: Request) -> PasswordResetService:
return request.app.state.password_reset


def _limit(request: Request, limiter_name: str, key: str) -> None:
retry_after = getattr(request.app.state, limiter_name).consume(key)
if retry_after is not None:
raise HTTPException(
429, "Too many requests. Try again later.", headers={"Retry-After": str(retry_after)}
)


def _admin_web_origin(request: Request) -> str:
# The admin sees this URL before handing it over, so falling back to the
# request's own origin is safe here — unlike an emailed self-serve link.
return public_web_url() or backend_base_url(request)


def _iso(value: datetime) -> str:
return value.isoformat(timespec="seconds").replace("+00:00", "Z")


@router.post("/auth/password-reset", status_code=202)
def request_password_reset(
request: Request, background: BackgroundTasks, *, _request_body: JsonBodyDep
) -> dict[str, bool]:
service = _service(request)
web_origin = trusted_web_origin()
if not service.self_serve_enabled or web_origin is None:
raise HTTPException(404, "Not Found")
# Per IP only. One inbox is protected by the per-account throttle in the
# store; a per-identifier bucket here would let anyone lock a person out
# of resetting by spending it on their username.
_limit(request, "password_reset_rate_limiter", f"password-reset:ip:{client_ip(request)}")
try:
identifier = normalize_reset_identifier(_request_body.get("identifier"))
except ValueError as error:
raise HTTPException(400, str(error)) from error
locale = _request_body.get("locale")
background.add_task(
service.request,
identifier,
locale=locale if isinstance(locale, str) else None,
web_origin=web_origin,
)
return {"ok": True}


@router.post("/auth/password-reset/lookup")
def lookup_password_reset(request: Request, *, _request_body: JsonBodyDep) -> dict[str, Any]:
_limit(request, "auth_rate_limiter", f"password-reset-lookup:{client_ip(request)}")
try:
live = _service(request).lookup(string_field(_request_body, "token"))
except ResetNotLiveError as error:
raise HTTPException(404, _RESET_NOT_LIVE) from error
return {"reset": {"username": live.account.username, "expiresAt": _iso(live.expires_at)}}


@router.post("/auth/password-reset/complete")
def complete_password_reset(
request: Request, background: BackgroundTasks, *, _request_body: JsonBodyDep
) -> dict[str, Any]:
service = _service(request)
_limit(request, "auth_rate_limiter", f"password-reset-complete:{client_ip(request)}")
try:
account = service.complete(
string_field(_request_body, "token"), string_field(_request_body, "password")
)
except ResetNotLiveError as error:
raise HTTPException(404, _RESET_NOT_LIVE) from error
except ValueError as error:
raise HTTPException(400, str(error)) from error
background.add_task(service.send_changed_notice, account)
return {"ok": True, "username": account.username}


@router.post("/admin/users/{user_id}/password-reset")
def admin_password_reset(user_id: str, request: Request, ctx: AppContextDep) -> dict[str, Any]:
require_admin_session(request, ctx.auth_store)
try:
issued = _service(request).admin_issue(user_id, web_origin=_admin_web_origin(request))
except KeyError as error:
raise HTTPException(404, "User not found.") from error
return {"url": issued.url, "expiresAt": _iso(issued.expires_at), "delivery": issued.delivery}
26 changes: 20 additions & 6 deletions backend/relay/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
daemon_node_routes,
managed_node_routes,
node_workspace_routes,
password_reset_routes,
profile_image_routes,
project_routes,
sandbox_routes,
Expand Down Expand Up @@ -98,7 +99,9 @@
)
from .services.team_membership import reconcile_team_memberships
from .services.waitlist import WaitlistService, waitlist_config_from_env
from .notifications.mailer import mailer_from_env
from .services.password_reset import PasswordResetService, password_reset_config_from_env
from .security.password_resets import password_reset_store_for
from .notifications.mailer import Mailer, mailer_from_env
from .services.workspace_query import WorkspaceQueryBroker
from .tasks import TaskScheduler
from .automations.service import AutomationService, max_runs_per_hour_from_env
Expand Down Expand Up @@ -264,8 +267,14 @@ def create_app(root_dir: str | Path = DEFAULT_RELAY_DATA_DIR) -> FastAPI:
create_schema=session_store.engine.dialect.name == "sqlite",
)
org_settings_store = org_settings_store_from_env(root_dir)
# One mailer for every transactional email; a half-configured mail setup
# fails startup here rather than silently dropping invites and resets.
mailer = mailer_from_env()
waitlist_enabled = waitlist_enabled_from_env()
waitlist = waitlist_service_from_env() if waitlist_enabled else None
waitlist = waitlist_service_from_env(mailer) if waitlist_enabled else None
password_reset = PasswordResetService(
password_reset_store_for(auth_store), mailer, config=password_reset_config_from_env()
)
from .persistence.operational_import import require_operational_import
require_operational_import(root_dir, managed_node_store)
control_plane_notifier = KeyedEventNotifier()
Expand Down Expand Up @@ -403,6 +412,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
attempts=int(os.environ.get("RELAY_WAITLIST_RATE_LIMIT_ATTEMPTS", "5")),
window_seconds=int(os.environ.get("RELAY_WAITLIST_RATE_LIMIT_WINDOW_SECONDS", "600")),
)
app.state.password_reset = password_reset
app.state.password_reset_rate_limiter = AuthRateLimiter(
attempts=int(os.environ.get("RELAY_PASSWORD_RESET_RATE_LIMIT_ATTEMPTS", "5")),
window_seconds=int(os.environ.get("RELAY_PASSWORD_RESET_RATE_LIMIT_WINDOW_SECONDS", "900")),
)
app.state.managed_node_store = managed_node_store
app.state.agent_store = agent_store
app.state.team_store = team_store
Expand Down Expand Up @@ -455,6 +469,7 @@ async def api_info() -> dict[str, Any]:
sandbox_routes.router,
daemon_node_routes.router,
managed_node_routes.router,
password_reset_routes.router,
waitlist_routes.router,
)
canonical_groups = api_router_groups()
Expand Down Expand Up @@ -637,12 +652,11 @@ def waitlist_enabled_from_env() -> bool:
return value in ("1", "true", "yes", "on")


def waitlist_service_from_env() -> WaitlistService:
# Database-only like org settings; the mailer is built here so a
# half-configured mail setup fails startup rather than dropping invites.
def waitlist_service_from_env(mailer: Mailer) -> WaitlistService:
# Database-only like org settings.
url = database_url_from_env(setting="database-only waitlist storage")
store = DatabaseWaitlistStore(url, create_schema=url.startswith("sqlite"))
return WaitlistService(store, mailer_from_env(), config=waitlist_config_from_env())
return WaitlistService(store, mailer, config=waitlist_config_from_env())


def chat_store_from_env(root_dir: Path) -> Any:
Expand Down
Loading