From 70c1152986603f036778e5b5d50360403c3af281 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Thu, 13 Aug 2026 15:24:16 +0000 Subject: [PATCH 1/6] feat(agent): mint the OS-native agent its own identity at first boot The agent built into the OS was the only agent in taOS without an identity. It authenticated as the OWNER -- the caller's browser session, or data/.auth_local_token, which is admin-equivalent -- so its actions were indistinguishable from the human's in every audit trail, it could not appear on the A2A bus as itself, and nothing it did could be revoked without revoking the human. Every install now mints its own, with no admin step and no prompt: an install that has an owner has an agent identity. Four properties, each a requirement rather than a nicety: - PER-INSTALL, anchored to /.install_id -- the same id the version ping uses, deliberately not a second one. install_id() is promoted from private to public for that reason: two readers of one id, not two ids. - OWNER-LINKED. user_id is immutable on a registry row, so the mint has to happen when the owner is already known. Hence two call sites: owner creation (fresh install) and startup (an install that upgraded into this code), both idempotent by install id. - NOT SHARED. The token lands in /.taos_agent_token, 0600, written with O_EXCL and never rewritten -- the agent may already be running with it. - CONSERVATIVE. a2a_send + a2a_receive, nothing else. Anything further goes through the existing user-mediated scope-request flow. A first-boot mint that quietly granted file or task access would be a silent privilege grant. Registry gains an install_id column (migration v6). Blank on every pre-existing row, and blank means UNKNOWN rather than 'this install' -- list_for_install refuses a blank id, because this is the query a group revocation would be built on and over-matching there costs an agent its credentials. The handle carries the install discriminator too. A bare '@taOS-agent' reads better and cannot work: the partial unique index on (handle) WHERE status='active' rejects the second insert the moment two installs' identities share a registry, which is exactly what the account/cluster model is for. The clone test caught it as an IntegrityError; it was not reasoned out in advance. Scope boundary, stated so it is not mistaken for an oversight: this does NOT let the agent drive the desktop with its token. /api/desktop/* resolves the acting user from the session and the middleware sets user_id=None for registry JWTs, so a registry token arrives there as nobody. The desktop path is unchanged. This slice is identity + bus. Never fatal at either call site: an install without an agent identity is degraded, not broken, and failing setup or boot over it would turn a missing convenience into an outage. 15 tests. Both /auth/setup paths (JSON and form) are pinned separately and each was proven to go red with only its own call site removed -- they are two routes into one event, and wiring only the one you tested leaves a whole class of install with no identity while the suite stays green. 336 green across the identity, registry, grants, auth and version-ping suites. --- .../native-agent-first-boot-identity.md | 1 + tests/test_native_agent_identity.py | 325 ++++++++++++++++++ tinyagentos/agent_registry_store.py | 64 +++- tinyagentos/app.py | 25 ++ tinyagentos/auto_update.py | 9 +- tinyagentos/native_agent_identity.py | 210 +++++++++++ tinyagentos/routes/auth.py | 31 ++ 7 files changed, 659 insertions(+), 6 deletions(-) create mode 100644 changelog.d/native-agent-first-boot-identity.md create mode 100644 tests/test_native_agent_identity.py create mode 100644 tinyagentos/native_agent_identity.py diff --git a/changelog.d/native-agent-first-boot-identity.md b/changelog.d/native-agent-first-boot-identity.md new file mode 100644 index 000000000..a36b0eedd --- /dev/null +++ b/changelog.d/native-agent-first-boot-identity.md @@ -0,0 +1 @@ +- **The OS-native agent has its own identity.** Every install now mints an agent identity at first boot — no admin step, no prompt, no shared credential. Previously the built-in agent authenticated as the owner (the browser session or the admin-equivalent `.auth_local_token`), so its actions were indistinguishable from the human's in every audit trail, it could not appear on the A2A bus as itself, and nothing it did could be revoked without revoking the human. The identity is per-install (anchored to `.install_id`), owner-linked, and conservative: `a2a_send` + `a2a_receive` only, with anything further going through the existing user-mediated scope-request flow. Its token is written to `/.taos_agent_token` (0600) and never leaves the install that minted it. Registry rows gain an `install_id` column so an owner's identities can be listed and revoked per machine. diff --git a/tests/test_native_agent_identity.py b/tests/test_native_agent_identity.py new file mode 100644 index 000000000..006561078 --- /dev/null +++ b/tests/test_native_agent_identity.py @@ -0,0 +1,325 @@ +"""First-boot identity for the OS-native taOS agent. + +The agent built into the OS authenticated as the OWNER (browser session or the +admin-equivalent ``.auth_local_token``), so its actions were indistinguishable +from the human's and could not be revoked without revoking the human. These +tests pin the identity it gets instead, and the properties that make it worth +having: per-install, owner-linked, not shared, conservative. +""" +import stat + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from tinyagentos.agent_grants_store import AgentGrantsStore +from tinyagentos.agent_registry_store import ( + AgentRegistryStore, + load_or_create_signing_keypair, + verify_registry_token, +) +from tinyagentos.native_agent_identity import ( + NATIVE_AGENT_HANDLE_PREFIX, + NATIVE_AGENT_ORIGIN, + NATIVE_AGENT_SCOPES, + ensure_native_agent_identity, + native_agent_handle, + token_path, +) + + +async def _stores(tmp_path): + """Registry + grants + signing key over one tmp data_dir. + + A plain async helper rather than an async fixture, matching the convention + in the sibling store tests. + """ + registry = AgentRegistryStore(tmp_path / "agent_registry.db") + await registry.init() + grants = AgentGrantsStore(tmp_path / "agent_grants.db") + await grants.init() + keypair = load_or_create_signing_keypair(tmp_path) + return registry, grants, tmp_path, keypair + + +async def _ensure(stores, user_id="user-1"): + registry, grants, data_dir, keypair = stores + return await ensure_native_agent_identity( + registry=registry, + grants=grants, + data_dir=data_dir, + signing_key_pem=keypair[0], + user_id=user_id, + ) + + +@pytest.mark.asyncio +class TestNativeAgentIdentity: + async def test_mints_an_owned_active_identity_with_a_bus_handle(self, tmp_path): + stores = await _stores(tmp_path) + registry, grants, data_dir, _ = stores + rec = await _ensure(stores) + + assert rec is not None + assert rec["status"] == "active" # no consent round-trip to run + assert rec["origin"] == NATIVE_AGENT_ORIGIN + install = (data_dir / ".install_id").read_text().strip() + assert rec["handle"] == native_agent_handle(install) + assert rec["handle"].startswith(NATIVE_AGENT_HANDLE_PREFIX) + assert rec["user_id"] == "user-1" # owner link + # canonical_id sits under the reserved `taos-` prefix, which only an + # in-process caller can claim. + assert rec["canonical_id"].startswith("taos-agent-") + + scopes = {g["scope"] for g in await grants.list_grants(rec["canonical_id"])} + assert scopes == set(NATIVE_AGENT_SCOPES) + + async def test_scopes_are_conservative(self, tmp_path): + """A first-boot mint that quietly granted file or task access would be a + silent privilege grant. Bus participation only; anything more goes + through the user-mediated scope-request flow.""" + stores = await _stores(tmp_path) + _, grants, _, _ = stores + rec = await _ensure(stores) + scopes = {g["scope"] for g in await grants.list_grants(rec["canonical_id"])} + assert scopes == {"a2a_send", "a2a_receive"} + for forbidden in ("files_write", "files_read", "tools_execute", + "memory_write", "project_tasks", "observatory_control"): + assert forbidden not in scopes + + async def test_is_idempotent_across_restarts(self, tmp_path): + """Runs on every start, so a second call must not fork a second identity + or a second token.""" + stores = await _stores(tmp_path) + registry, _, data_dir, _ = stores + first = await _ensure(stores) + token_first = token_path(data_dir).read_text() + + second = await _ensure(stores) + + assert second["canonical_id"] == first["canonical_id"] + assert len(await registry.list_all()) == 1 + assert token_path(data_dir).read_text() == token_first + + async def test_token_is_written_0600_and_verifies(self, tmp_path): + stores = await _stores(tmp_path) + _, _, data_dir, keypair = stores + rec = await _ensure(stores) + + path = token_path(data_dir) + assert path.exists() + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + claims = verify_registry_token(path.read_text(), keypair[1]) + assert claims["sub"] == rec["canonical_id"] + assert claims["user_id"] == "user-1" + + async def test_an_existing_token_file_is_never_rewritten(self, tmp_path): + """The agent may already be running with that token. Rewriting it under + a live process leaves it holding a credential nobody recognises.""" + stores = await _stores(tmp_path) + _, _, data_dir, _ = stores + token_path(data_dir).write_text("a-token-already-in-use") + + await _ensure(stores) + + assert token_path(data_dir).read_text() == "a-token-already-in-use" + + async def test_deferred_when_the_install_has_no_owner_yet(self, tmp_path): + """user_id is immutable on the registry row, so minting before an owner + exists would strand the identity ownerless for life. Skip and let the + setup route call again.""" + stores = await _stores(tmp_path) + registry, _, data_dir, _ = stores + rec = await _ensure(stores, user_id="") + + assert rec is None + assert await registry.list_all() == [] + assert not token_path(data_dir).exists() + + async def test_refuses_to_mint_without_an_install_anchor(self, tmp_path, monkeypatch): + """install_id() swallows its own errors and returns "" -- fine for a + telemetry ping, not fine here. A blank anchor is indistinguishable from + the pre-v6 rows that legitimately have none, so the identity could never + be listed or revoked as part of this install.""" + stores = await _stores(tmp_path) + registry, _, data_dir, _ = stores + monkeypatch.setattr( + "tinyagentos.native_agent_identity.read_install_id", lambda _d: "" + ) + + rec = await _ensure(stores) + + assert rec is None + assert await registry.list_all() == [] + assert not token_path(data_dir).exists() + + async def test_identity_is_anchored_to_this_install(self, tmp_path): + """Per-install is the property that makes "revoke that machine" + answerable.""" + stores = await _stores(tmp_path) + registry, _, data_dir, _ = stores + rec = await _ensure(stores) + + install = (data_dir / ".install_id").read_text().strip() + assert install + assert rec["install_id"] == install + assert rec["canonical_id"].startswith(f"taos-agent-{install[:8]}-") + + found = await registry.list_for_install(install) + assert [r["canonical_id"] for r in found] == [rec["canonical_id"]] + + async def test_a_new_install_mints_its_own_identity(self, tmp_path): + """An image cloned to a new machine gets a new install id and mints its + own identity rather than carrying the original's credential.""" + stores = await _stores(tmp_path) + registry, grants, data_dir, keypair = stores + first = await _ensure(stores) + + (data_dir / ".install_id").write_text("f" * 32) + token_path(data_dir).unlink() + + second = await _ensure(stores) + + assert second["canonical_id"] != first["canonical_id"] + assert second["install_id"] == "f" * 32 + assert len(await registry.list_all()) == 2 + # And the HANDLES differ. A bare "@taOS-agent" cannot survive here: the + # partial unique index on (handle) WHERE status='active' rejects the + # second insert outright, which is what this test caught. + assert second["handle"] != first["handle"] + assert second["handle"] == native_agent_handle("f" * 32) + + async def test_grants_are_reasserted_but_user_additions_are_kept(self, tmp_path): + """Re-asserting the baseline must only ever ADD: a scope the user + granted on top must survive a restart.""" + stores = await _stores(tmp_path) + _, grants, _, _ = stores + rec = await _ensure(stores) + await grants.add_grant(rec["canonical_id"], "files_read") + + await _ensure(stores) + + scopes = {g["scope"] for g in await grants.list_grants(rec["canonical_id"])} + assert scopes == {"a2a_send", "a2a_receive", "files_read"} + + +@pytest.mark.asyncio +class TestInstallIdColumn: + async def test_legacy_rows_report_unknown_not_this_install(self, tmp_path): + """Blank install_id means "unknown", never "this install" -- so a group + revocation cannot scoop up identities minted before installs were + tracked.""" + stores = await _stores(tmp_path) + registry, _, data_dir, _ = stores + legacy = await registry.register(framework="claude-code", display_name="old") + assert legacy["install_id"] == "" + + await _ensure(stores) + install = (data_dir / ".install_id").read_text().strip() + + found = await registry.list_for_install(install) + assert legacy["canonical_id"] not in [r["canonical_id"] for r in found] + + async def test_list_for_install_refuses_a_blank_id(self, tmp_path): + """Otherwise the blank-anchored legacy rows would all match at once.""" + stores = await _stores(tmp_path) + registry, _, _, _ = stores + await registry.register(framework="claude-code", display_name="old") + assert await registry.list_for_install("") == [] + + +# --------------------------------------------------------------------------- +# The setup routes: BOTH of them +# --------------------------------------------------------------------------- + +@pytest_asyncio.fixture +async def setup_client(app): + """An app with the registry stores live and NO user yet.""" + for attr in ("agent_registry", "agent_grants", "metrics"): + store = getattr(app.state, attr) + if store._db is None: + await store.init() + app.state._startup_complete = True + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + c._app = app + yield c + + for attr in ("agent_registry", "agent_grants", "metrics"): + store = getattr(app.state, attr) + if store._db is not None: + await store.close() + + +async def _native_row(app): + rows = await app.state.agent_registry.list_all() + return next((r for r in rows if r["origin"] == NATIVE_AGENT_ORIGIN), None) + + +@pytest.mark.asyncio +class TestSetupMintsTheIdentity: + """`/auth/setup` has TWO paths -- JSON and form-encoded -- and they are two + routes into the same event: an install acquiring its first user. Wiring only + the one you happened to test leaves a whole class of install (the no-JS HTML + setup page, or the API-driven one) with no agent identity, while the suite + stays green. Both are pinned here for that reason. + """ + + async def test_json_setup_path_mints_the_identity(self, setup_client): + app = setup_client._app + assert await _native_row(app) is None + + resp = await setup_client.post( + "/auth/setup", + json={"username": "admin", "full_name": "Admin", "email": "", + "password": "newpassword"}, + ) + assert resp.status_code == 200, resp.text + + rec = await _native_row(app) + assert rec is not None, "JSON setup path did not mint the native identity" + owner = app.state.auth.find_user("admin") + assert rec["user_id"] == owner["id"] + scopes = {g["scope"] for g in await app.state.agent_grants.list_grants(rec["canonical_id"])} + assert scopes == set(NATIVE_AGENT_SCOPES) + + async def test_form_setup_path_mints_the_identity(self, setup_client): + app = setup_client._app + assert await _native_row(app) is None + + resp = await setup_client.post( + "/auth/setup", + data={"username": "admin", "full_name": "Admin", "email": "", + "password": "newpassword"}, + follow_redirects=False, + ) + assert resp.status_code == 303, resp.text + + rec = await _native_row(app) + assert rec is not None, "form setup path did not mint the native identity" + owner = app.state.auth.find_user("admin") + assert rec["user_id"] == owner["id"] + + async def test_setup_still_succeeds_when_the_mint_fails(self, setup_client, monkeypatch): + """An install whose agent identity failed to mint is degraded, not + broken. Failing setup over it would strand the user on the setup page + with an account that already exists.""" + app = setup_client._app + + async def _boom(**_kwargs): + raise RuntimeError("registry is having a bad day") + + monkeypatch.setattr( + "tinyagentos.native_agent_identity.ensure_native_agent_identity", _boom + ) + + resp = await setup_client.post( + "/auth/setup", + json={"username": "admin", "full_name": "Admin", "email": "", + "password": "newpassword"}, + ) + assert resp.status_code == 200, resp.text + assert app.state.auth.is_configured() is True + assert await _native_row(app) is None diff --git a/tinyagentos/agent_registry_store.py b/tinyagentos/agent_registry_store.py index 4957178e6..8e3bc222b 100644 --- a/tinyagentos/agent_registry_store.py +++ b/tinyagentos/agent_registry_store.py @@ -225,6 +225,32 @@ async def _migration_v5_add_token_min_iat(conn) -> None: "ALTER TABLE agent_registry ADD COLUMN token_min_iat INTEGER NOT NULL DEFAULT 0" ) await conn.commit() +async def _migration_v6_add_install_id(conn) -> None: + """Add install_id column (idempotent), anchoring an identity to ONE install. + + Empty for every pre-existing row, which is correct rather than merely + convenient: those identities were minted before installs were distinguished, + so claiming to know which install they belong to would be an invention. A + blank install_id therefore means "unknown", never "this install". + + This is what makes per-install identities listable and revocable AS A GROUP + (``list_for_install``): the account/cluster model has one owner holding + identities across several installs, so "revoke that machine" has to be + answerable without string-matching canonical_ids. + """ + existing_cols = { + row[1] + for row in await ( + await conn.execute("PRAGMA table_info(agent_registry)") + ).fetchall() + } + if "install_id" not in existing_cols: + await conn.execute( + "ALTER TABLE agent_registry ADD COLUMN install_id TEXT NOT NULL DEFAULT ''" + ) + await conn.commit() + + # --------------------------------------------------------------------------- # Signing-key helpers (Ed25519, persisted to disk) # --------------------------------------------------------------------------- @@ -469,6 +495,7 @@ async def _post_init(self) -> None: # handles cannot make the CREATE UNIQUE INDEX (hence boot) fail. await _migration_v4_dedupe_active_handles(self._db) await _migration_v5_add_token_min_iat(self._db) + await _migration_v6_add_install_id(self._db) # Created after the status migration so the partial index's WHERE clause # can reference the status column on the pre-status migration path. # Guard the index creation too: if some path we did not anticipate still @@ -500,6 +527,7 @@ async def register( reports_to: Optional[str] = None, capabilities: Optional[list[str]] = None, allow_reserved: bool = False, + install_id: str = "", ) -> dict: """Mint a canonical_id, persist the record, and return it. @@ -550,11 +578,13 @@ async def register( """ INSERT INTO agent_registry (canonical_id, display_name, framework, user_id, origin, - handle, role, title, reports_to, capabilities, created_ts, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + handle, role, title, reports_to, capabilities, created_ts, status, + install_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, (canonical_id, display_name, framework, user_id, origin, - handle, role, title, reports_to, caps_json, created_ts, initial_status), + handle, role, title, reports_to, caps_json, created_ts, initial_status, + install_id), ) await self._db.commit() @@ -701,6 +731,34 @@ async def list_for_user(self, user_id: str, *, status: Optional[str] = None) -> rows = await cursor.fetchall() return [_row_to_dict(r) for r in rows] + async def list_for_install( + self, install_id: str, *, status: Optional[str] = None + ) -> list[dict]: + """Return every record minted by ONE install, optionally by status. + + An empty *install_id* returns nothing rather than every legacy row. + Blank means "unknown install" (see the v6 migration), so matching on it + would quietly scoop up identities from before installs were tracked -- + and this is the query a group revocation would be built on, where + over-matching costs an agent its credentials. + """ + if self._db is None: + raise RuntimeError("AgentRegistryStore not initialised") + if not install_id: + return [] + if status is not None: + cursor = await self._db.execute( + "SELECT * FROM agent_registry WHERE install_id = ? AND status = ? ORDER BY id", + (install_id, status), + ) + else: + cursor = await self._db.execute( + "SELECT * FROM agent_registry WHERE install_id = ? ORDER BY id", + (install_id,), + ) + rows = await cursor.fetchall() + return [_row_to_dict(r) for r in rows] + async def list_revoked(self) -> list[dict]: """Return [{canonical_id, revoked_at}] for all revoked entries (back-compat feed).""" if self._db is None: diff --git a/tinyagentos/app.py b/tinyagentos/app.py index ec18eda4b..f2ec84b3a 100644 --- a/tinyagentos/app.py +++ b/tinyagentos/app.py @@ -525,6 +525,31 @@ async def lifespan(app: FastAPI): await agent_scope_requests_store.init() await agent_grants_store.init() app.state.agent_grants = agent_grants_store + + # First-boot identity for the OS-native agent. Runs on EVERY start, not + # only on a fresh install: it is how an install that upgraded into this + # code gets an identity without the owner doing anything, and it is + # idempotent by install id. An ownerless install (setup not completed + # yet) is skipped and picked up by the setup route the moment an owner + # exists. + # + # Never fatal. An install with no agent identity is degraded -- the + # agent keeps working through the owner's credential exactly as it did + # before this existed -- and refusing to boot over it would turn that + # into an outage. + try: + from tinyagentos.native_agent_identity import ensure_native_agent_identity + + _owner = auth_manager.get_primary_user() + await ensure_native_agent_identity( + registry=agent_registry_store, + grants=agent_grants_store, + data_dir=data_dir, + signing_key_pem=agent_registry_keypair[0], + user_id=(_owner or {}).get("id", ""), + ) + except Exception: + logger.exception("native agent identity could not be ensured at startup") await user_shares_store.init() app.state.user_shares = user_shares_store await app_grants_store.init() diff --git a/tinyagentos/auto_update.py b/tinyagentos/auto_update.py index c7789231b..7413250ce 100644 --- a/tinyagentos/auto_update.py +++ b/tinyagentos/auto_update.py @@ -77,11 +77,14 @@ def _ping_enabled_by_env() -> bool: return os.environ.get("TAOS_NO_UPDATE_PING", "").strip() not in ("1", "true", "yes") -def _install_id(data_dir: Optional[Path]) -> str: +def install_id(data_dir: Optional[Path]) -> str: """Return this install's stable random id, creating it once if needed. A random UUID with no PII and no hardware fingerprint, stored at - ``/.install_id``. The data dir is preserved across upgrades and + ``/.install_id``. Public because it is no longer telemetry-only: + it is also the anchor for this install's native agent identity + (``native_agent_identity.py``), and both MUST read the same id or an install + would report one identity to the version ping and mint another. The data dir is preserved across upgrades and in-place reinstalls, so the id (and the install's place in the historical count) is stable. A full wipe yields a new id, which is correct: that is a genuinely new install. @@ -114,7 +117,7 @@ async def send_version_ping(http_client, data_dir: Optional[Path] = None) -> Non version = getattr(tinyagentos, "__version__", "unknown") plat = f"{sys.platform}-{platform.machine()}" params = {"v": version, "platform": plat} - iid = _install_id(data_dir) + iid = install_id(data_dir) if iid: params["id"] = iid try: diff --git a/tinyagentos/native_agent_identity.py b/tinyagentos/native_agent_identity.py new file mode 100644 index 000000000..f88264b5a --- /dev/null +++ b/tinyagentos/native_agent_identity.py @@ -0,0 +1,210 @@ +# tinyagentos/native_agent_identity.py +"""First-boot identity for the OS-native taOS agent. + +WHY THIS EXISTS. Every other agent in taOS has an identity: a canonical_id, a +registry row, scopes it was granted, and a token that is its own. The agent +built into the OS -- the one that operates the desktop on the user's behalf -- +had none of that. It authenticated as the OWNER, using either the caller's +browser session or ``data/.auth_local_token``, an admin-equivalent shared +credential. So the agent's actions were indistinguishable from the human's in +every audit trail, it could not appear on the A2A bus as itself, and nothing it +did could be revoked without revoking the human. + +This module mints that identity at first boot, with no admin step and no +prompt: an install that has an owner has an agent identity. + +FOUR PROPERTIES, each of which is a requirement rather than a nicety: + +1. PER-INSTALL. The identity is anchored to ``/.install_id`` -- the + same id the version ping uses, deliberately not a second one. Two installs + owned by the same account are two identities, so "this machine's agent" is a + thing that can be named, listed and revoked. + +2. OWNER-LINKED. ``user_id`` is the install's primary user. The registry + treats user_id as immutable, so the mint has to happen at a moment when the + owner is already known -- which is why this runs at owner creation and at + startup, never at an ownerless boot. An identity minted with no owner would + be stuck that way for its whole life. + +3. NOT SHARED. The token is written to ``/.taos_agent_token`` (0600) + on the install that minted it. Nothing ships a credential between installs; + an image cloned to a new machine gets a new install id and mints its own. + +4. CONSERVATIVE. Two scopes, ``a2a_send`` + ``a2a_receive``: enough to be a + participant on the bus as itself, and nothing else. Anything more goes + through the normal scope-request flow, which already exists and is + user-mediated. A first-boot mint that quietly granted file or task access + would be a silent privilege grant, which is the opposite of the point. + +WHAT THIS DOES NOT DO. It does not let the agent drive the desktop with its +token. ``/api/desktop/*`` resolves the acting user from the session and the +middleware sets ``user_id = None`` for registry JWTs, so a registry token +reaches those routes as nobody. The desktop path still uses the session/local +token exactly as before. This slice is identity + bus; the desktop half is a +separate change and is called out here so the boundary is not mistaken for an +oversight. +""" +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Any, Optional + +from tinyagentos.agent_registry_store import mint_registry_token +from tinyagentos.auto_update import install_id as read_install_id + +logger = logging.getLogger(__name__) + +# The origin marks the row as minted by the OS itself rather than deployed by a +# user or self-joined by an external agent. register() lands any origin other +# than "external-selfjoin" as active, which is what we want: there is no consent +# round-trip to run against the owner who just created the install. +NATIVE_AGENT_ORIGIN = "taos-native" + +# Bus participation only. See property 4 above before adding to this. +NATIVE_AGENT_SCOPES = ("a2a_send", "a2a_receive") + +# How much of the install id goes into the canonical_id and the handle. The +# full id is on the row in install_id; this is for humans reading either one in +# an audit log. +_SLUG_INSTALL_CHARS = 8 + +# The handle carries the install discriminator for the same reason the +# canonical_id does. A bare "@taOS-agent" looks nicer and is wrong: the registry +# holds a UNIQUE index on (handle) WHERE status='active', so the moment two +# installs' identities live in one registry -- which is the whole point of the +# account/cluster model -- the second one cannot be inserted at all. Found by +# the clone test below, not by reasoning: it failed with +# "UNIQUE constraint failed: agent_registry.handle". +NATIVE_AGENT_HANDLE_PREFIX = "@taOS-agent" + + +def native_agent_handle(install: str) -> str: + """Bus handle for the native agent of install *install*.""" + return f"{NATIVE_AGENT_HANDLE_PREFIX}-{install[:_SLUG_INSTALL_CHARS]}" + + +# The token file is per-install and never leaves it. +TOKEN_FILENAME = ".taos_agent_token" + +# canonical_id slug prefix. `taos-` is a RESERVED prefix: register() refuses it +# unless allow_reserved=True, which is a keyword the HTTP layer never populates +# from a request body. This module is in-process, so it can legitimately pass +# it -- an agent named by the OS should be under the OS's own prefix, and an +# external caller still cannot claim one. +_SLUG_PREFIX = "taos-agent" + + +def token_path(data_dir: Path | str) -> Path: + """Path of this install's native-agent token file.""" + return Path(data_dir) / TOKEN_FILENAME + + +def _write_token(data_dir: Path | str, token: str) -> Optional[Path]: + """Write *token* 0600, creating it only if absent. Returns the path or None. + + Created with O_EXCL rather than a plain write: two workers racing at startup + must not have one truncate the file the other is reading. An existing file + is left ALONE -- the agent may already be running with that token, and + rewriting it under a live process is how you get an agent holding a + credential nobody recognises. + """ + path = token_path(data_dir) + try: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError: + return path + except OSError as exc: + logger.error("native agent token could not be written to %s: %s", path, exc) + return None + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(token) + except OSError as exc: + logger.error("native agent token write failed at %s: %s", path, exc) + return None + return path + + +async def ensure_native_agent_identity( + *, + registry: Any, + grants: Any, + data_dir: Path | str, + signing_key_pem: bytes, + user_id: str, +) -> Optional[dict]: + """Mint (or re-assert) this install's native agent identity. Idempotent. + + Returns the registry record, or ``None`` when the identity could not be + minted -- in which case the caller carries on: an install without a native + agent identity is degraded, not broken, and refusing to boot over it would + turn a missing convenience into an outage. + + Safe to call on every startup and at owner creation. Lookup is by + install_id, not by handle: the handle is a display string that a user or a + migration could change, while the install id is what the identity actually + belongs to. + """ + install = read_install_id(Path(data_dir)) + if not install: + # install_id() swallows its own errors and returns "" -- fine for a + # telemetry ping, not fine here. An identity minted with a blank anchor + # could never be listed or revoked as part of this install, and would be + # indistinguishable from the pre-v6 rows that legitimately have none. + logger.error( + "native agent identity NOT minted: no install id could be read from %s. " + "The agent will keep using the owner's credential.", + data_dir, + ) + return None + + if not user_id: + # Not an error: an install with no owner yet simply is not ready. The + # setup route calls us again the moment the owner exists. + logger.info("native agent identity deferred: install has no owner yet") + return None + + existing = await registry.list_for_install(install, status="active") + record = next( + (r for r in existing if r.get("origin") == NATIVE_AGENT_ORIGIN), None + ) + + if record is None: + short = install[:_SLUG_INSTALL_CHARS] + record = await registry.register( + framework=NATIVE_AGENT_ORIGIN, + display_name=f"{_SLUG_PREFIX}-{short}", + user_id=user_id, + origin=NATIVE_AGENT_ORIGIN, + handle=native_agent_handle(install), + capabilities=[], + allow_reserved=True, + install_id=install, + ) + logger.info( + "native agent identity minted: %s (install %s, owner %s)", + record["canonical_id"], short, user_id, + ) + + # add_grant is idempotent, so this re-asserts the baseline on every boot. + # That is deliberate: a scope removed by hand comes back, because these two + # are what the OS agent needs to function at all. Anything a user ADDED is + # untouched -- this only ever adds. Default tier, matching the internal + # mint path; 'once' is the only tier this store writes today. + for scope in NATIVE_AGENT_SCOPES: + await grants.add_grant(record["canonical_id"], scope) + + if not token_path(data_dir).exists(): + token = mint_registry_token( + record["canonical_id"], + signing_key_pem, + user_id=record.get("user_id", ""), + framework=record.get("framework", NATIVE_AGENT_ORIGIN), + ) + written = _write_token(data_dir, token) + if written is not None: + logger.info("native agent token written to %s", written) + + return record diff --git a/tinyagentos/routes/auth.py b/tinyagentos/routes/auth.py index 60f3bd687..845c180f9 100644 --- a/tinyagentos/routes/auth.py +++ b/tinyagentos/routes/auth.py @@ -1,6 +1,7 @@ from __future__ import annotations import html +import logging import threading import time from collections import OrderedDict @@ -9,6 +10,8 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response from tinyagentos.middleware.csrf import verify_csrf +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/auth", tags=["auth"]) @@ -504,6 +507,32 @@ async def lock(request: Request): return resp +async def _ensure_native_agent_identity(request: Request, user_id: str) -> None: + """Mint this install's native agent identity, now that it has an owner. + + Called from BOTH setup paths (JSON and form). They are two routes into the + same event -- an install acquiring its first user -- and wiring only the one + you happened to test is how a fresh install ends up with no agent identity + while every test passes. The paired tests below cover both. + + Never raises: an install whose agent identity failed to mint is degraded, + not broken, and failing setup over it would strand the user on the setup + page with an account that already exists. + """ + try: + from tinyagentos.native_agent_identity import ensure_native_agent_identity + + await ensure_native_agent_identity( + registry=request.app.state.agent_registry, + grants=request.app.state.agent_grants, + data_dir=request.app.state.data_dir, + signing_key_pem=request.app.state.agent_registry_keypair[0], + user_id=user_id, + ) + except Exception: + logger.exception("native agent identity could not be minted at setup") + + @router.post("/setup") async def auth_setup(request: Request): """Onboard the first user. Only works when zero users exist. @@ -543,6 +572,7 @@ async def auth_setup(request: Request): record = auth_mgr.find_user(username) user_id = record["id"] if record else "" auth_mgr.update_last_login(user_id) + await _ensure_native_agent_identity(request, user_id) token = auth_mgr.create_session(user_id=user_id, long_lived=long_lived, user_agent=user_agent) resp = JSONResponse({"ok": True, "user": user}) if long_lived: @@ -576,6 +606,7 @@ async def auth_setup(request: Request): record = auth_mgr.find_user(username) user_id = record["id"] if record else "" auth_mgr.update_last_login(user_id) + await _ensure_native_agent_identity(request, user_id) token = auth_mgr.create_session(user_id=user_id, long_lived=long_lived, user_agent=user_agent) response = RedirectResponse("/desktop", status_code=303) if long_lived: From 1bbd69fc163f979b533250fd9ee0661e29be22b7 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Thu, 13 Aug 2026 15:25:38 +0000 Subject: [PATCH 2/6] docs(agent-manual): document the identity the agent now has Says what it is, what it is anchored to, and -- as loudly -- what it is not: the token does not authenticate desktop control, and nothing in the chat runtime reads it yet. An agent that told a user it could post to the bus as itself today would be wrong. --- docs/agent-manual/00-identity.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/agent-manual/00-identity.md b/docs/agent-manual/00-identity.md index cfb23f5e8..0422ceab0 100644 --- a/docs/agent-manual/00-identity.md +++ b/docs/agent-manual/00-identity.md @@ -13,3 +13,27 @@ Your character, in four lines: - You always speak as "I" and call the product "taOS" (never "TAOS" or "TinyAgentOS"). **Capability boundary (v1):** you answer questions only. You cannot run commands, restart agents, read live state, create apps, or change settings. If the user asks you to DO something, explain how they can do it themselves, then say: "I can't do that for you yet myself, but it's coming." + +## Your registry identity + +Every taOS install mints an identity for you at first boot. No admin step, no prompt: if the install has an owner, you have an identity. Before this you had none, and authenticated as the owner — so nothing you did could be told apart from something the human did, and nothing you did could be revoked without revoking them. + +What you get: + +| | | +|---|---| +| canonical_id | `taos-agent---