Skip to content

Commit 5585b56

Browse files
committed
fix(cloud cli): accept tracer slug or id, --json on key create, anon scan (0.3.1)
- _resolve_tracer queries by slug when the ref is not a UUID (the id column is uuid-typed, so a slug as an id filter 400s before any fallback). - keys/ingest-keys create honor --json and emit the created record as JSON. - scan runs without a session (public endpoint) and sends a first-party Origin so the same-origin guard passes for the anonymous flow. - bump to 0.3.1.
1 parent ad251d4 commit 5585b56

5 files changed

Lines changed: 89 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@
33
All notable changes to TRACER are recorded here. This project follows semantic
44
versioning.
55

6+
## 0.3.1 (2026-06)
7+
8+
### Changed
9+
- `tracer cloud` commands that take a tracer (`get`, `training`, `analytics`,
10+
`label-space`, `traces`, ...) now accept either the tracer slug or its id.
11+
- `tracer cloud scan` works without logging in, matching the public web scan.
12+
13+
### Fixed
14+
- `tracer cloud keys create` and `tracer cloud ingest-keys create` now honor
15+
`--json` and emit the created record (including the one-time secret) as JSON.
16+
617
## 0.3.0 (2026-06)
718

819
### Added

‎src/tracer/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def __getattr__(name: str):
7878
return obj
7979

8080

81-
__version__ = "0.3.0"
81+
__version__ = "0.3.1"
8282

8383
__all__ = [
8484
# Core API

‎src/tracer/cloud/cli.py‎

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import getpass
1313
import json as _json
1414
import os
15+
import re
1516
import sys
1617
import time
1718
from typing import Any
@@ -76,10 +77,19 @@ def _resolve_tenant(client: CloudClient, want: str | None) -> dict[str, Any]:
7677
return tenants[0]
7778

7879

80+
_UUID_RE = re.compile(
81+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I
82+
)
83+
84+
7985
def _resolve_tracer(client: CloudClient, ref: str) -> dict[str, Any]:
80-
# Try by id, then by slug.
81-
rows = client.db("tracer_summary", select="*", filters={"id": f"eq.{ref}"})
82-
if not rows:
86+
# A UUID ref is queried by id; anything else is treated as a slug. The id
87+
# column is uuid-typed, so sending a non-uuid as an id filter makes
88+
# PostgREST 400 ("invalid input syntax for type uuid") before any slug
89+
# fallback could run -- so pick the right column up front.
90+
key = "id" if _UUID_RE.match(ref or "") else "slug"
91+
rows = client.db("tracer_summary", select="*", filters={key: f"eq.{ref}"})
92+
if not rows and key == "id":
8393
rows = client.db("tracer_summary", select="*", filters={"slug": f"eq.{ref}"})
8494
if not rows:
8595
raise CloudError(f"tracer not found: {ref}")
@@ -514,6 +524,9 @@ def _cmd_keys(client: CloudClient, args) -> None:
514524
_table(res.get("keys", []), ["id", "name", "prefix", "last4", "last_used_at", "revoked_at"])
515525
elif args.action == "create":
516526
res = client.api("POST", base, json_body={"name": args.name})
527+
if args.json:
528+
_print_json(res)
529+
return
517530
print(_c("✓ key (shown once):", _GREEN), res.get("full_key"))
518531
elif args.action == "revoke":
519532
client.api("DELETE", f"{base}/{args.id}")
@@ -532,6 +545,9 @@ def _cmd_ingest_keys(client: CloudClient, args) -> None:
532545
elif args.action == "create":
533546
tenant = _resolve_tenant(client, args.tenant)
534547
res = client.api("POST", "/api/observe/keys", json_body={"tenant_id": tenant["id"], "name": args.name})
548+
if args.json:
549+
_print_json(res)
550+
return
535551
print(_c("✓ ingest key (shown once):", _GREEN), res.get("full_key"))
536552
elif args.action == "revoke":
537553
client.api("DELETE", f"/api/observe/keys/{args.id}")
@@ -574,7 +590,9 @@ def _cmd_billing(client: CloudClient, args) -> None:
574590

575591

576592
def _cmd_scan(client: CloudClient, args) -> None:
577-
res = client.api_multipart("/api/scan", {}, args.file)
593+
# /api/scan is public (matches the anonymous web /scan flow), so the scan
594+
# works logged out; the bearer is attached only if a session exists.
595+
res = client.api_multipart("/api/scan", {}, args.file, auth="optional")
578596
# The agent may need to disambiguate the file format. Re-submit with answers
579597
# (CLI flags override the server's best-guess defaults) so the scan is
580598
# non-interactive instead of silently no-opping on an ambiguous file.
@@ -587,7 +605,9 @@ def _cmd_scan(client: CloudClient, args) -> None:
587605
if args.task:
588606
answers["task"] = args.task
589607
print(_c("clarifying format", _DIM), _json.dumps(answers))
590-
res = client.api_multipart("/api/scan", {"answers": _json.dumps(answers)}, args.file)
608+
res = client.api_multipart(
609+
"/api/scan", {"answers": _json.dumps(answers)}, args.file, auth="optional"
610+
)
591611
job_id = res.get("job_id") if isinstance(res, dict) else None
592612
if not job_id:
593613
_print_json(res)
@@ -596,7 +616,7 @@ def _cmd_scan(client: CloudClient, args) -> None:
596616
print(_c("link", _DIM), f"{client.base_url}/scan/{job_id}")
597617
start = time.time()
598618
while time.time() - start < 300:
599-
st = client.api("GET", f"/api/scan/{job_id}")
619+
st = client.api("GET", f"/api/scan/{job_id}", auth="optional")
600620
if st.get("status") == "ready":
601621
print(_c("✓", _GREEN), "scan ready")
602622
if getattr(args, "json", False):
@@ -964,8 +984,9 @@ def run(args) -> None:
964984
print("usage: tracer cloud <subcommand> (try `tracer cloud --help`)")
965985
sys.exit(1)
966986
client = CloudClient.load()
967-
# login/logout don't require an existing session.
968-
if cmd not in ("login", "logout") and not client.logged_in:
987+
# login/logout don't require an existing session; `scan` is a public
988+
# endpoint (mirrors the anonymous web /scan) so it runs logged out too.
989+
if cmd not in ("login", "logout", "scan") and not client.logged_in:
969990
print(_c("not logged in.", _RED), "run `tracer cloud login` first.")
970991
sys.exit(1)
971992
handler = _DISPATCH.get(cmd)

‎src/tracer/cloud/client.py‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class CloudError(RuntimeError):
3939

4040
# A real User-Agent: the default "Python-urllib/x" trips Cloudflare's bot
4141
# protection on app.tracerml.ai (HTTP 403, CF error 1010).
42-
USER_AGENT = "tracer-cli/0.3.0 (+https://tracerml.ai)"
42+
USER_AGENT = "tracer-cli/0.3.1 (+https://tracerml.ai)"
4343

4444

4545
def _http(
@@ -191,6 +191,21 @@ def _access_token(self) -> str:
191191
self._refresh()
192192
return self.cfg["access_token"]
193193

194+
def _auth_header(self, auth: str) -> dict[str, str]:
195+
"""Build the Authorization header for a request.
196+
197+
auth="required" (default): a session is mandatory (raises if absent).
198+
auth="optional": attach the bearer when logged in, otherwise call
199+
anonymously -- for public endpoints (e.g. /api/scan) that work
200+
without an account, matching the public web flow.
201+
auth="none": never attach a bearer.
202+
"""
203+
if auth == "none":
204+
return {}
205+
if auth == "optional" and not self.logged_in:
206+
return {}
207+
return {"Authorization": f"Bearer {self._access_token()}"}
208+
194209
def logout(self) -> None:
195210
for k in ("access_token", "refresh_token", "expires_at", "email", "user_id"):
196211
self.cfg.pop(k, None)
@@ -204,11 +219,12 @@ def api(
204219
*,
205220
json_body: Any | None = None,
206221
params: dict[str, Any] | None = None,
222+
auth: str = "required",
207223
) -> Any:
208224
url = f"{self.base_url}{path}"
209225
if params:
210226
url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
211-
headers = {"Authorization": f"Bearer {self._access_token()}"}
227+
headers = {**self._auth_header(auth), "Origin": self.base_url}
212228
data = None
213229
if json_body is not None:
214230
headers["Content-Type"] = "application/json"
@@ -220,7 +236,9 @@ def api(
220236
raise CloudError(f"{method} {path} -> {status}: {err}")
221237
return payload
222238

223-
def api_multipart(self, path: str, fields: dict[str, str], file_path: str | None) -> Any:
239+
def api_multipart(
240+
self, path: str, fields: dict[str, str], file_path: str | None, *, auth: str = "required"
241+
) -> Any:
224242
"""POST multipart/form-data (used by the upload + create routes)."""
225243
boundary = f"----tracer{uuid.uuid4().hex}"
226244
buf = io.BytesIO()
@@ -245,7 +263,8 @@ def w(s: str) -> None:
245263
w(f"--{boundary}--\r\n")
246264

247265
headers = {
248-
"Authorization": f"Bearer {self._access_token()}",
266+
**self._auth_header(auth),
267+
"Origin": self.base_url,
249268
"Content-Type": f"multipart/form-data; boundary={boundary}",
250269
}
251270
status, body, _ = _http(

‎tests/test_cloud_cli.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,31 @@ def test_tenant_delete_calls_rpc_when_confirmed():
9797
assert c.rpc_calls == [("delete_company_tenant", {"_tenant": "t1"})]
9898

9999

100+
# ----- _resolve_tracer picks id vs slug by ref shape ------------------------- #
101+
class _ResolveClient:
102+
def __init__(self):
103+
self.filters = []
104+
105+
def db(self, view, **kw):
106+
self.filters.append(kw.get("filters"))
107+
return [{"id": "t-1", "slug": "my-tracer"}]
108+
109+
110+
def test_resolve_tracer_uses_slug_for_nonuuid():
111+
# A slug must NOT be sent as an id filter (the id column is uuid-typed and
112+
# PostgREST 400s on a non-uuid before any fallback can run).
113+
c = _ResolveClient()
114+
cli._resolve_tracer(c, "my-tracer")
115+
assert c.filters[0] == {"slug": "eq.my-tracer"}
116+
117+
118+
def test_resolve_tracer_uses_id_for_uuid():
119+
c = _ResolveClient()
120+
uid = "38b9a2cf-7888-4ebd-a136-18add9c1d56a"
121+
cli._resolve_tracer(c, uid)
122+
assert c.filters[0] == {"id": f"eq.{uid}"}
123+
124+
100125
# ----- _table doesn't crash on empty ----------------------------------------- #
101126
def test_table_empty(capsys):
102127
cli._table([], ["a", "b"])

0 commit comments

Comments
 (0)