🔑 agents get keys: shared agent token minting + guest-visible job outputs

scripts/agent_token.py mints the 'agents' guest user + token non-interactively,
writes data/agent.env (0600, gitignored, never printed). Fixes _register_outputs
dropping user_id — guest job outputs were registered ownerless, so guests could
never see or download their own results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-15 23:30:04 +10:00
parent b8c333a93e
commit 19cf9ed21e
2 changed files with 75 additions and 2 deletions

68
scripts/agent_token.py Normal file
View File

@ -0,0 +1,68 @@
#!/usr/bin/env python
"""Mint (or re-mint) the shared agent credential, non-interactively.
Creates the `agents` guest user if missing (random throwaway password agents
authenticate by token, not password), mints a fresh API token for it, and writes
a ready-to-source env file to data/agent.env (chmod 600, gitignored). The token
is never printed to stdout.
Run from the repo root:
uv run python scripts/agent_token.py # mint, write data/agent.env
uv run python scripts/agent_token.py --revoke # delete ALL of agents' old tokens first
Agents/macs then use:
source ~/Documents/MODELBEAST/data/agent.env # sets MB_HOST + MB_TOKEN
./mb ops
(from another tailnet mac: scp m3ultra:~/Documents/MODELBEAST/data/agent.env .)
The user is a GUEST on purpose: local operators only (flux_local, bg_remove_local,
trellis_mac, sf3d, ...) all free; the owner's paid fal/OpenRouter keys are
blocked server-side. Mint an owner token from the UI if an agent ever truly needs
a cloud operator.
"""
import argparse
import secrets
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from server import auth, db # noqa: E402
USERNAME = "agents"
MAX_JOBS = 8 # shared across every agent/mac using this credential
ENV_PATH = ROOT / "data" / "agent.env"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--revoke", action="store_true",
help="revoke all existing tokens for the agents user first")
args = ap.parse_args()
con = db.connect()
user = auth.get_user_by_name(con, USERNAME)
if user is None:
user = auth.create_user(con, USERNAME, secrets.token_hex(16),
role="guest", max_active_jobs=MAX_JOBS)
print(f"created user '{USERNAME}' (guest, max_jobs={MAX_JOBS})")
else:
print(f"user '{USERNAME}' exists ({user['role']})")
if args.revoke:
n = con.execute("DELETE FROM api_tokens WHERE user_id = ?", (user["id"],)).rowcount
con.commit()
print(f"revoked {n} old token(s)")
raw = auth.create_token(con, user["id"], name="shared-agent-cred")
ENV_PATH.parent.mkdir(parents=True, exist_ok=True)
ENV_PATH.write_text(
"# MODELBEAST shared agent credential — source this file, never commit/paste it\n"
"export MB_HOST=http://100.89.131.57:8777\n"
f"export MB_TOKEN={raw}\n")
ENV_PATH.chmod(0o600)
print(f"token written to {ENV_PATH} (not shown; source it to use)")
if __name__ == "__main__":
main()

View File

@ -305,6 +305,10 @@ class Runner:
def _register_outputs(self, con, outdir: Path, job_id: str) -> list[str]: def _register_outputs(self, con, outdir: Path, job_id: str) -> list[str]:
result_path = outdir / "result.json" result_path = outdir / "result.json"
registered = [] registered = []
# outputs belong to whoever ran the job — guests see only their own
# assets, so registering without user_id hides a guest's results from them
row = con.execute("SELECT user_id FROM jobs WHERE id = ?", (job_id,)).fetchone()
owner_id = row["user_id"] if row else None
if result_path.exists(): if result_path.exists():
result = json.loads(result_path.read_text()) result = json.loads(result_path.read_text())
for out in result.get("outputs", []): for out in result.get("outputs", []):
@ -313,13 +317,14 @@ class Runner:
p = outdir / p p = outdir / p
if p.exists(): if p.exists():
a = store.register_file(con, p, name=out.get("name"), a = store.register_file(con, p, name=out.get("name"),
parent_job=job_id, move=True, meta=out.get("meta")) parent_job=job_id, move=True, meta=out.get("meta"),
user_id=owner_id)
registered.append(a["id"]) registered.append(a["id"])
else: else:
for p in sorted(outdir.iterdir()): for p in sorted(outdir.iterdir()):
if p.name == "result.json" or p.name.startswith("."): if p.name == "result.json" or p.name.startswith("."):
continue continue
a = store.register_file(con, p, parent_job=job_id, move=True) a = store.register_file(con, p, parent_job=job_id, move=True, user_id=owner_id)
registered.append(a["id"]) registered.append(a["id"])
return registered return registered