kiwi-code 0.0.20__tar.gz → 0.0.22__tar.gz

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.
Files changed (48) hide show
  1. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/PKG-INFO +37 -6
  2. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/README.md +36 -5
  3. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/pyproject.toml +1 -1
  4. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/auth.py +64 -15
  5. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/models.py +39 -3
  6. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_runtime/main.py +329 -20
  7. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/main.py +86 -47
  8. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/runtime_agent.py +113 -6
  9. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/attach_content.py +9 -6
  10. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/dashboard.py +79 -37
  11. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/file_browser.py +13 -10
  12. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/widgets.py +179 -59
  13. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/uv.lock +1 -1
  14. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/.github/workflows/publish.yml +0 -0
  15. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/.github/workflows/test.yml +0 -0
  16. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/.gitignore +0 -0
  17. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/.python-version +0 -0
  18. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/CLAUDE.md +0 -0
  19. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/Makefile +0 -0
  20. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/__init__.py +0 -0
  21. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/cli.py +0 -0
  22. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/client.py +0 -0
  23. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/commands.py +0 -0
  24. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/config.py +0 -0
  25. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/logger.py +0 -0
  26. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/runtime_manager.py +0 -0
  27. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_runtime/__init__.py +0 -0
  28. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_runtime/__main__.py +0 -0
  29. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_runtime/snake_game/.gitignore +0 -0
  30. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
  31. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/__init__.py +0 -0
  32. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/inline_file_picker.py +0 -0
  33. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/__init__.py +0 -0
  34. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/command_result.py +0 -0
  35. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/id_picker.py +0 -0
  36. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/login.py +0 -0
  37. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  38. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  39. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/slash_picker.py +0 -0
  40. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/test_hello.py +0 -0
  41. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/tests/__init__.py +0 -0
  42. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/tests/conftest.py +0 -0
  43. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/tests/test_cli_help.py +0 -0
  44. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/tests/test_config.py +0 -0
  45. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/tests/test_imports.py +0 -0
  46. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/tests/test_reexec_kiwi.py +0 -0
  47. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/tests/test_tokens.py +0 -0
  48. {kiwi_code-0.0.20 → kiwi_code-0.0.22}/tests/test_tui_headless.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.20
3
+ Version: 0.0.22
4
4
  Summary: A textual-based terminal user interface application
5
5
  Project-URL: Homepage, https://meetkiwi.ai
6
6
  Project-URL: Repository, https://github.com/jetoslabs/kiwi-code
@@ -40,12 +40,43 @@ Kiwi Code is a terminal-first UI (TUI) for chatting with **Kiwi Actions** and ma
40
40
 
41
41
  ## Quick start
42
42
 
43
- ### 1) Install deps (repo)
43
+ ### 1) Install
44
44
 
45
- ```bash
46
- cd kiwi-code
47
- uv sync
48
- ```
45
+ pip install kiwi-code
46
+
47
+ ### 2) Start the TUI
48
+
49
+ Connect using a server preset:
50
+
51
+ kiwi connect --server app
52
+
53
+ Available presets:
54
+ - app (prod)
55
+ - dev (dev)
56
+
57
+ ### 3) Login
58
+
59
+ The TUI will prompt for login if you’re not authenticated.
60
+
61
+ Tokens/config are stored under:
62
+
63
+ - ~/.kiwi/tokens.json
64
+ - ~/.kiwi/config.json
65
+
66
+ ### 4) (Optional) Start the Runtime
67
+
68
+ If you want to run the runtime manually:
69
+
70
+ kiwi-runtime connect --server dev --scope full
71
+
72
+ Or for production:
73
+
74
+ kiwi-runtime connect --server app --scope full
75
+
76
+ ### Notes
77
+
78
+ - --scope full gives unrestricted access. Use restricted if you want tighter control.
79
+ - In most cases, you can skip manual runtime startup and just use /connect-cli inside the TUI.
49
80
 
50
81
  ### 2) Start the TUI
51
82
 
@@ -11,12 +11,43 @@ Kiwi Code is a terminal-first UI (TUI) for chatting with **Kiwi Actions** and ma
11
11
 
12
12
  ## Quick start
13
13
 
14
- ### 1) Install deps (repo)
14
+ ### 1) Install
15
15
 
16
- ```bash
17
- cd kiwi-code
18
- uv sync
19
- ```
16
+ pip install kiwi-code
17
+
18
+ ### 2) Start the TUI
19
+
20
+ Connect using a server preset:
21
+
22
+ kiwi connect --server app
23
+
24
+ Available presets:
25
+ - app (prod)
26
+ - dev (dev)
27
+
28
+ ### 3) Login
29
+
30
+ The TUI will prompt for login if you’re not authenticated.
31
+
32
+ Tokens/config are stored under:
33
+
34
+ - ~/.kiwi/tokens.json
35
+ - ~/.kiwi/config.json
36
+
37
+ ### 4) (Optional) Start the Runtime
38
+
39
+ If you want to run the runtime manually:
40
+
41
+ kiwi-runtime connect --server dev --scope full
42
+
43
+ Or for production:
44
+
45
+ kiwi-runtime connect --server app --scope full
46
+
47
+ ### Notes
48
+
49
+ - --scope full gives unrestricted access. Use restricted if you want tighter control.
50
+ - In most cases, you can skip manual runtime startup and just use /connect-cli inside the TUI.
20
51
 
21
52
  ### 2) Start the TUI
22
53
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiwi-code"
3
- version = "0.0.20"
3
+ version = "0.0.22"
4
4
  description = "A textual-based terminal user interface application"
5
5
  readme = {file = "README.md", content-type = "text/markdown"}
6
6
  requires-python = ">=3.11,<4.0"
@@ -1,14 +1,65 @@
1
1
  """Authentication and token management."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import json
6
+ import os
4
7
  import sys
8
+ import time
9
+ from contextlib import contextmanager
5
10
  from pathlib import Path
6
11
  from typing import Optional
12
+ def _lock_path_for(token_path: Path) -> Path:
13
+ return token_path.with_suffix(".lock")
14
+
15
+
16
+ @contextmanager
17
+ def _file_lock(lock_path: Path):
18
+ """Cross-process lock using a lockfile.
19
+
20
+ This prevents concurrent refresh-token rotation races and avoids partial writes.
21
+ """
22
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
23
+ fp = open(lock_path, "a+")
24
+ try:
25
+ if sys.platform == "win32":
26
+ import msvcrt
27
+ # Lock 1 byte; blocks until available.
28
+ msvcrt.locking(fp.fileno(), msvcrt.LK_LOCK, 1)
29
+ else:
30
+ import fcntl
31
+ fcntl.flock(fp.fileno(), fcntl.LOCK_EX)
32
+ yield
33
+ finally:
34
+ try:
35
+ if sys.platform == "win32":
36
+ import msvcrt
37
+ msvcrt.locking(fp.fileno(), msvcrt.LK_UNLCK, 1)
38
+ else:
39
+ import fcntl
40
+ fcntl.flock(fp.fileno(), fcntl.LOCK_UN)
41
+ finally:
42
+ fp.close()
43
+
44
+
45
+ def _atomic_write_json(path: Path, data: dict) -> None:
46
+ """Atomically write JSON (write temp + replace) to avoid corruption."""
47
+ path.parent.mkdir(parents=True, exist_ok=True)
48
+ tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
49
+ with open(tmp, "w", encoding="utf-8") as f:
50
+ json.dump(data, f, indent=2, default=str)
51
+ f.flush()
52
+ os.fsync(f.fileno())
53
+ os.replace(tmp, path)
54
+ if sys.platform != "win32":
55
+ path.chmod(0o600)
56
+
7
57
  from loguru import logger
8
58
 
9
59
  from .models import AuthTokens
10
60
 
11
61
 
62
+
12
63
  class TokenManager:
13
64
  """Manages authentication tokens with secure storage."""
14
65
 
@@ -24,27 +75,25 @@ class TokenManager:
24
75
  self.token_path = token_path
25
76
  self._tokens: Optional[AuthTokens] = None
26
77
 
78
+ @contextmanager
79
+ def file_lock(self):
80
+ """Acquire an exclusive cross-process lock for refresh/write operations."""
81
+ with _file_lock(_lock_path_for(self.token_path)):
82
+ yield
27
83
  def save_tokens(self, tokens: AuthTokens) -> None:
28
- """Save tokens to secure storage.
84
+ """Save tokens to secure storage (atomic write).
85
+
86
+ Notes:
87
+ Refresh flows should acquire `file_lock()` before calling this to avoid
88
+ refresh-token rotation races across processes.
29
89
 
30
90
  Args:
31
91
  tokens: Authentication tokens to save
32
92
  """
33
93
  self._tokens = tokens
34
- self.token_path.parent.mkdir(parents=True, exist_ok=True)
35
-
36
- try:
37
- with open(self.token_path, "w") as f:
38
- json.dump(tokens.model_dump(mode="json"), f, indent=2, default=str)
39
-
40
- # Set file permissions to user read/write only (0600)
41
- if sys.platform != "win32":
42
- self.token_path.chmod(0o600)
43
-
44
- logger.info("Authentication tokens saved securely")
45
- except Exception as e:
46
- logger.error(f"Failed to save tokens: {e}")
47
- raise
94
+ data = tokens.model_dump(mode="json")
95
+ _atomic_write_json(self.token_path, data)
96
+ logger.info("Authentication tokens saved securely")
48
97
 
49
98
  def load_tokens(self) -> Optional[AuthTokens]:
50
99
  """Load tokens from storage.
@@ -1,8 +1,13 @@
1
1
  """Pydantic models for Autobots TUI."""
2
2
 
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
3
7
  from datetime import datetime, timedelta
4
8
  from enum import Enum
5
9
  from typing import Optional, Any
10
+
6
11
  from pydantic import BaseModel, Field
7
12
 
8
13
 
@@ -63,11 +68,42 @@ class AuthTokens(BaseModel):
63
68
  expires_at: Optional[datetime] = None
64
69
 
65
70
  def is_expired(self) -> bool:
66
- """Check if access token is expired."""
67
- if not self.expires_at:
71
+ """Check if access token is expired.
72
+
73
+ Notes:
74
+ - Prefer `expires_at` when present.
75
+ - If `expires_at` is missing (common when the backend doesn't return expires_in),
76
+ fall back to the JWT `exp` claim (if the access token is a JWT).
77
+ """
78
+ def _jwt_exp(token: str) -> datetime | None:
79
+ try:
80
+ parts = token.split(".")
81
+ if len(parts) < 2:
82
+ return None
83
+ payload_b64 = parts[1]
84
+ # base64url decode with padding
85
+ payload_b64 += "=" * (-len(payload_b64) % 4)
86
+ payload = base64.urlsafe_b64decode(payload_b64.encode("utf-8"))
87
+ data = json.loads(payload.decode("utf-8"))
88
+ exp = data.get("exp")
89
+ if not isinstance(exp, (int, float)):
90
+ return None
91
+ return datetime.fromtimestamp(exp)
92
+ except Exception:
93
+ return None
94
+
95
+ jwt_expires_at = _jwt_exp(self.access_token)
96
+ effective_expires_at = self.expires_at or jwt_expires_at
97
+ if self.expires_at and jwt_expires_at:
98
+ # Be conservative if they disagree.
99
+ effective_expires_at = min(self.expires_at, jwt_expires_at)
100
+
101
+ if not effective_expires_at:
102
+ # No expiry info; assume valid (server will decide).
68
103
  return False
104
+
69
105
  # Add 60 second buffer before expiry
70
- return datetime.now() >= (self.expires_at - timedelta(seconds=60))
106
+ return datetime.now() >= (effective_expires_at - timedelta(seconds=60))
71
107
 
72
108
 
73
109
  class LoginCredentials(BaseModel):
@@ -13,6 +13,10 @@ Usage:
13
13
  kiwi connect --server dev --allow /path/to/extra/dir
14
14
  """
15
15
 
16
+ from __future__ import annotations
17
+
18
+ from datetime import datetime
19
+
16
20
  import argparse
17
21
  import asyncio
18
22
  import getpass
@@ -23,6 +27,9 @@ import shlex
23
27
  import signal
24
28
  import subprocess
25
29
  import sys
30
+ from pathlib import Path
31
+ from typing import Any
32
+
26
33
 
27
34
  IS_WINDOWS = sys.platform == "win32"
28
35
 
@@ -36,6 +43,226 @@ import httpx
36
43
  import websockets
37
44
  import threading
38
45
  FS_MAX_CONCURRENCY = 8
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Shared token storage (used by both kiwi-code and standalone kiwi-runtime)
49
+ # ---------------------------------------------------------------------------
50
+
51
+ TOKENS_PATH = Path.home() / ".kiwi" / "tokens.json"
52
+ TOKENS_LOCK_PATH = Path.home() / ".kiwi" / "tokens.lock"
53
+ TOKEN_EXPIRY_SKEW_SEC = 60 # refresh a bit early to avoid edge cases
54
+
55
+
56
+ def _parse_expires_at(value: Any) -> datetime | None:
57
+ """Parse expires_at from tokens.json.
58
+
59
+ Supports:
60
+ - ISO8601 strings written by Pydantic (e.g. 2026-05-03T12:34:56.789)
61
+ - str(datetime) legacy (e.g. 2026-05-03 12:34:56.789)
62
+ - epoch seconds (int/float)
63
+ - None / missing
64
+ """
65
+ try:
66
+ if value is None:
67
+ return None
68
+ if isinstance(value, (int, float)):
69
+ return datetime.fromtimestamp(value)
70
+ if not isinstance(value, str):
71
+ return None
72
+ s = value.strip()
73
+ if not s:
74
+ return None
75
+ # Handle Zulu suffix
76
+ s = s.replace("Z", "+00:00")
77
+ return datetime.fromisoformat(s)
78
+ except Exception:
79
+ return None
80
+
81
+
82
+ def _jwt_expires_at(token: str) -> datetime | None:
83
+ """Best-effort parse of JWT exp claim."""
84
+ try:
85
+ import base64
86
+ import json as _json
87
+ parts = token.split(".")
88
+ if len(parts) < 2:
89
+ return None
90
+ payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
91
+ payload = base64.urlsafe_b64decode(payload_b64.encode("utf-8"))
92
+ data = _json.loads(payload.decode("utf-8"))
93
+ exp = data.get("exp")
94
+ if not isinstance(exp, (int, float)):
95
+ return None
96
+ return datetime.fromtimestamp(exp)
97
+ except Exception:
98
+ return None
99
+
100
+
101
+ def _token_is_expired(*, access_token: str, expires_at: datetime | None) -> bool:
102
+ from datetime import timedelta
103
+ # Prefer explicit expires_at, but fall back to JWT exp if available.
104
+ jwt_exp = _jwt_expires_at(access_token)
105
+ effective = expires_at or jwt_exp
106
+ if expires_at and jwt_exp:
107
+ effective = min(expires_at, jwt_exp)
108
+ if not effective:
109
+ return False
110
+ return datetime.now() >= (effective - timedelta(seconds=TOKEN_EXPIRY_SKEW_SEC))
111
+
112
+
113
+ def _load_tokens_file(path: Path = TOKENS_PATH) -> dict[str, Any] | None:
114
+ """Load tokens.json (best-effort)."""
115
+ if not path.exists():
116
+ return None
117
+ # Avoid occasional partial-read issues during replace on some platforms by retrying quickly.
118
+ for _ in range(3):
119
+ try:
120
+ raw = path.read_text(encoding="utf-8")
121
+ return json.loads(raw) if raw.strip() else None
122
+ except Exception:
123
+ import time
124
+ time.sleep(0.05)
125
+ return None
126
+
127
+
128
+ def _atomic_write_tokens(path: Path, data: dict[str, Any]) -> None:
129
+ """Atomic tokens.json write to avoid corruption."""
130
+ path.parent.mkdir(parents=True, exist_ok=True)
131
+ tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
132
+ with open(tmp, "w", encoding="utf-8") as f:
133
+ json.dump(data, f, indent=2, default=str)
134
+ f.flush()
135
+ os.fsync(f.fileno())
136
+ os.replace(tmp, path)
137
+ if sys.platform != "win32":
138
+ try:
139
+ path.chmod(0o600)
140
+ except Exception:
141
+ pass
142
+
143
+
144
+ class _TokensLock:
145
+ def __init__(self, lock_path: Path = TOKENS_LOCK_PATH):
146
+ self.lock_path = lock_path
147
+ self.fp = None
148
+
149
+ def __enter__(self):
150
+ self.lock_path.parent.mkdir(parents=True, exist_ok=True)
151
+ self.fp = open(self.lock_path, "a+")
152
+ if sys.platform == "win32":
153
+ import msvcrt
154
+ msvcrt.locking(self.fp.fileno(), msvcrt.LK_LOCK, 1)
155
+ else:
156
+ import fcntl
157
+ fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX)
158
+ return self
159
+
160
+ def __exit__(self, exc_type, exc, tb):
161
+ try:
162
+ if self.fp:
163
+ if sys.platform == "win32":
164
+ import msvcrt
165
+ msvcrt.locking(self.fp.fileno(), msvcrt.LK_UNLCK, 1)
166
+ else:
167
+ import fcntl
168
+ fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
169
+ finally:
170
+ try:
171
+ if self.fp:
172
+ self.fp.close()
173
+ except Exception:
174
+ pass
175
+ self.fp = None
176
+
177
+
178
+ async def _refresh_tokens(http_base_url: str, refresh_token: str) -> dict[str, Any]:
179
+ """Refresh tokens via the backend and return tokens.json-compatible dict."""
180
+ from datetime import datetime, timedelta
181
+ url = f"{http_base_url.rstrip('/')}/v1/auth/session/refresh"
182
+ async with httpx.AsyncClient(timeout=30) as client:
183
+ resp = await client.post(url, params={"refresh_token": refresh_token})
184
+ if resp.status_code != 200:
185
+ raise Exception(f"Token refresh failed (HTTP {resp.status_code}): {resp.text[:200]}")
186
+ body = resp.json()
187
+ session = body.get("session") or {}
188
+ access = session.get("access_token")
189
+ new_refresh = session.get("refresh_token") or refresh_token
190
+ expires_in = session.get("expires_in")
191
+ token_type = session.get("token_type") or "Bearer"
192
+ if not access:
193
+ raise Exception(f"Token refresh response missing access_token: {body}")
194
+ expires_at = None
195
+ try:
196
+ if isinstance(expires_in, (int, float)):
197
+ expires_at = (datetime.now() + timedelta(seconds=int(expires_in))).isoformat()
198
+ except Exception:
199
+ expires_at = None
200
+ out: dict[str, Any] = {
201
+ "access_token": access,
202
+ "refresh_token": new_refresh,
203
+ "token_type": token_type,
204
+ }
205
+ if expires_at:
206
+ out["expires_at"] = expires_at
207
+ return out
208
+
209
+
210
+ async def _get_valid_access_token(http_base_url: str, fallback_token: str) -> str:
211
+ """Return a non-expired access token, preferring ~/.kiwi/tokens.json when available.
212
+
213
+ Behavior:
214
+ - If tokens.json contains a valid access_token, use it.
215
+ - If tokens.json exists but is expired and has refresh_token, try refresh under a lock.
216
+ - Otherwise fall back to the provided token.
217
+ """
218
+ data = _load_tokens_file(TOKENS_PATH)
219
+ if not data:
220
+ return fallback_token
221
+ access = data.get("access_token") or fallback_token
222
+ refresh = data.get("refresh_token")
223
+ expires_at = _parse_expires_at(data.get("expires_at"))
224
+ if access and not _token_is_expired(access_token=access, expires_at=expires_at):
225
+ return access
226
+ if not refresh:
227
+ return access or fallback_token
228
+ # Lock: another process may have refreshed already.
229
+ with _TokensLock():
230
+ data2 = _load_tokens_file(TOKENS_PATH) or data
231
+ access2 = data2.get("access_token") or access
232
+ refresh2 = data2.get("refresh_token") or refresh
233
+ expires_at2 = _parse_expires_at(data2.get("expires_at"))
234
+ if access2 and not _token_is_expired(access_token=access2, expires_at=expires_at2):
235
+ return access2
236
+ # Still expired -> attempt refresh. If refresh fails, fall back to the best token we have.
237
+ try:
238
+ new_data = await _refresh_tokens(http_base_url, refresh2)
239
+ _atomic_write_tokens(TOKENS_PATH, new_data)
240
+ return str(new_data.get("access_token"))
241
+ except Exception:
242
+ # Another process may have refreshed while we attempted, or refresh token may be invalid.
243
+ data3 = _load_tokens_file(TOKENS_PATH) or data2
244
+ return str(data3.get("access_token") or access2 or fallback_token)
245
+
246
+
247
+ async def _force_refresh_access_token(http_base_url: str, fallback_token: str) -> str:
248
+ """Force a refresh (if refresh_token exists). Used after HTTP 401."""
249
+ data = _load_tokens_file(TOKENS_PATH)
250
+ refresh = None
251
+ with _TokensLock():
252
+ data2 = _load_tokens_file(TOKENS_PATH) or data or {}
253
+ refresh2 = data2.get("refresh_token") or refresh
254
+ try:
255
+ new_data = await _refresh_tokens(http_base_url, refresh2)
256
+ _atomic_write_tokens(TOKENS_PATH, new_data)
257
+ return str(new_data.get("access_token"))
258
+ except Exception:
259
+ data3 = _load_tokens_file(TOKENS_PATH) or data2
260
+ return str(data3.get("access_token") or fallback_token)
261
+ refresh2 = data2.get("refresh_token") or refresh
262
+ new_data = await _refresh_tokens(http_base_url, refresh2)
263
+ _atomic_write_tokens(TOKENS_PATH, new_data)
264
+ return str(new_data.get("access_token"))
265
+
39
266
  _chunked_writes_lock = threading.Lock()
40
267
  MAX_OUTPUT_BYTES = 50 * 1024 # 50KB cap on command output
41
268
 
@@ -63,8 +290,6 @@ import shutil
63
290
  import tempfile
64
291
  import uuid
65
292
  from dataclasses import dataclass
66
- from pathlib import Path
67
- from typing import Any
68
293
 
69
294
 
70
295
  @dataclass
@@ -1347,9 +1572,62 @@ def _is_within_allowed(resolved_path: str, allowed_dirs: list[str]) -> bool:
1347
1572
  # ---------------------------------------------------------------------------
1348
1573
 
1349
1574
  async def login(http_base_url: str, email: str, password: str) -> str:
1350
- """Authenticate with email/password and return the JWT access token."""
1351
- url = f"{http_base_url}/v1/auth/token"
1575
+ """Authenticate with email/password and return the access token.
1576
+
1577
+ We prefer the session-based endpoint (/v1/auth/) which returns a refresh token so
1578
+ this runtime can keep making authenticated HTTP requests (e.g. file uploads) even
1579
+ after long uptimes.
1580
+
1581
+ On success, we persist tokens to ~/.kiwi/tokens.json (shared with kiwi-code).
1582
+ """
1583
+ base = http_base_url.rstrip("/")
1352
1584
  async with httpx.AsyncClient(timeout=30) as client:
1585
+ # Prefer session-based login (returns refresh_token + expires_in).
1586
+ url = f"{base}/v1/auth/"
1587
+ resp = await client.post(
1588
+ url,
1589
+ data={"username": email, "password": password},
1590
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
1591
+ )
1592
+ if resp.status_code == 200:
1593
+ body = resp.json()
1594
+ session = body.get("session") or {}
1595
+ access = session.get("access_token") or body.get("access_token")
1596
+ refresh = session.get("refresh_token")
1597
+ expires_in = session.get("expires_in")
1598
+ token_type = session.get("token_type") or "Bearer"
1599
+ if not access:
1600
+ raise Exception(f"No access_token in response: {body}")
1601
+ tokens_out: dict[str, Any] = {
1602
+ "access_token": access,
1603
+ "token_type": token_type,
1604
+ }
1605
+ if refresh:
1606
+ tokens_out["refresh_token"] = refresh
1607
+ # Compute expires_at from expires_in when available.
1608
+ try:
1609
+ from datetime import datetime, timedelta
1610
+ if isinstance(expires_in, (int, float)):
1611
+ tokens_out["expires_at"] = (
1612
+ datetime.now() + timedelta(seconds=int(expires_in))
1613
+ ).isoformat()
1614
+ except Exception:
1615
+ pass
1616
+ # Write shared tokens.json under lock; preserve an existing refresh_token if
1617
+ # server doesn't return one for some reason.
1618
+ try:
1619
+ with _TokensLock():
1620
+ existing = _load_tokens_file(TOKENS_PATH) or {}
1621
+ if "refresh_token" not in tokens_out and existing.get("refresh_token"):
1622
+ tokens_out["refresh_token"] = existing.get("refresh_token")
1623
+ _atomic_write_tokens(TOKENS_PATH, tokens_out)
1624
+ except Exception:
1625
+ # Best-effort persistence; auth is still successful.
1626
+ pass
1627
+ return access
1628
+
1629
+ # Fallback: OAuth-style token endpoint (access token only).
1630
+ url = f"{base}/v1/auth/token"
1353
1631
  resp = await client.post(
1354
1632
  url,
1355
1633
  data={"username": email, "password": password},
@@ -2034,6 +2312,7 @@ async def handle_read_files(
2034
2312
  open_handles = []
2035
2313
  try:
2036
2314
  upload_url = f"{http_base_url.rstrip('/')}/v1/files/public/"
2315
+ upload_token = await _get_valid_access_token(http_base_url, token)
2037
2316
  async with httpx.AsyncClient(timeout=120) as client:
2038
2317
  file_tuples = []
2039
2318
  for filename, filepath, _ in files_to_upload:
@@ -2044,9 +2323,27 @@ async def handle_read_files(
2044
2323
  resp = await client.post(
2045
2324
  upload_url,
2046
2325
  files=file_tuples,
2047
- headers={"Authorization": f"Bearer {token}"},
2326
+ headers={"Authorization": f"Bearer {upload_token}"},
2048
2327
  )
2049
2328
 
2329
+ # If the access token expired mid-session, try once more after a refresh.
2330
+ if resp.status_code in (401, 403):
2331
+ try:
2332
+ upload_token2 = await _force_refresh_access_token(http_base_url, upload_token)
2333
+ for fh in open_handles:
2334
+ try:
2335
+ fh.seek(0)
2336
+ except Exception:
2337
+ pass
2338
+ resp = await client.post(
2339
+ upload_url,
2340
+ files=file_tuples,
2341
+ headers={"Authorization": f"Bearer {upload_token2}"},
2342
+ )
2343
+ except Exception:
2344
+ # Fall through to error handling below.
2345
+ pass
2346
+
2050
2347
  if resp.status_code == 200:
2051
2348
  file_metas = resp.json()
2052
2349
  for meta in file_metas:
@@ -2102,6 +2399,10 @@ async def connect(
2102
2399
  if not agent_id:
2103
2400
  agent_id = str(uuid.uuid4())
2104
2401
 
2402
+ # Always prefer a fresh access token from ~/.kiwi/tokens.json when available.
2403
+ # This keeps long-running runtimes working even after kiwi-code refreshes tokens.
2404
+ token = await _get_valid_access_token(http_base_url, token)
2405
+
2105
2406
  await ws.send(json.dumps({
2106
2407
  "type": "auth",
2107
2408
  "token": token,
@@ -2575,23 +2876,31 @@ def main():
2575
2876
  print_status(">", "Using provided access token", GREEN)
2576
2877
  else:
2577
2878
  print_section("Authentication")
2578
- email = args.email
2579
- if email:
2580
- print_status(">", f"Email: {email}", GREY)
2581
- else:
2582
- email = input(f" {C}>{RESET} Email: ")
2583
- password = getpass.getpass(f" {C}>{RESET} Password: ")
2584
- print()
2585
- print_status(">", f"Agent ID: {BOLD}{agent_id}{RESET}", GREY)
2586
-
2587
- print_status("~", f"Authenticating with {BOLD}{http_url}{RESET}...", C)
2588
2879
  try:
2589
- token = loop.run_until_complete(login(http_url, email, password))
2590
- print_status(">", "Login successful!", GREEN)
2591
- except Exception as e:
2592
- print_status("x", f"Login failed: {e}", RED)
2880
+ email = args.email
2881
+ if email:
2882
+ print_status(">", f"Email: {email}", GREY)
2883
+ else:
2884
+ email = input(f" {C}>{RESET} Email: ")
2885
+ password = getpass.getpass(f" {C}>{RESET} Password: ")
2886
+ print()
2887
+ print_status(">", f"Agent ID: {BOLD}{agent_id}{RESET}", GREY)
2888
+
2889
+ print_status("~", f"Authenticating with {BOLD}{http_url}{RESET}...", C)
2890
+ try:
2891
+ token = loop.run_until_complete(login(http_url, email, password))
2892
+ print_status(">", "Login successful!", GREEN)
2893
+ except Exception as e:
2894
+ print_status("x", f"Login failed: {e}", RED)
2895
+ loop.close()
2896
+ sys.exit(1)
2897
+ except (KeyboardInterrupt, EOFError):
2898
+ print()
2899
+ print(f" {GREY}{'─' * 50}{RESET}")
2900
+ print_status(">", "Disconnected. Goodbye!", C)
2901
+ print()
2593
2902
  loop.close()
2594
- sys.exit(1)
2903
+ sys.exit(0)
2595
2904
 
2596
2905
  print_section("Connection")
2597
2906