tamfis-code 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,461 @@
1
+ """Thin async client for the TamfisGPT Remote API.
2
+
3
+ This is the ONLY place TamfisGPT Code talks to the backend. It calls the
4
+ exact same endpoints the web frontend's remoteApi.ts calls (tier_ii_gateway/
5
+ api/remote.py) -- no separate CLI-only provider/tool/session logic, per
6
+ docs/REMOTE_AGENT_MASTER_SPEC.md Phase 21's "shared runtime contract"
7
+ requirement.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import mimetypes
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Any, AsyncIterator, Optional
17
+
18
+ import httpx
19
+
20
+ from .config import (
21
+ CREDENTIALS_PATH, Config, Credentials, clear_credentials as _clear_file_credentials,
22
+ load_credentials as _load_file_credentials, save_credentials as _save_file_credentials,
23
+ )
24
+
25
+ KEYRING_SERVICE = "tamfis-code"
26
+ KEYRING_ACCOUNT = "default"
27
+
28
+
29
+ def _keyring_module():
30
+ try:
31
+ import keyring # type: ignore
32
+ return keyring
33
+ except (ImportError, RuntimeError):
34
+ return None
35
+
36
+
37
+ def credential_storage_backend() -> str:
38
+ return "system-keyring" if _keyring_module() is not None else f"owner-only-file ({CREDENTIALS_PATH})"
39
+
40
+
41
+ def load_secure_credentials() -> Optional[Credentials]:
42
+ # Preserve the documented environment-token override without copying it
43
+ # into keyring or disk.
44
+ if os.environ.get("TAMFIS_CODE_TOKEN"):
45
+ return _load_file_credentials()
46
+ keyring = _keyring_module()
47
+ if keyring is not None:
48
+ try:
49
+ raw = keyring.get_password(KEYRING_SERVICE, KEYRING_ACCOUNT)
50
+ if raw:
51
+ data = json.loads(raw)
52
+ if data.get("access_token"):
53
+ return Credentials(**{key: data.get(key) for key in (
54
+ "access_token", "refresh_token", "user_id", "email"
55
+ )})
56
+ except Exception:
57
+ # Headless Secret Service installations often import correctly
58
+ # but have no unlocked collection. The 0600 fallback is the
59
+ # intentional supported path there.
60
+ pass
61
+ return _load_file_credentials()
62
+
63
+
64
+ def save_secure_credentials(creds: Credentials) -> str:
65
+ keyring = _keyring_module()
66
+ if keyring is not None:
67
+ try:
68
+ keyring.set_password(KEYRING_SERVICE, KEYRING_ACCOUNT, json.dumps({
69
+ "access_token": creds.access_token, "refresh_token": creds.refresh_token,
70
+ "user_id": creds.user_id, "email": creds.email,
71
+ }))
72
+ _clear_file_credentials()
73
+ return "system-keyring"
74
+ except Exception:
75
+ pass
76
+ _save_file_credentials(creds)
77
+ return "owner-only-file"
78
+
79
+
80
+ def clear_secure_credentials() -> bool:
81
+ removed = _clear_file_credentials()
82
+ keyring = _keyring_module()
83
+ if keyring is not None:
84
+ try:
85
+ keyring.delete_password(KEYRING_SERVICE, KEYRING_ACCOUNT)
86
+ removed = True
87
+ except Exception:
88
+ pass
89
+ return removed
90
+
91
+
92
+ class RemoteAPIError(RuntimeError):
93
+ def __init__(self, status_code: int, detail: str):
94
+ super().__init__(detail)
95
+ self.status_code = status_code
96
+ self.detail = detail
97
+
98
+
99
+ class AuthRequiredError(RemoteAPIError):
100
+ """401 that survived a refresh attempt (or no credentials at all) --
101
+ the caller must run `tamfis-code login`."""
102
+
103
+
104
+ def _api_root(api_base: str) -> str:
105
+ return api_base.rstrip("/") + "/api/v1"
106
+
107
+
108
+ class RemoteAPIClient:
109
+ def __init__(self, config: Config, credentials: Optional[Credentials] = None):
110
+ self.config = config
111
+ self.credentials = credentials or load_secure_credentials()
112
+ self._client = httpx.AsyncClient(timeout=config.timeout_seconds)
113
+
114
+ async def aclose(self) -> None:
115
+ await self._client.aclose()
116
+
117
+ async def __aenter__(self) -> "RemoteAPIClient":
118
+ return self
119
+
120
+ async def __aexit__(self, *exc: Any) -> None:
121
+ await self.aclose()
122
+
123
+ # -- low-level request plumbing -----------------------------------
124
+
125
+ def _headers(self) -> dict[str, str]:
126
+ if not self.credentials:
127
+ return {}
128
+ return {"Authorization": f"Bearer {self.credentials.access_token}"}
129
+
130
+ async def _refresh(self) -> bool:
131
+ if not self.credentials or not self.credentials.refresh_token:
132
+ return False
133
+ # /auth/refresh reads the refresh token ONLY from the TAMFIS_REFRESH
134
+ # cookie (tier_ii_gateway/dependencies/auth.py's refresh_token()) --
135
+ # a JSON body is silently ignored. The browser client relies on the
136
+ # httpOnly Set-Cookie from /auth/login; the CLI has no cookie jar
137
+ # persisted across processes, so it sends the stored refresh token
138
+ # as that same cookie explicitly on just this one request.
139
+ url = f"{_api_root(self.config.api_base)}/auth/refresh"
140
+ try:
141
+ # Set the Cookie header directly rather than httpx's per-request
142
+ # `cookies=` kwarg -- that's deprecated on a shared client (its
143
+ # interaction with the client's own persistent cookie jar is
144
+ # ambiguous); this request needs exactly one cookie and nothing
145
+ # from the jar, so there's no ambiguity to have.
146
+ resp = await self._client.post(
147
+ url,
148
+ headers={
149
+ "Cookie": f"TAMFIS_REFRESH={self.credentials.refresh_token}",
150
+ "X-Tamfis-Client": "tamfis-code",
151
+ },
152
+ )
153
+ except httpx.HTTPError:
154
+ return False
155
+ if resp.status_code != 200:
156
+ return False
157
+ data = _unwrap(resp.json())
158
+ access_token = data.get("access_token")
159
+ if not access_token:
160
+ return False
161
+ self.credentials = Credentials(
162
+ access_token=access_token,
163
+ refresh_token=data.get("refresh_token", self.credentials.refresh_token),
164
+ user_id=self.credentials.user_id,
165
+ email=self.credentials.email,
166
+ )
167
+ save_secure_credentials(self.credentials)
168
+ return True
169
+
170
+ async def request(
171
+ self, method: str, path: str, *, json_body: Optional[dict[str, Any]] = None,
172
+ params: Optional[dict[str, Any]] = None, _retried: bool = False,
173
+ ) -> Any:
174
+ url = f"{_api_root(self.config.api_base)}{path}"
175
+ resp = await self._client.request(method, url, json=json_body, params=params, headers=self._headers())
176
+
177
+ if resp.status_code == 401 and not _retried:
178
+ if await self._refresh():
179
+ return await self.request(method, path, json_body=json_body, params=params, _retried=True)
180
+ raise AuthRequiredError(401, "Not authenticated -- run `tamfis-code login`")
181
+
182
+ if resp.status_code >= 400:
183
+ detail = _error_detail(resp)
184
+ if resp.status_code == 401:
185
+ raise AuthRequiredError(401, detail)
186
+ raise RemoteAPIError(resp.status_code, detail)
187
+
188
+ if not resp.content:
189
+ return None
190
+ return _unwrap(resp.json())
191
+
192
+ # -- auth -----------------------------------------------------------
193
+
194
+ async def login(self, email: str, password: str) -> dict[str, Any]:
195
+ url = f"{_api_root(self.config.api_base)}/auth/login"
196
+ # remember_me=True: a CLI session is inherently long-lived and
197
+ # single-user, not a shared browser -- the short (minutes-scale)
198
+ # default token expiry meant a real interactive session (the kind
199
+ # multi-step engineering work actually needs) would silently start
200
+ # 401ing mid-task after normal working-session lengths, forcing a
201
+ # re-login with no warning. remember_me buys the full 30-day expiry
202
+ # tier_ii_gateway/dependencies/auth.py already defines for exactly
203
+ # this case.
204
+ resp = await self._client.post(
205
+ url,
206
+ json={"email": email, "password": password, "remember_me": True},
207
+ headers={"X-Tamfis-Client": "tamfis-code"},
208
+ )
209
+ if resp.status_code >= 400:
210
+ raise RemoteAPIError(resp.status_code, _error_detail(resp))
211
+ return _unwrap(resp.json())
212
+
213
+ async def me(self) -> dict[str, Any]:
214
+ return await self.request("GET", "/auth/me")
215
+
216
+ async def logout(self) -> dict[str, Any]:
217
+ return await self.request("POST", "/auth/logout")
218
+
219
+ async def upload_attachment(self, path: Path, *, _retried: bool = False) -> dict[str, Any]:
220
+ """Upload a CLI attachment without base64 expansion.
221
+
222
+ Multipart keeps images and other binary inputs byte-for-byte and is
223
+ the same authenticated endpoint used by the web composer.
224
+ """
225
+ path = path.expanduser().resolve()
226
+ content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
227
+ url = f"{_api_root(self.config.api_base)}/files/upload"
228
+ with path.open("rb") as handle:
229
+ response = await self._client.post(
230
+ url,
231
+ files={"file": (path.name, handle, content_type)},
232
+ data={"source": "tamfis-code"},
233
+ headers=self._headers(),
234
+ )
235
+ if response.status_code == 401 and not _retried:
236
+ if await self._refresh():
237
+ return await self.upload_attachment(path, _retried=True)
238
+ raise AuthRequiredError(401, "Not authenticated -- run `tamfis-code login`")
239
+ if response.status_code >= 400:
240
+ raise RemoteAPIError(response.status_code, _error_detail(response))
241
+ return _unwrap(response.json())
242
+
243
+ # -- servers ----------------------------------------------------------
244
+
245
+ async def list_servers(self) -> list[dict[str, Any]]:
246
+ return await self.request("GET", "/remote/servers")
247
+
248
+ async def register_local_server(self, name: str) -> dict[str, Any]:
249
+ return await self.request(
250
+ "POST", "/remote/servers", json_body={"name": name, "transport_type": "local"}
251
+ )
252
+
253
+ # -- sessions -----------------------------------------------------
254
+
255
+ async def list_sessions(self) -> list[dict[str, Any]]:
256
+ return await self.request("GET", "/remote/sessions")
257
+
258
+ async def create_session(self, server_id: int, working_directory: Optional[str] = None) -> dict[str, Any]:
259
+ body: dict[str, Any] = {"server_id": server_id}
260
+ if working_directory:
261
+ body["working_directory"] = working_directory
262
+ return await self.request("POST", "/remote/sessions", json_body=body)
263
+
264
+ async def get_session(self, session_id: int) -> dict[str, Any]:
265
+ return await self.request("GET", f"/remote/sessions/{session_id}")
266
+
267
+ async def set_session_cwd(self, session_id: int, working_directory: str) -> dict[str, Any]:
268
+ return await self.request(
269
+ "POST", f"/remote/sessions/{session_id}/cwd", json_body={"working_directory": working_directory}
270
+ )
271
+
272
+ async def expand_session_workspace(self, session_id: int, path: str) -> dict[str, Any]:
273
+ return await self.request(
274
+ "POST", f"/remote/sessions/{session_id}/expand-workspace", json_body={"path": path}
275
+ )
276
+
277
+ async def get_thread(self, session_id: int, after_sequence: int = 0) -> dict[str, Any]:
278
+ return await self.request(
279
+ "GET", f"/remote/sessions/{session_id}/thread", params={"after_sequence": after_sequence}
280
+ )
281
+
282
+ # -- file mutation ledger (Phase 14: diff/rollback) ------------------
283
+
284
+ async def list_file_mutations(self, session_id: int, limit: int = 50) -> dict[str, Any]:
285
+ return await self.request(
286
+ "GET", f"/remote/sessions/{session_id}/file-mutations", params={"limit": limit}
287
+ )
288
+
289
+ async def revert_file_mutation(self, session_id: int, mutation_id: str) -> dict[str, Any]:
290
+ return await self.request(
291
+ "POST", f"/remote/sessions/{session_id}/file-mutations/{mutation_id}/revert"
292
+ )
293
+
294
+ # -- commands (explicit shell path) --------------------------------
295
+
296
+ async def submit_command(self, session_id: int, command: str) -> dict[str, Any]:
297
+ return await self.request(
298
+ "POST", f"/remote/sessions/{session_id}/commands", json_body={"command": command}
299
+ )
300
+
301
+ async def get_command(self, command_id: int) -> dict[str, Any]:
302
+ return await self.request("GET", f"/remote/commands/{command_id}")
303
+
304
+ async def approve_command(self, command_id: int, decision: str) -> dict[str, Any]:
305
+ return await self.request(
306
+ "POST", f"/remote/commands/{command_id}/approve", json_body={"decision": decision}
307
+ )
308
+
309
+ async def cancel_command(self, command_id: int) -> dict[str, Any]:
310
+ return await self.request("POST", f"/remote/commands/{command_id}/cancel")
311
+
312
+ # -- persistent background PTY terminal --------------------------------
313
+
314
+ async def start_pty(self, session_id: int, shell_command: str = "bash", cols: int = 80, rows: int = 24) -> dict[str, Any]:
315
+ return await self.request(
316
+ "POST", "/remote/pty/start",
317
+ json_body={"session_id": session_id, "shell_command": shell_command, "cols": cols, "rows": rows},
318
+ )
319
+
320
+ async def approve_pty(self, pty_id: str) -> dict[str, Any]:
321
+ return await self.request("POST", f"/remote/pty/{pty_id}/approve")
322
+
323
+ async def deny_pty(self, pty_id: str) -> dict[str, Any]:
324
+ return await self.request("POST", f"/remote/pty/{pty_id}/deny")
325
+
326
+ async def write_pty(self, pty_id: str, data: str) -> dict[str, Any]:
327
+ return await self.request("POST", f"/remote/pty/{pty_id}/write", json_body={"data": data})
328
+
329
+ async def read_pty(self, pty_id: str, since: int = 0) -> dict[str, Any]:
330
+ return await self.request("GET", f"/remote/pty/{pty_id}/read", params={"since": since})
331
+
332
+ async def kill_pty(self, pty_id: str) -> dict[str, Any]:
333
+ return await self.request("POST", f"/remote/pty/{pty_id}/kill")
334
+
335
+ async def list_pty(self, session_id: int) -> list[dict[str, Any]]:
336
+ return await self.request("GET", f"/remote/sessions/{session_id}/pty")
337
+
338
+ # -- AI tasks ---------------------------------------------------------
339
+
340
+ async def run_ai_task(
341
+ self, session_id: int, objective: str, mode: str = "coding", task_id: Optional[str] = None,
342
+ model: str = "auto", provider: Optional[str] = None,
343
+ attachments: Optional[list[dict[str, Any]]] = None,
344
+ ) -> dict[str, Any]:
345
+ # Identify CLI-originated work so the shared Remote service can
346
+ # apply tamfis-code preferences without changing web/chat routing.
347
+ body: dict[str, Any] = {
348
+ "objective": objective,
349
+ "mode": mode,
350
+ "source_client": "tamfis-code",
351
+ "model": model or "auto",
352
+ "attachments": attachments or [],
353
+ }
354
+ if provider:
355
+ body["provider"] = provider
356
+ if task_id:
357
+ body["task_id"] = task_id
358
+ return await self.request("POST", f"/remote/sessions/{session_id}/ai-tasks", json_body=body)
359
+
360
+ async def list_models(self, provider: Optional[str] = None) -> dict[str, Any]:
361
+ params: dict[str, Any] = {"per_page": 200}
362
+ if provider:
363
+ params["provider"] = provider
364
+ return await self.request("GET", "/models", params=params)
365
+
366
+ async def get_task(self, task_id: str) -> dict[str, Any]:
367
+ return await self.request("GET", f"/remote/tasks/{task_id}")
368
+
369
+ async def cancel_task(self, task_id: str) -> dict[str, Any]:
370
+ return await self.request("POST", f"/remote/tasks/{task_id}/cancel")
371
+
372
+ async def retry_task(self, task_id: str, mode: Optional[str] = None) -> dict[str, Any]:
373
+ body = {"mode": mode} if mode else {}
374
+ return await self.request("POST", f"/remote/tasks/{task_id}/retry", json_body=body)
375
+
376
+ async def add_task_instruction(self, task_id: str, text: str, classification: str = "append") -> dict[str, Any]:
377
+ return await self.request(
378
+ "POST", f"/remote/tasks/{task_id}/instructions",
379
+ json_body={"text": text, "classification": classification},
380
+ )
381
+
382
+ # -- streaming ---------------------------------------------------------
383
+
384
+ async def stream_session(self, session_id: int, last_event_id: int = 0) -> AsyncIterator[dict[str, Any]]:
385
+ """Yields parsed event envelopes from GET /sessions/{id}/stream.
386
+
387
+ Mirrors remoteStore.ts's connectSessionStream: an authenticated raw
388
+ fetch/stream (not EventSource, which cannot send an Authorization
389
+ header), reconnecting the caller's responsibility -- this generator
390
+ ends when the server closes the stream or on a connection error, and
391
+ does not retry itself, so `interactive.py`/non-interactive callers
392
+ control reconnect-with-last-event-id explicitly."""
393
+ url = f"{_api_root(self.config.api_base)}/remote/sessions/{session_id}/stream"
394
+ params = {"last_event_id": max(0, last_event_id)}
395
+ async with self._client.stream(
396
+ "GET", url, params=params, headers=self._headers(), timeout=None
397
+ ) as resp:
398
+ if resp.status_code == 401:
399
+ if await self._refresh():
400
+ async for event in self.stream_session(session_id, last_event_id):
401
+ yield event
402
+ return
403
+ raise AuthRequiredError(401, "Not authenticated -- run `tamfis-code login`")
404
+ if resp.status_code >= 400:
405
+ body = await resp.aread()
406
+ raise RemoteAPIError(resp.status_code, body.decode("utf-8", errors="replace"))
407
+
408
+ event_type = None
409
+ event_id = None
410
+ data_lines: list[str] = []
411
+ async for raw_line in resp.aiter_lines():
412
+ line = raw_line.rstrip("\n")
413
+ if line == "":
414
+ if data_lines:
415
+ raw_data = "\n".join(data_lines)
416
+ data_lines = []
417
+ if raw_data.strip() and raw_data.strip() != "[DONE]":
418
+ try:
419
+ payload = json.loads(raw_data)
420
+ except json.JSONDecodeError:
421
+ continue
422
+ if event_type and "event_type" not in payload:
423
+ payload["event_type"] = event_type
424
+ # Only the SSE `id:` field is a valid replay
425
+ # cursor. Canonical payloads also have a
426
+ # `sequence`, but that number belongs to a
427
+ # different/global event domain and must never
428
+ # advance a session stream checkpoint.
429
+ if event_id is not None:
430
+ try:
431
+ payload["stream_sequence"] = int(event_id)
432
+ payload.setdefault("sequence", int(event_id))
433
+ except ValueError:
434
+ payload["stream_event_id"] = event_id
435
+ yield payload
436
+ event_type = None
437
+ event_id = None
438
+ continue
439
+ if line.startswith("event:"):
440
+ event_type = line[len("event:"):].strip()
441
+ elif line.startswith("id:"):
442
+ event_id = line[len("id:"):].strip()
443
+ elif line.startswith("data:"):
444
+ data_lines.append(line[len("data:"):].strip())
445
+
446
+
447
+ def _unwrap(payload: Any) -> Any:
448
+ if isinstance(payload, dict) and "data" in payload and set(payload.keys()) <= {"data", "success", "message"}:
449
+ return payload["data"]
450
+ return payload
451
+
452
+
453
+ def _error_detail(resp: httpx.Response) -> str:
454
+ try:
455
+ data = resp.json()
456
+ except (json.JSONDecodeError, ValueError):
457
+ return resp.text or f"HTTP {resp.status_code}"
458
+ detail = data.get("detail") if isinstance(data, dict) else None
459
+ if isinstance(detail, list):
460
+ return "; ".join(str(item.get("msg", item)) for item in detail)
461
+ return str(detail or data)