Skip to content
Closed
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
38 changes: 38 additions & 0 deletions tests/test_agent_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,8 @@ def test_mint_and_verify(self, tmp_path):
assert payload["sub"] == "agent-20260609-120000"
assert payload["iss"] == "taos-registry"
assert "iat" in payload
assert "exp" in payload
assert payload["exp"] > payload["iat"]
assert payload["user_id"] == "user-1"
assert payload["framework"] == "openclaw"

Expand Down Expand Up @@ -578,6 +580,42 @@ async def test_revoked_feed_admin_can_read(self, registry_client):
resp = await registry_client.get("/api/agents/registry/revoked")
assert resp.status_code == 200

async def test_renew_token_returns_new_token(self, registry_client):
"""POST /api/agents/registry/token/renew issues a fresh token."""
reg_resp = await registry_client.post(
"/api/agents/registry/register",
json={"framework": "openclaw", "display_name": "Renew Me"},
)
assert reg_resp.status_code == 200
old_token = reg_resp.json()["token"]

renew_resp = await registry_client.post(
"/api/agents/registry/token/renew",
headers={"Authorization": f"Bearer {old_token}"},
)
assert renew_resp.status_code == 200
new_token = renew_resp.json()["token"]
assert new_token != old_token

pubkey_resp = await registry_client.get("/api/agents/registry/pubkey")
pub_pem = pubkey_resp.json()["public_key"].encode()
old_payload = verify_registry_token(old_token, pub_pem)
new_payload = verify_registry_token(new_token, pub_pem)
assert new_payload["sub"] == old_payload["sub"]
assert new_payload["user_id"] == old_payload["user_id"]
assert new_payload["exp"] > new_payload["iat"]

async def test_renew_token_requires_bearer(self, registry_client):
resp = await registry_client.post("/api/agents/registry/token/renew")
assert resp.status_code == 401

async def test_renew_token_rejects_invalid_token(self, registry_client):
resp = await registry_client.post(
"/api/agents/registry/token/renew",
headers={"Authorization": "Bearer not-a-valid-token"},
)
assert resp.status_code == 401


# ---------------------------------------------------------------------------
# registry_feeds_read scope -- feed token auth
Expand Down
114 changes: 113 additions & 1 deletion tests/test_agent_registry_store.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import time
from datetime import datetime, timezone
from pathlib import Path

Expand All @@ -17,6 +18,7 @@
load_or_create_signing_keypair,
mint_canonical_id,
mint_registry_token,
renew_registry_token,
verify_registry_token,
VALID_STATUSES,
)
Expand Down Expand Up @@ -256,6 +258,14 @@ def test_token_has_iat(self, signing_keypair):
assert "iat" in payload
assert isinstance(payload["iat"], int)

def test_token_has_exp(self, signing_keypair):
priv, pub = signing_keypair
token = mint_registry_token("agent-008", priv)
payload = verify_registry_token(token, pub)
assert "exp" in payload
assert isinstance(payload["exp"], int)
assert payload["exp"] > payload["iat"]


# ---------------------------------------------------------------------------
# Signing keypair persistence
Expand Down Expand Up @@ -1256,10 +1266,112 @@ async def test_org_tree_dangling_reports_to_becomes_root(self, store):

@pytest.mark.asyncio
async def test_get_by_slug_rejects_glob_metacharacters(store):
# A caller-supplied member string with GLOB metachars must not reach the
# A caller-supplied member string with GLOB metachrs must not reach the
# matcher: it returns None instead of matching or raising.
assert await store.get_by_slug("alpha[a-z]") is None
assert await store.get_by_slug("alpha*") is None
assert await store.get_by_slug("alpha?") is None
assert await store.get_by_slug("") is None
assert await store.get_by_slug("Alpha") is None


# ---------------------------------------------------------------------------
# Token exp claim: minting, verification, renewal, migration window
# ---------------------------------------------------------------------------


def _build_token_no_exp(priv_pem, sub="agent-noexp") -> str:
header = _b64url_encode(
json.dumps({"alg": "EdDSA", "typ": "JWT"}, separators=(",", ":")).encode()
)
claims = {
"sub": sub,
"iss": "taos-registry",
"iat": int(time.time()),
"jti": "test-jti-noexp",
}
payload_b64 = _b64url_encode(json.dumps(claims, separators=(",", ":")).encode())
signing_input = f"{header}.{payload_b64}".encode()
from cryptography.hazmat.primitives.serialization import load_pem_private_key
priv = load_pem_private_key(priv_pem, password=None)
sig = _b64url_encode(priv.sign(signing_input))
return f"{header}.{payload_b64}.{sig}"


class TestTokenExpiration:
def test_mint_includes_exp(self, signing_keypair):
priv, pub = signing_keypair
token = mint_registry_token("agent-exp-check", priv)
payload = verify_registry_token(token, pub)
assert "exp" in payload
assert isinstance(payload["exp"], int)
assert payload["exp"] > payload["iat"]

def test_expired_token_is_rejected(self, signing_keypair, monkeypatch):
priv, pub = signing_keypair
token = mint_registry_token("agent-expired", priv, lifetime_seconds=0)
real_now = time.time()
monkeypatch.setattr(
"tinyagentos.agent_registry_store.time.time",
lambda: real_now + 1,
)
with pytest.raises(ValueError, match="expired"):
verify_registry_token(token, pub)

def test_token_without_exp_accepted_during_migration(self, signing_keypair, monkeypatch):
priv, pub = signing_keypair
token = _build_token_no_exp(priv)
cutoff = time.time() + 3600
monkeypatch.setattr(
"tinyagentos.agent_registry_store.time.time",
lambda: cutoff - 1800,
)
payload = verify_registry_token(token, pub, allow_no_exp_until=cutoff)
assert payload["sub"] == "agent-noexp"

def test_token_without_exp_rejected_after_migration(self, signing_keypair, monkeypatch):
priv, pub = signing_keypair
token = _build_token_no_exp(priv)
cutoff = time.time() + 3600
monkeypatch.setattr(
"tinyagentos.agent_registry_store.time.time",
lambda: cutoff + 1800,
)
with pytest.raises(ValueError, match="no exp claim"):
verify_registry_token(token, pub, allow_no_exp_until=cutoff)

def test_decode_fails_closed_without_exp(self, signing_keypair):
priv, pub = signing_keypair
token = _build_token_no_exp(priv)
with pytest.raises(ValueError, match="no exp claim"):
verify_registry_token(token, pub)


class TestTokenRenewal:
def test_renewal_issues_working_token(self, signing_keypair):
priv, pub = signing_keypair
original = mint_registry_token(
"agent-renew", priv, user_id="u1", framework="fw", lifetime_seconds=60
)
renewed = renew_registry_token(original, pub, priv)
payload = verify_registry_token(renewed, pub)
assert payload["sub"] == "agent-renew"
assert payload["user_id"] == "u1"
assert payload["framework"] == "fw"
assert payload["exp"] > payload["iat"]

def test_renewal_preserves_project_id(self, signing_keypair):
priv, pub = signing_keypair
original = mint_registry_token(
"agent-renew-proj", priv, project_id="proj-99", lifetime_seconds=60
)
renewed = renew_registry_token(original, pub, priv)
payload = verify_registry_token(renewed, pub)
assert payload["project_id"] == "proj-99"

def test_renewal_rejects_bad_signature(self, signing_keypair, tmp_path):
priv, _ = signing_keypair
_, wrong_pub = load_or_create_signing_keypair(tmp_path / "other_keys")
token = mint_registry_token("agent-bad-renew", priv)
with pytest.raises(ValueError, match="signature verification failed"):
renew_registry_token(token, wrong_pub, priv)
92 changes: 75 additions & 17 deletions tinyagentos/agent_registry_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,10 @@ def load_or_create_signing_keypair(data_dir: Path) -> tuple[bytes, bytes]:
# Token minting (compact JWT-style - header.payload.signature, base64url)
# ---------------------------------------------------------------------------

DEFAULT_REGISTRY_TOKEN_LIFETIME = 86400 # 24 hours: long enough for multi-turn agent
# tasks, short enough that a leaked bearer credential is not permanent.


def _b64url_encode(data: bytes) -> str:
import base64
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
Expand All @@ -276,13 +280,41 @@ def _b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s)


def _verify_signature_only(token: str, public_key_pem: bytes) -> dict:
"""Verify EdDSA signature and return payload without checking exp.

Internal helper for the renewal path, which must accept expired tokens
so an agent can rotate its credential without human intervention.
"""
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from cryptography.exceptions import InvalidSignature

parts = token.split(".")
if len(parts) != 3:
raise ValueError("token must have three dot-separated parts")

header_b64, payload_b64, sig_b64 = parts
signing_input = f"{header_b64}.{payload_b64}".encode()
sig_bytes = _b64url_decode(sig_b64)

public_key = load_pem_public_key(public_key_pem)
try:
public_key.verify(sig_bytes, signing_input)
except InvalidSignature:
raise ValueError("token signature verification failed") from None

payload = json.loads(_b64url_decode(payload_b64))
return payload


def mint_registry_token(
canonical_id: str,
private_key_pem: bytes,
*,
user_id: str = "",
framework: str = "",
project_id: Optional[str] = None,
lifetime_seconds: int | None = None,
) -> str:
"""Return a signed compact EdDSA JWT: <header>.<payload>.<signature> (base64url).

Expand All @@ -294,6 +326,7 @@ def mint_registry_token(
sub - canonical_id (immutable agent identity)
iss - "taos-registry"
iat - unix timestamp of issuance
exp - unix timestamp of expiry (iat + lifetime_seconds)
user_id - owning user_id at registration time
framework - agent framework at registration time
project_id - project binding, present only when non-empty; absent means
Expand All @@ -308,10 +341,13 @@ def mint_registry_token(
header = _b64url_encode(
json.dumps({"alg": "EdDSA", "typ": "JWT"}, separators=(",", ":")).encode()
)
if lifetime_seconds is None:
lifetime_seconds = DEFAULT_REGISTRY_TOKEN_LIFETIME
claims: dict = {
"sub": canonical_id,
"iss": "taos-registry",
"iat": int(time.time()),
"exp": int(time.time()) + lifetime_seconds,
"jti": uuid.uuid4().hex,
"user_id": user_id,
"framework": framework,
Expand All @@ -326,31 +362,53 @@ def mint_registry_token(
return f"{header}.{payload}.{signature}"


def verify_registry_token(token: str, public_key_pem: bytes) -> dict:
def verify_registry_token(
token: str,
public_key_pem: bytes,
allow_no_exp_until: float | None = None,
) -> dict:
"""Verify *token* against *public_key_pem*.

Returns the decoded payload dict on success.
Raises ``ValueError`` on invalid format or bad signature.
Raises ``ValueError`` on invalid format, bad signature, missing exp (after
the migration window), or expired token.
"""
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from cryptography.exceptions import InvalidSignature
payload = _verify_signature_only(token, public_key_pem)

parts = token.split(".")
if len(parts) != 3:
raise ValueError("token must have three dot-separated parts")
now = time.time()
exp = payload.get("exp")
if exp is not None:
if now >= exp:
raise ValueError("token has expired")
elif allow_no_exp_until is None or now >= allow_no_exp_until:
raise ValueError("token has no exp claim")

header_b64, payload_b64, sig_b64 = parts
signing_input = f"{header_b64}.{payload_b64}".encode()
sig_bytes = _b64url_decode(sig_b64)
return payload

public_key = load_pem_public_key(public_key_pem)
try:
public_key.verify(sig_bytes, signing_input)
except InvalidSignature:
raise ValueError("token signature verification failed") from None

payload = json.loads(_b64url_decode(payload_b64))
return payload
def renew_registry_token(
token: str,
public_key_pem: bytes,
private_key_pem: bytes,
*,
lifetime_seconds: int | None = None,
) -> str:
"""Renew an existing registry token.

Verifies the EdDSA signature (but does not require the token to be
unexpired) and returns a freshly-minted token with the same claims and
a new exp. This is the self-service path for agents whose token has
expired without human re-minting.
"""
payload = _verify_signature_only(token, public_key_pem)
return mint_registry_token(
payload["sub"],
private_key_pem,
Comment on lines +403 to +406

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

5. Renew bypasses exp migration 🐞 Bug ⛨ Security

renew_registry_token() re-mints tokens after verifying only the signature, so a legacy token
without exp can be exchanged for a fresh exp token even after the migration cutoff would have
rejected it. This defeats the cutoff policy intended to retire non-expiring tokens.
Agent Prompt
### Issue description
The renewal path accepts legacy tokens with no `exp` forever because it uses `_verify_signature_only()` and never applies the migration cutoff policy.

### Issue Context
The renewal path is intentionally allowed to accept *expired* tokens, but it should not allow *no-exp* legacy tokens past the configured migration cutoff.

### Fix Focus Areas
- tinyagentos/agent_registry_store.py[283-412]
- tinyagentos/routes/agent_registry.py[747-773]
- tinyagentos/config.py[52-214]

### Suggested fix
- Extend `renew_registry_token()` to accept `allow_no_exp_until: float | None` (or `migration_cutoff_ts`) and:
  - verify signature,
  - require that the token either has an `exp` claim OR `now < allow_no_exp_until`.
  - do **not** require the token to be unexpired.
- In `renew_registry_token_route`, pass `request.app.state.config.registry_token_migration_cutoff_ts` into `renew_registry_token()`.
- Add a test covering: a legacy no-`exp` token cannot be renewed after the cutoff.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

user_id=payload.get("user_id", ""),
framework=payload.get("framework", ""),
project_id=payload.get("project_id"),
lifetime_seconds=lifetime_seconds,
)
Comment on lines +389 to +411

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard against a payload missing the sub claim.

renew_registry_token accesses payload["sub"] directly at Line 405. _verify_signature_only only checks the signature, not the claim shape, so a validly-signed but malformed payload (missing sub) raises KeyError here instead of ValueError. The renewal route in tinyagentos/routes/agent_registry.py only catches ValueError, so this would surface as a 500 instead of a controlled 401. Other verification call sites (agent_token_auth.py) defensively use payload.get("sub", "") before use; apply the same pattern here.

🛡️ Proposed fix
     payload = _verify_signature_only(token, public_key_pem)
+    canonical_id = payload.get("sub")
+    if not canonical_id:
+        raise ValueError("token missing sub claim")
     return mint_registry_token(
-        payload["sub"],
+        canonical_id,
         private_key_pem,
         user_id=payload.get("user_id", ""),
         framework=payload.get("framework", ""),
         project_id=payload.get("project_id"),
         lifetime_seconds=lifetime_seconds,
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def renew_registry_token(
token: str,
public_key_pem: bytes,
private_key_pem: bytes,
*,
lifetime_seconds: int | None = None,
) -> str:
"""Renew an existing registry token.
Verifies the EdDSA signature (but does not require the token to be
unexpired) and returns a freshly-minted token with the same claims and
a new exp. This is the self-service path for agents whose token has
expired without human re-minting.
"""
payload = _verify_signature_only(token, public_key_pem)
return mint_registry_token(
payload["sub"],
private_key_pem,
user_id=payload.get("user_id", ""),
framework=payload.get("framework", ""),
project_id=payload.get("project_id"),
lifetime_seconds=lifetime_seconds,
)
def renew_registry_token(
token: str,
public_key_pem: bytes,
private_key_pem: bytes,
*,
lifetime_seconds: int | None = None,
) -> str:
"""Renew an existing registry token.
Verifies the EdDSA signature (but does not require the token to be
unexpired) and returns a freshly-minted token with the same claims and
a new exp. This is the self-service path for agents whose token has
expired without human re-minting.
"""
payload = _verify_signature_only(token, public_key_pem)
canonical_id = payload.get("sub")
if not canonical_id:
raise ValueError("token missing sub claim")
return mint_registry_token(
canonical_id,
private_key_pem,
user_id=payload.get("user_id", ""),
framework=payload.get("framework", ""),
project_id=payload.get("project_id"),
lifetime_seconds=lifetime_seconds,
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/agent_registry_store.py` around lines 389 - 411, Update
renew_registry_token to retrieve the subject claim with payload.get("sub", "")
instead of direct payload["sub"] access, matching the defensive pattern used by
other verification call sites and ensuring malformed signed payloads raise
ValueError through mint_registry_token rather than KeyError.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate file and relevant symbols"
if [ -f tinyagentos/agent_registry_store.py ]; then
  wc -l tinyagentos/agent_registry_store.py
  ast-grep outline tinyagentos/agent_registry_store.py --match renew_registry_token --view expanded || true
  echo "--- relevant lines 240-430 ---"
  sed -n '240,430p' tinyagentos/agent_registry_store.py | nl -ba -v240
else
  fd -i 'agent_registry_store.py' .
fi

echo "--- search for token handling symbols ---"
rg -n "TOKEN_(LIFETIME|RENEW_|EXPIRED)?|lifetime_seconds|renew_registry_token|mint_registry_token|active|verify_signature|jti" tinyagentos

Repository: jaylfc/taOS

Length of output: 361


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- relevant lines 240-430 ---"
sed -n '240,430p' tinyagentos/agent_registry_store.py | sed 's/^/: /'

echo "--- search token-related symbols ---"
rg -n "TOKEN_(LIFETIME|RENEW_|EXPIRED)?|lifetime_seconds|renew_registry_token|mint_registry_token|active|verify_signature|jti" tinyagentos || true

echo "--- inspect registry state references around relevant functions ---"
rg -n "_verify_signature_only|mint_registry_token|renew_registry_token|def .*registry_token|lookup|active|update|delete|agent" tinyagentos/agent_registry_store.py | sed 's/^/: /'

Repository: jaylfc/taOS

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- renew_registry_token_route ---"
sed -n '748,774p' tinyagentos/routes/agent_registry.py | sed 's/^/: /'

echo "--- agent_token_auth relevant lines ---"
sed -n '1,140p' tinyagentos/agent_token_auth.py | sed 's/^/: /'

echo "--- deterministic behavior probe ---"
python3 - <<'PY'
import json
from base64 import urlsafe_b64encode, urlsafe_b64decode
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, PublicFormat, NoEncryption

def b64url(data): return urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def b64url_decode(s): return urlsafe_b64decode(s + b"=" * (4 - len(s) % 4))

private = Ed25519PrivateKey.generate()
public = private.public_key()
priv_pem = private.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
pub_pem = public.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)

def sign(payload):
    header = b64url(json.dumps({"alg":"EdDSA","typ":"JWT"}, separators=(",", ":")).encode())
    payload_b64 = b64url(json.dumps(payload).encode())
    sig = b64url(private.sign(f"{header}.{payload_b64}".encode()))
    return f"{header}.{payload_b64}.{sig}"

for days in [25, 36500]:
    token = sign({"sub":"agent-a1b2","iss":"taos-registry","iat":0,"exp":days*86400,"jti":"old","user_id":"u"})
    # Minimal implementation matching tinyagentos: signature checked, exp ignored, new exp iat+lifetime
    payload = json.loads(b64url_decode(token.split(".")[1]))
    new_token = sign({"sub":payload["sub"],"iss":"taos-registry","iat":1,\
        "exp":1+86400,"jti":"new","user_id":payload.get("user_id",""),**{k:payload.get(k) for k in ["framework","project_id"]}})
    print({
        "days": days,
        "old_exp": payload["exp"],
        "old_expired_at_renewal_time": False,
        "new_exp": json.loads(new_token.split(".")[1])["exp"],
        "renewal_same_id": payload["sub"] == json.loads(new_token.split(".")[1])["sub"]
    })

token = sign({"sub":"agent-a1b2","iss":"taos-registry","iat":100,"exp":200,"jti":"old"})
payload = json.loads(b64url_decode(token.split(".")[1]))
try:
    public.verify(b64url_decode(token.split(".")[2]), f"{token.split('.')[0]}.{token.split('.')[1]}".encode())
    print("signature_verifies_any_expired_payload", payload["sub"])
except Exception as exc:
    print("signature_verification_failed", type(exc).__name__, str(exc))
PY

Repository: jaylfc/taOS

Length of output: 7356


Bound self-service token renewal.

renew_registry_token only verifies the EdDSA signature and accepts any matching payload, even an expired one. Because renew_registry_token_route returns the fresh token and normal verification later requires the agent to be active, a captured active bearer token can rotate indefinitely while that agent record stays active. This conflicts with the DEFAULT_REGISTRY_TOKEN_LIFETIME intent that leaked bearer credentials should not be permanent. Add an enforcement rule, such as rejecting renewal past a maximum grace period after exp or invalidating the superseded jti/issuer key scope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/agent_registry_store.py` around lines 389 - 411, Bound
renew_registry_token so a signed token cannot be renewed indefinitely after
expiration: enforce a maximum grace period relative to its exp (using the
existing registry lifetime configuration where appropriate) and reject tokens
beyond that window before minting the replacement. Keep valid unexpired tokens
and recently expired tokens eligible, while preserving the existing claim reuse
and minting behavior.



# ---------------------------------------------------------------------------
Expand Down
11 changes: 9 additions & 2 deletions tinyagentos/agent_token_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ def _get_store(request: Request):
return store


def _get_migration_cutoff(request: Request) -> float | None:
config = getattr(request.app.state, "config", None)
if config is None:
return None
return getattr(config, "registry_token_migration_cutoff_ts", None)


def _get_grants_store(request: Request):
store = getattr(request.app.state, "agent_grants", None)
if store is None:
Expand Down Expand Up @@ -96,7 +103,7 @@ async def _verify_agent_scope(
# Verify the EdDSA signature using the registry public key.
_private_pem, public_pem = _get_keypair(request)
try:
payload = verify_registry_token(raw_token, public_pem)
payload = verify_registry_token(raw_token, public_pem, allow_no_exp_until=_get_migration_cutoff(request))
except ValueError:
Comment on lines 104 to 107

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

4. Legacy tokens break immediately 🐞 Bug ☼ Reliability

check_agent_scope()/check_agent_identity() now enforce exp via verify_registry_token(), but
the default registry_token_migration_cutoff_ts=None means any legacy registry token without exp
is rejected (401) unless an operator explicitly configures a future cutoff. This is a
backward-compatibility/availability regression on upgrade for deployments with pre-exp tokens.
Agent Prompt
### Issue description
Legacy registry tokens that lack an `exp` claim will be rejected immediately after upgrade because `registry_token_migration_cutoff_ts` defaults to `None`, causing `verify_registry_token()` to raise `ValueError("token has no exp claim")`.

### Issue Context
The PR adds a migration-window mechanism (`allow_no_exp_until`) but leaves the default configuration with no window. The existing module documentation indicates tokens previously carried no `exp` claim, so this is likely to break existing agents.

### Fix Focus Areas
- tinyagentos/config.py[52-214]
- tinyagentos/agent_token_auth.py[39-109]
- tinyagentos/agent_registry_store.py[365-386]

### Suggested fix
- In `load_config()`, if `registry_token_migration_cutoff_ts` is absent, set it once to a reasonable future timestamp (e.g., `time.time() + 30*86400`) and call `save_config(cfg, path)` similar to the existing litellm_port pin migration.
- Add/adjust tests to ensure legacy no-`exp` tokens authenticate during the grace window by default on existing installs (or clearly document/enforce the requirement to set the cutoff explicitly).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

raise HTTPException(status_code=401, detail="invalid or malformed registry token")

Expand Down Expand Up @@ -178,7 +185,7 @@ async def check_agent_identity(request: Request) -> Optional[str]:

_private_pem, public_pem = _get_keypair(request)
try:
payload = verify_registry_token(raw_token, public_pem)
payload = verify_registry_token(raw_token, public_pem, allow_no_exp_until=_get_migration_cutoff(request))
except ValueError:
raise HTTPException(status_code=401, detail="invalid or malformed registry token")

Expand Down
4 changes: 4 additions & 0 deletions tinyagentos/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ class AppConfig:
memory_url: str = "http://localhost:7900"
wallhaven_api_key: str | None = None
github_app_id: str = ""
registry_token_lifetime_seconds: int = 86400
registry_token_migration_cutoff_ts: float | None = None
Comment on lines +65 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

6. Token lifetime config unused 🐞 Bug ≡ Correctness

registry_token_lifetime_seconds is added and parsed into AppConfig, but minting/renewal call
sites never pass it, so configuring token lifetime has no effect. Tokens always use
DEFAULT_REGISTRY_TOKEN_LIFETIME unless callers explicitly override lifetime_seconds.
Agent Prompt
### Issue description
The new `registry_token_lifetime_seconds` config knob is dead: minted and renewed tokens ignore it.

### Issue Context
`mint_registry_token()` supports `lifetime_seconds`, but production call sites do not pass a value.

### Fix Focus Areas
- tinyagentos/routes/agent_registry.py[242-285]
- tinyagentos/routes/agent_registry.py[287-349]
- tinyagentos/routes/agent_registry.py[747-773]
- tinyagentos/routes/agent_auth_requests.py[449-455]
- tinyagentos/routes/agent_auth_requests.py[537-543]
- tinyagentos/agent_registry_store.py[310-363]

### Suggested fix
- Read `lifetime = request.app.state.config.registry_token_lifetime_seconds` (with sane validation, e.g. `>= 60`) and pass `lifetime_seconds=lifetime` to:
  - `/api/agents/registry/register` mint
  - `_mint_internal_identity` mint
  - consent/approval mint paths in `agent_auth_requests.py`
  - `/api/agents/registry/token/renew` renewal (pass through to `renew_registry_token(..., lifetime_seconds=lifetime)`)

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +65 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -B2 -A15 'def save_config' tinyagentos/config.py
rg -n 'to_dict\(' tinyagentos/config.py

Repository: jaylfc/taOS

Length of output: 1247


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== config.py outline relevant =="
ast-grep outline tinyagentos/config.py --match AppConfig --view expanded || true

echo "== AppConfig class region =="
sed -n '1,130p' tinyagentos/config.py

echo "== save/load/save_locked regions =="
sed -n '320,355p' tinyagentos/config.py

Repository: jaylfc/taOS

Length of output: 8506


Persist the new registry-token fields when saving config.

save_config() writes config.to_dict() directly, and AppConfig.to_dict() only emits memory_url for scalar optional settings, so custom values for registry_token_lifetime_seconds and registry_token_migration_cutoff_ts can be dropped on any save_config()/save_config_locked() call and will reload as defaults.

Add explicit entries for these fields in to_dict() with the same conditional/default-comparison style used for memory_url.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/config.py` around lines 65 - 66, Update AppConfig.to_dict() to
explicitly serialize registry_token_lifetime_seconds and
registry_token_migration_cutoff_ts, using the same
conditional/default-comparison pattern as memory_url so custom values persist
through save_config() and save_config_locked() while defaults remain omitted.

config_path: Path | None = None

def to_dict(self) -> dict:
Expand Down Expand Up @@ -194,6 +196,8 @@ def load_config(path: Path) -> AppConfig:
github_app_id=str(data.get("github_app_id", "") or ""),
config_path=path,
wallhaven_api_key=wallhaven_api_key,
registry_token_lifetime_seconds=int(data.get("registry_token_lifetime_seconds", 86400)),
registry_token_migration_cutoff_ts=float(data["registry_token_migration_cutoff_ts"]) if "registry_token_migration_cutoff_ts" in data else None,
Comment on lines +199 to +200

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

7. Config fields not saved 🐞 Bug ⚙ Maintainability

load_config() reads registry_token_lifetime_seconds and registry_token_migration_cutoff_ts,
but AppConfig.to_dict() omits them, so any save_config() will silently drop these settings from
config.yaml. This can unexpectedly revert a configured migration cutoff (and lifetime) after
unrelated config-saving migrations.
Agent Prompt
### Issue description
New registry token config settings are not round-trippable: they are loaded but not serialized.

### Issue Context
`save_config()` writes `yaml.dump(config.to_dict())`, so any omitted fields are lost when config is persisted.

### Fix Focus Areas
- tinyagentos/config.py[69-88]
- tinyagentos/config.py[186-214]
- tinyagentos/config.py[345-349]

### Suggested fix
- Update `AppConfig.to_dict()` to include:
  - `registry_token_lifetime_seconds` (at least when != default)
  - `registry_token_migration_cutoff_ts` (when not None)
- Add a unit test that loads a config containing these keys, saves it, and asserts the keys remain present with the same values.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +199 to +200

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unguarded numeric coercion can crash config loading.

int(data.get("registry_token_lifetime_seconds", 86400)) and float(data["registry_token_migration_cutoff_ts"]) raise an uncaught ValueError/TypeError if the YAML value is malformed (e.g., a non-numeric string), crashing load_config instead of falling back to the default. Wrap these in a try/except that logs a warning and falls back to the default, matching the defensive style already used for other config fields.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/config.py` around lines 199 - 200, Update the config loading
logic for registry_token_lifetime_seconds and registry_token_migration_cutoff_ts
to catch invalid numeric values, log a warning, and use their respective
defaults instead of allowing ValueError or TypeError to escape. Follow the
existing defensive parsing pattern in load_config and preserve None when the
migration cutoff key is absent.

)
if "github_app_private_key" in data:
global _deprecation_warned_github_key
Expand Down
Loading
Loading