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.
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/PKG-INFO +37 -6
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/README.md +36 -5
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/pyproject.toml +1 -1
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/auth.py +64 -15
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/models.py +39 -3
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_runtime/main.py +329 -20
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/main.py +86 -47
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/runtime_agent.py +113 -6
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/attach_content.py +9 -6
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/dashboard.py +79 -37
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/file_browser.py +13 -10
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/widgets.py +179 -59
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/uv.lock +1 -1
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/.gitignore +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/.python-version +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/CLAUDE.md +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/Makefile +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/config.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_runtime/snake_game/.gitignore +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/test_hello.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/tests/__init__.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/tests/conftest.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/tests/test_config.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.20 → kiwi_code-0.0.22}/tests/test_tokens.py +0 -0
- {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.
|
|
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
|
|
43
|
+
### 1) Install
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
14
|
+
### 1) Install
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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,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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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() >= (
|
|
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
|
|
1351
|
-
|
|
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 {
|
|
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
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
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(
|
|
2903
|
+
sys.exit(0)
|
|
2595
2904
|
|
|
2596
2905
|
print_section("Connection")
|
|
2597
2906
|
|