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
36 changes: 36 additions & 0 deletions tests/test_routes_project_invites.py
Original file line number Diff line number Diff line change
Expand Up @@ -585,3 +585,39 @@ async def test_guide_markdown_contains_required_instructions(client, app, monkey
# Timed-check instruction mentions the interval value.
assert "1800" in guide



@pytest.mark.asyncio
async def test_mint_default_ttl_is_one_hour(client, app):
pid = await _create_project(client)
before = time.time()
resp = await client.post(
f"/api/projects/{pid}/invites",
json={"scopes": [], "approval_mode": "auto"},
)
assert resp.status_code == 200, resp.text
ttl = resp.json()["expires_ts"] - before
assert 3500 < ttl <= 3610


@pytest.mark.asyncio
async def test_mint_honours_ttl_secs(client, app):
pid = await _create_project(client)
before = time.time()
resp = await client.post(
f"/api/projects/{pid}/invites",
json={"scopes": [], "approval_mode": "auto", "ttl_secs": 86400},
)
assert resp.status_code == 200, resp.text
ttl = resp.json()["expires_ts"] - before
assert 86300 < ttl <= 86410


@pytest.mark.asyncio
async def test_mint_rejects_ttl_over_cap(client, app):
pid = await _create_project(client)
resp = await client.post(
f"/api/projects/{pid}/invites",
json={"scopes": [], "approval_mode": "auto", "ttl_secs": 999999},
)
assert resp.status_code == 422, resp.text
9 changes: 6 additions & 3 deletions tinyagentos/projects/invite_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from tinyagentos.base_store import BaseStore

_EXPIRY_SECS = 15 * 60
_EXPIRY_SECS = 60 * 60
_MAX_ATTEMPTS = 5
_PENDING_CAP = 10

Expand Down Expand Up @@ -131,7 +131,8 @@ async def mint(self, *, project_id=None, scopes: list[str], approval_mode: str,
display_name: str | None = None,
kind: str = "agent",
pin_required: bool = True,
contact_id: str | None = None) -> dict:
contact_id: str | None = None,
ttl_secs: int | None = None) -> dict:
if self._db is None:
raise RuntimeError("ProjectInviteStore not initialised")

Expand Down Expand Up @@ -180,7 +181,9 @@ async def mint(self, *, project_id=None, scopes: list[str], approval_mode: str,
pin = self._generate_pin()
pin_hash = hashlib.sha256(pin.encode()).hexdigest()
now = _now()
expires_ts = now + _EXPIRY_SECS
if ttl_secs is not None and ttl_secs <= 0:
raise ValueError("ttl_secs must be positive")
expires_ts = now + (ttl_secs if ttl_secs is not None else _EXPIRY_SECS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: mint only enforces a lower bound on ttl_secs (> 0); the 24-hour hard cap lives solely in the route-layer Pydantic model (le=86400).

mint is a shared boundary called from multiple routes (project mint here, plus the OS-level mint call sites). If any current or future caller passes a ttl_secs that hasn't been route-validated, this line will silently mint an invite that lives far longer than the intended 24h cap — defeating the "no invite lives indefinitely" guarantee described in the PR. Consider clamping/validating the upper bound in the store itself so the invariant holds regardless of caller.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.


# 6-digit invite IDs have a non-trivial collision chance under load
# (~12 % at 500 pending). Retry on UNIQUE constraint violation so a
Expand Down
5 changes: 5 additions & 0 deletions tinyagentos/routes/project_invites.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ class MintInviteIn(BaseModel):
scopes: list[str] = Field(default_factory=list)
approval_mode: str = Field(default="auto", pattern="^(auto|manual)$")
check_interval_secs: int = Field(default=1800, ge=60)
# How long the invite stays redeemable. Omitted means the store default
# (1 hour). Capped at 24 hours: the PIN is the security gate, expiry is
# hygiene, but an indefinitely live link is not a state we want.
ttl_secs: int | None = Field(default=None, ge=60, le=86400)


class MintOsInviteIn(BaseModel):
Expand Down Expand Up @@ -649,6 +653,7 @@ async def mint_invite(
approval_mode=payload.approval_mode,
check_interval_secs=payload.check_interval_secs,
created_by=user.user_id,
ttl_secs=payload.ttl_secs,
)
except InvitePendingCapError as exc:
return JSONResponse({"error": str(exc)}, status_code=429)
Expand Down
Loading