forgexa-cli 1.11.3__tar.gz → 1.11.4__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.
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.4}/PKG-INFO +13 -4
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.4}/README.md +12 -3
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.4}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.4}/forgexa_cli/daemon.py +147 -16
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.4}/forgexa_cli/main.py +123 -60
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.4}/forgexa_cli.egg-info/PKG-INFO +13 -4
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.4}/forgexa_cli.egg-info/SOURCES.txt +2 -1
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.4}/pyproject.toml +1 -1
- forgexa_cli-1.11.4/tests/test_auth_and_runtime_commands.py +236 -0
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.4}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.4}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.4}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.4}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.11.3 → forgexa_cli-1.11.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: forgexa-cli
|
|
3
|
-
Version: 1.11.
|
|
3
|
+
Version: 1.11.4
|
|
4
4
|
Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
|
|
5
5
|
Author-email: Jason Sun <dev.winds@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -100,9 +100,9 @@ forgexa board --project <project-id>
|
|
|
100
100
|
| `forgexa budget --workspace <id>` | Budget overview |
|
|
101
101
|
| `forgexa daemon start` | Start local daemon (discover agents, run tasks) |
|
|
102
102
|
| `forgexa daemon start -d` | Start daemon in background |
|
|
103
|
-
| `forgexa daemon status` | Show daemon statuses |
|
|
103
|
+
| `forgexa daemon status` | Show your daemon statuses |
|
|
104
104
|
| `forgexa daemon stop` | Stop local daemon |
|
|
105
|
-
| `forgexa runtimes list` | List runtimes |
|
|
105
|
+
| `forgexa runtimes list` | List your runtimes |
|
|
106
106
|
| `forgexa version` | Show CLI version |
|
|
107
107
|
|
|
108
108
|
## Configuration
|
|
@@ -144,11 +144,20 @@ forgexa-daemon
|
|
|
144
144
|
### Other Daemon Commands
|
|
145
145
|
|
|
146
146
|
```bash
|
|
147
|
-
# Check daemon status (from server)
|
|
147
|
+
# Check your daemon status (from server)
|
|
148
148
|
forgexa daemon status
|
|
149
149
|
|
|
150
|
+
# Platform admin: list all runtimes
|
|
151
|
+
forgexa daemon status --all
|
|
152
|
+
|
|
150
153
|
# Stop background daemon
|
|
151
154
|
forgexa daemon stop
|
|
155
|
+
|
|
156
|
+
# List your runtimes
|
|
157
|
+
forgexa runtimes list
|
|
158
|
+
|
|
159
|
+
# Platform admin: list all runtimes
|
|
160
|
+
forgexa runtimes list --all
|
|
152
161
|
```
|
|
153
162
|
|
|
154
163
|
### Supported AI Agents
|
|
@@ -70,9 +70,9 @@ forgexa board --project <project-id>
|
|
|
70
70
|
| `forgexa budget --workspace <id>` | Budget overview |
|
|
71
71
|
| `forgexa daemon start` | Start local daemon (discover agents, run tasks) |
|
|
72
72
|
| `forgexa daemon start -d` | Start daemon in background |
|
|
73
|
-
| `forgexa daemon status` | Show daemon statuses |
|
|
73
|
+
| `forgexa daemon status` | Show your daemon statuses |
|
|
74
74
|
| `forgexa daemon stop` | Stop local daemon |
|
|
75
|
-
| `forgexa runtimes list` | List runtimes |
|
|
75
|
+
| `forgexa runtimes list` | List your runtimes |
|
|
76
76
|
| `forgexa version` | Show CLI version |
|
|
77
77
|
|
|
78
78
|
## Configuration
|
|
@@ -114,11 +114,20 @@ forgexa-daemon
|
|
|
114
114
|
### Other Daemon Commands
|
|
115
115
|
|
|
116
116
|
```bash
|
|
117
|
-
# Check daemon status (from server)
|
|
117
|
+
# Check your daemon status (from server)
|
|
118
118
|
forgexa daemon status
|
|
119
119
|
|
|
120
|
+
# Platform admin: list all runtimes
|
|
121
|
+
forgexa daemon status --all
|
|
122
|
+
|
|
120
123
|
# Stop background daemon
|
|
121
124
|
forgexa daemon stop
|
|
125
|
+
|
|
126
|
+
# List your runtimes
|
|
127
|
+
forgexa runtimes list
|
|
128
|
+
|
|
129
|
+
# Platform admin: list all runtimes
|
|
130
|
+
forgexa runtimes list --all
|
|
122
131
|
```
|
|
123
132
|
|
|
124
133
|
### Supported AI Agents
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.11.
|
|
2
|
+
__version__ = "1.11.4"
|
|
@@ -68,6 +68,42 @@ except ImportError:
|
|
|
68
68
|
_HTTPX_DEPS_DIR = os.path.join(str(Path.home()), ".forgexa", "daemon", "deps")
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def _cli_config_path() -> Path:
|
|
72
|
+
return Path.home() / ".forgexa" / "config"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_cli_config() -> dict:
|
|
76
|
+
path = _cli_config_path()
|
|
77
|
+
if not path.exists():
|
|
78
|
+
return {}
|
|
79
|
+
try:
|
|
80
|
+
return json.loads(path.read_text())
|
|
81
|
+
except Exception:
|
|
82
|
+
return {}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _save_cli_config(data: dict) -> None:
|
|
86
|
+
path = _cli_config_path()
|
|
87
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
path.write_text(json.dumps(data, indent=2))
|
|
89
|
+
path.chmod(0o600)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _save_cli_tokens(access_token: str, refresh_token: str | None = None) -> None:
|
|
93
|
+
cfg = _load_cli_config()
|
|
94
|
+
cfg["token"] = access_token
|
|
95
|
+
if refresh_token:
|
|
96
|
+
cfg["refresh_token"] = refresh_token
|
|
97
|
+
else:
|
|
98
|
+
cfg.pop("refresh_token", None)
|
|
99
|
+
_save_cli_config(cfg)
|
|
100
|
+
|
|
101
|
+
token_path = Path.home() / ".forgexa" / "token"
|
|
102
|
+
token_path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
token_path.write_text(access_token)
|
|
104
|
+
token_path.chmod(0o600)
|
|
105
|
+
|
|
106
|
+
|
|
71
107
|
def _try_install_httpx(deps_dir: str) -> tuple[bool, str]:
|
|
72
108
|
"""Try to install httpx to a user-writable directory.
|
|
73
109
|
|
|
@@ -438,7 +474,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
438
474
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
439
475
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
440
476
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
441
|
-
DAEMON_VERSION = "1.11.
|
|
477
|
+
DAEMON_VERSION = "1.11.4"
|
|
442
478
|
|
|
443
479
|
|
|
444
480
|
def _detect_client_type() -> str:
|
|
@@ -2844,6 +2880,10 @@ class ProcessManager:
|
|
|
2844
2880
|
or (task.input_data or {}).get("output_dir", "")
|
|
2845
2881
|
or ""
|
|
2846
2882
|
)
|
|
2883
|
+
elif task.node_type == "fix":
|
|
2884
|
+
bugfix_doc_path = str((task.input_data or {}).get("bugfix_doc_path", "") or "")
|
|
2885
|
+
bugfix_doc_path = bugfix_doc_path.replace("\\", "/").lstrip("./")
|
|
2886
|
+
return {bugfix_doc_path} if bugfix_doc_path else set()
|
|
2847
2887
|
else:
|
|
2848
2888
|
output_dir = str((task.input_data or {}).get("output_dir", "") or "")
|
|
2849
2889
|
output_dir = output_dir.replace("\\", "/").lstrip("./").rstrip("/")
|
|
@@ -4646,23 +4686,76 @@ class ServerConnection:
|
|
|
4646
4686
|
parsed = urlparse(server_url)
|
|
4647
4687
|
self.label = parsed.hostname or server_url
|
|
4648
4688
|
|
|
4649
|
-
def
|
|
4650
|
-
|
|
4689
|
+
def _apply_api_token(self, token: str) -> bool:
|
|
4690
|
+
token = str(token or "").strip()
|
|
4691
|
+
if not token or token == self.api_token:
|
|
4692
|
+
return False
|
|
4693
|
+
self.api_token = token
|
|
4694
|
+
self.client.headers["Authorization"] = f"Bearer {token}"
|
|
4695
|
+
return True
|
|
4651
4696
|
|
|
4652
|
-
|
|
4653
|
-
"""
|
|
4697
|
+
def _load_token_from_disk(self) -> str:
|
|
4654
4698
|
token_path = Path.home() / ".forgexa" / "token"
|
|
4655
4699
|
try:
|
|
4656
|
-
|
|
4700
|
+
return token_path.read_text().strip() if token_path.exists() else ""
|
|
4657
4701
|
except OSError:
|
|
4658
|
-
|
|
4702
|
+
return ""
|
|
4659
4703
|
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4704
|
+
async def refresh_access_token(self) -> bool:
|
|
4705
|
+
"""Refresh the daemon's user session token.
|
|
4706
|
+
|
|
4707
|
+
First re-read ~/.forgexa/token in case an interactive CLI login already
|
|
4708
|
+
rotated the access token. If that did not change anything, fall back to
|
|
4709
|
+
~/.forgexa/config refresh_token and call /api/v1/auth/refresh.
|
|
4710
|
+
"""
|
|
4711
|
+
if self._apply_api_token(self._load_token_from_disk()):
|
|
4663
4712
|
logger.info("[%s] Refreshed daemon token from ~/.forgexa/token", self.label)
|
|
4664
4713
|
return True
|
|
4665
|
-
|
|
4714
|
+
|
|
4715
|
+
if self.api_token.startswith("pat_"):
|
|
4716
|
+
return False
|
|
4717
|
+
|
|
4718
|
+
cfg = _load_cli_config()
|
|
4719
|
+
refresh_token = str(cfg.get("refresh_token") or "").strip()
|
|
4720
|
+
if not refresh_token:
|
|
4721
|
+
return False
|
|
4722
|
+
|
|
4723
|
+
try:
|
|
4724
|
+
resp = await self.client.post(
|
|
4725
|
+
f"{self.server_url}/api/v1/auth/refresh",
|
|
4726
|
+
json={"refresh_token": refresh_token},
|
|
4727
|
+
headers={"Content-Type": "application/json"},
|
|
4728
|
+
timeout=15,
|
|
4729
|
+
)
|
|
4730
|
+
except Exception as exc:
|
|
4731
|
+
logger.warning("[%s] Token refresh request failed: %s", self.label, exc)
|
|
4732
|
+
return False
|
|
4733
|
+
|
|
4734
|
+
if resp.status_code != 200:
|
|
4735
|
+
logger.warning(
|
|
4736
|
+
"[%s] Token refresh rejected: HTTP %s",
|
|
4737
|
+
self.label,
|
|
4738
|
+
resp.status_code,
|
|
4739
|
+
)
|
|
4740
|
+
return False
|
|
4741
|
+
|
|
4742
|
+
try:
|
|
4743
|
+
data = resp.json()
|
|
4744
|
+
except Exception as exc:
|
|
4745
|
+
logger.warning("[%s] Token refresh payload invalid: %s", self.label, exc)
|
|
4746
|
+
return False
|
|
4747
|
+
|
|
4748
|
+
access_token = str(data.get("access_token") or "").strip()
|
|
4749
|
+
next_refresh_token = str(data.get("refresh_token") or refresh_token).strip()
|
|
4750
|
+
if not access_token:
|
|
4751
|
+
logger.warning("[%s] Token refresh payload missing access_token", self.label)
|
|
4752
|
+
return False
|
|
4753
|
+
|
|
4754
|
+
self.api_token = access_token
|
|
4755
|
+
self.client.headers["Authorization"] = f"Bearer {access_token}"
|
|
4756
|
+
_save_cli_tokens(access_token, next_refresh_token or None)
|
|
4757
|
+
logger.info("[%s] Refreshed daemon token via /auth/refresh", self.label)
|
|
4758
|
+
return True
|
|
4666
4759
|
|
|
4667
4760
|
async def re_register(self, agents: list[DiscoveredAgent], max_concurrent: int):
|
|
4668
4761
|
"""Refresh token and re-register with the server.
|
|
@@ -4671,7 +4764,7 @@ class ServerConnection:
|
|
|
4671
4764
|
After re-registration, update the runtime_id on heartbeat/poller/reporter
|
|
4672
4765
|
in case the server assigned a different one.
|
|
4673
4766
|
"""
|
|
4674
|
-
self.
|
|
4767
|
+
await self.refresh_access_token()
|
|
4675
4768
|
try:
|
|
4676
4769
|
await self.register(agents, max_concurrent)
|
|
4677
4770
|
# Sync runtime_id to all services (may change after re-registration)
|
|
@@ -4701,8 +4794,8 @@ class ServerConnection:
|
|
|
4701
4794
|
}
|
|
4702
4795
|
for a in agents
|
|
4703
4796
|
]
|
|
4704
|
-
|
|
4705
|
-
|
|
4797
|
+
async def _register_once():
|
|
4798
|
+
return await self.client.post(
|
|
4706
4799
|
f"{self.server_url}/api/v1/runtimes/register",
|
|
4707
4800
|
json={
|
|
4708
4801
|
"daemon_id": self.daemon_id,
|
|
@@ -4722,6 +4815,11 @@ class ServerConnection:
|
|
|
4722
4815
|
},
|
|
4723
4816
|
timeout=15,
|
|
4724
4817
|
)
|
|
4818
|
+
|
|
4819
|
+
try:
|
|
4820
|
+
resp = await _register_once()
|
|
4821
|
+
if resp.status_code == 401 and await self.refresh_access_token():
|
|
4822
|
+
resp = await _register_once()
|
|
4725
4823
|
resp.raise_for_status()
|
|
4726
4824
|
data = resp.json()
|
|
4727
4825
|
self.runtime_id = data["runtime_id"]
|
|
@@ -5719,9 +5817,9 @@ class RuntimeDaemon:
|
|
|
5719
5817
|
result.status = "failed"
|
|
5720
5818
|
result.failure_code = "all_agents_rate_limited"
|
|
5721
5819
|
|
|
5722
|
-
# 4.55 Analysis/design nodes must update their deliverables in THIS run.
|
|
5820
|
+
# 4.55 Analysis/design/fix nodes must update their deliverables in THIS run.
|
|
5723
5821
|
# Existing files from a prior iteration are not sufficient evidence.
|
|
5724
|
-
if result.status == "success" and task.node_type in ("analysis", "design"):
|
|
5822
|
+
if result.status == "success" and task.node_type in ("analysis", "design", "fix"):
|
|
5725
5823
|
committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
|
|
5726
5824
|
git_check_passed = self.process_manager._has_required_deliverable_updates(
|
|
5727
5825
|
task,
|
|
@@ -5770,6 +5868,11 @@ class RuntimeDaemon:
|
|
|
5770
5868
|
if result.status == "success" and task.node_type == "design":
|
|
5771
5869
|
await self._collect_design_artifacts(workspace_path, task, result)
|
|
5772
5870
|
|
|
5871
|
+
# 4.8 For fix nodes: attach the bugfix report inline so knowledge
|
|
5872
|
+
# extraction can use the exact root-cause and verification text.
|
|
5873
|
+
if result.status == "success" and task.node_type == "fix":
|
|
5874
|
+
await self._collect_bugfix_artifacts(workspace_path, task, result)
|
|
5875
|
+
|
|
5773
5876
|
# 5. Auto-commit and push if changes exist
|
|
5774
5877
|
if result.status == "success":
|
|
5775
5878
|
commit_result = await self._auto_commit(workspace_path, task)
|
|
@@ -6547,6 +6650,34 @@ class RuntimeDaemon:
|
|
|
6547
6650
|
except Exception as e:
|
|
6548
6651
|
logger.warning("Failed to read design artifact: %s", e)
|
|
6549
6652
|
|
|
6653
|
+
async def _collect_bugfix_artifacts(
|
|
6654
|
+
self, workspace_path: Path, task: TaskInfo, result: TaskResult
|
|
6655
|
+
) -> None:
|
|
6656
|
+
"""Attach the bugfix markdown report as an inline artifact."""
|
|
6657
|
+
bugfix_doc_path = str((task.input_data or {}).get("bugfix_doc_path", "") or "")
|
|
6658
|
+
bugfix_doc_path = bugfix_doc_path.replace("\\", "/").lstrip("./")
|
|
6659
|
+
if not bugfix_doc_path:
|
|
6660
|
+
return
|
|
6661
|
+
|
|
6662
|
+
artifact_paths = {a.get("path", "").replace("\\", "/") for a in result.artifacts}
|
|
6663
|
+
if bugfix_doc_path in artifact_paths:
|
|
6664
|
+
return
|
|
6665
|
+
|
|
6666
|
+
full_path = workspace_path / bugfix_doc_path
|
|
6667
|
+
if not full_path.exists() or full_path.stat().st_size == 0:
|
|
6668
|
+
return
|
|
6669
|
+
|
|
6670
|
+
try:
|
|
6671
|
+
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
6672
|
+
result.artifacts.append({
|
|
6673
|
+
"path": bugfix_doc_path,
|
|
6674
|
+
"content": content,
|
|
6675
|
+
"type": "text/markdown",
|
|
6676
|
+
})
|
|
6677
|
+
logger.debug("Attached bugfix artifact inline: %s (%d bytes)", bugfix_doc_path, len(content))
|
|
6678
|
+
except Exception as e:
|
|
6679
|
+
logger.warning("Failed to read bugfix artifact %s: %s", bugfix_doc_path, e)
|
|
6680
|
+
|
|
6550
6681
|
async def _remove_root_scratch_files(
|
|
6551
6682
|
self, workspace_path: Path, task: TaskInfo, git_status_output: str
|
|
6552
6683
|
) -> None:
|
|
@@ -69,6 +69,29 @@ def _save_config(data: dict) -> None:
|
|
|
69
69
|
p.chmod(0o600)
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
def _save_tokens(
|
|
73
|
+
access_token: str,
|
|
74
|
+
refresh_token: str | None = None,
|
|
75
|
+
*,
|
|
76
|
+
server_url: str | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
cfg = _load_config()
|
|
79
|
+
if server_url:
|
|
80
|
+
cfg["server_url"] = server_url.rstrip("/")
|
|
81
|
+
cfg["token"] = access_token
|
|
82
|
+
if refresh_token:
|
|
83
|
+
cfg["refresh_token"] = refresh_token
|
|
84
|
+
else:
|
|
85
|
+
cfg.pop("refresh_token", None)
|
|
86
|
+
_save_config(cfg)
|
|
87
|
+
|
|
88
|
+
token_dir = Path.home() / ".forgexa"
|
|
89
|
+
token_dir.mkdir(exist_ok=True)
|
|
90
|
+
token_file = token_dir / "token"
|
|
91
|
+
token_file.write_text(access_token)
|
|
92
|
+
token_file.chmod(0o600)
|
|
93
|
+
|
|
94
|
+
|
|
72
95
|
def _api_url() -> str:
|
|
73
96
|
"""Resolve the server URL using priority chain."""
|
|
74
97
|
if _SERVER_URL_OVERRIDE:
|
|
@@ -93,70 +116,111 @@ def _token() -> str | None:
|
|
|
93
116
|
return cfg.get("token") or None
|
|
94
117
|
|
|
95
118
|
|
|
96
|
-
def
|
|
97
|
-
|
|
119
|
+
def _can_refresh_session() -> bool:
|
|
120
|
+
if os.environ.get("FORGEXA_TOKEN"):
|
|
121
|
+
return False
|
|
98
122
|
token = _token()
|
|
99
|
-
if token:
|
|
100
|
-
|
|
101
|
-
|
|
123
|
+
if not token or token.startswith("pat_"):
|
|
124
|
+
return False
|
|
125
|
+
cfg = _load_config()
|
|
126
|
+
return bool(str(cfg.get("refresh_token") or "").strip())
|
|
102
127
|
|
|
103
128
|
|
|
104
|
-
def
|
|
129
|
+
def _refresh_access_token() -> bool:
|
|
130
|
+
if not _can_refresh_session():
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
cfg = _load_config()
|
|
134
|
+
refresh_token = str(cfg.get("refresh_token") or "").strip()
|
|
135
|
+
if not refresh_token:
|
|
136
|
+
return False
|
|
137
|
+
|
|
105
138
|
import urllib.request
|
|
106
139
|
import urllib.error
|
|
107
140
|
|
|
108
|
-
url = f"{_api_url()}/api/v1
|
|
109
|
-
|
|
141
|
+
url = f"{_api_url()}/api/v1/auth/refresh"
|
|
142
|
+
body = json.dumps({"refresh_token": refresh_token}).encode()
|
|
143
|
+
req = urllib.request.Request(
|
|
144
|
+
url,
|
|
145
|
+
data=body,
|
|
146
|
+
headers={"Content-Type": "application/json"},
|
|
147
|
+
method="POST",
|
|
148
|
+
)
|
|
110
149
|
try:
|
|
111
150
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
112
|
-
|
|
113
|
-
except urllib.error.HTTPError
|
|
114
|
-
|
|
115
|
-
print(f"Error {e.code}: {body}", file=sys.stderr)
|
|
116
|
-
sys.exit(1)
|
|
117
|
-
except urllib.error.URLError as e:
|
|
118
|
-
print(f"Connection error: {e.reason}\nServer: {_api_url()}", file=sys.stderr)
|
|
119
|
-
sys.exit(1)
|
|
151
|
+
payload = json.loads(resp.read())
|
|
152
|
+
except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError, OSError):
|
|
153
|
+
return False
|
|
120
154
|
|
|
155
|
+
access_token = str(payload.get("access_token") or "").strip()
|
|
156
|
+
next_refresh_token = str(payload.get("refresh_token") or refresh_token).strip()
|
|
157
|
+
if not access_token:
|
|
158
|
+
return False
|
|
121
159
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
import urllib.error
|
|
160
|
+
_save_tokens(access_token, next_refresh_token or None)
|
|
161
|
+
return True
|
|
125
162
|
|
|
126
|
-
url = f"{_api_url()}/api/v1{path}"
|
|
127
|
-
body = json.dumps(data or {}).encode()
|
|
128
|
-
req = urllib.request.Request(url, data=body, headers=_headers(), method="POST")
|
|
129
|
-
try:
|
|
130
|
-
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
131
|
-
return json.loads(resp.read())
|
|
132
|
-
except urllib.error.HTTPError as e:
|
|
133
|
-
body_text = e.read().decode(errors="replace")
|
|
134
|
-
print(f"Error {e.code}: {body_text}", file=sys.stderr)
|
|
135
|
-
sys.exit(1)
|
|
136
|
-
except urllib.error.URLError as e:
|
|
137
|
-
print(f"Connection error: {e.reason}\nServer: {_api_url()}", file=sys.stderr)
|
|
138
|
-
sys.exit(1)
|
|
139
163
|
|
|
164
|
+
def _headers() -> dict[str, str]:
|
|
165
|
+
h: dict[str, str] = {"Content-Type": "application/json"}
|
|
166
|
+
token = _token()
|
|
167
|
+
if token:
|
|
168
|
+
h["Authorization"] = f"Bearer {token}"
|
|
169
|
+
return h
|
|
140
170
|
|
|
141
|
-
|
|
171
|
+
|
|
172
|
+
def _request_json(
|
|
173
|
+
path: str,
|
|
174
|
+
*,
|
|
175
|
+
method: str = "GET",
|
|
176
|
+
data: dict | None = None,
|
|
177
|
+
timeout: int = 15,
|
|
178
|
+
allow_refresh: bool = True,
|
|
179
|
+
) -> dict | list | None:
|
|
142
180
|
import urllib.request
|
|
143
181
|
import urllib.error
|
|
144
182
|
|
|
145
183
|
url = f"{_api_url()}/api/v1{path}"
|
|
146
|
-
|
|
184
|
+
body = json.dumps(data).encode() if data is not None else None
|
|
185
|
+
req = urllib.request.Request(url, data=body, headers=_headers(), method=method)
|
|
147
186
|
try:
|
|
148
|
-
with urllib.request.urlopen(req, timeout=
|
|
187
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
149
188
|
content = resp.read()
|
|
150
189
|
return json.loads(content) if content else None
|
|
151
190
|
except urllib.error.HTTPError as e:
|
|
152
|
-
|
|
153
|
-
|
|
191
|
+
body_text = e.read().decode(errors="replace")
|
|
192
|
+
if e.code == 401 and allow_refresh and _refresh_access_token():
|
|
193
|
+
return _request_json(
|
|
194
|
+
path,
|
|
195
|
+
method=method,
|
|
196
|
+
data=data,
|
|
197
|
+
timeout=timeout,
|
|
198
|
+
allow_refresh=False,
|
|
199
|
+
)
|
|
200
|
+
print(f"Error {e.code}: {body_text}", file=sys.stderr)
|
|
154
201
|
sys.exit(1)
|
|
155
202
|
except urllib.error.URLError as e:
|
|
156
203
|
print(f"Connection error: {e.reason}\nServer: {_api_url()}", file=sys.stderr)
|
|
157
204
|
sys.exit(1)
|
|
158
205
|
|
|
159
206
|
|
|
207
|
+
def _get(path: str) -> dict | list:
|
|
208
|
+
result = _request_json(path, method="GET", timeout=15)
|
|
209
|
+
if result is None:
|
|
210
|
+
return {}
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _post(path: str, data: dict | None = None) -> dict:
|
|
215
|
+
result = _request_json(path, method="POST", data=data or {}, timeout=30)
|
|
216
|
+
return result if isinstance(result, dict) else {}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _delete(path: str) -> dict | None:
|
|
220
|
+
result = _request_json(path, method="DELETE", timeout=15)
|
|
221
|
+
return result if isinstance(result, dict) else None
|
|
222
|
+
|
|
223
|
+
|
|
160
224
|
# ── Output helpers ──
|
|
161
225
|
|
|
162
226
|
_output_format = "table"
|
|
@@ -203,20 +267,9 @@ def cmd_login(args: argparse.Namespace) -> None:
|
|
|
203
267
|
email = args.email or input("Email: ")
|
|
204
268
|
password = args.password or getpass.getpass("Password: ")
|
|
205
269
|
result = _post("/auth/login", {"email": email, "password": password})
|
|
206
|
-
token = result.get("access_token"
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
cfg = _load_config()
|
|
210
|
-
if server:
|
|
211
|
-
cfg["server_url"] = _SERVER_URL_OVERRIDE
|
|
212
|
-
cfg["token"] = token
|
|
213
|
-
_save_config(cfg)
|
|
214
|
-
|
|
215
|
-
# Also keep the legacy token file for backwards compatibility
|
|
216
|
-
token_dir = Path.home() / ".forgexa"
|
|
217
|
-
token_dir.mkdir(exist_ok=True)
|
|
218
|
-
(token_dir / "token").write_text(token)
|
|
219
|
-
(token_dir / "token").chmod(0o600)
|
|
270
|
+
token = str(result.get("access_token") or "").strip()
|
|
271
|
+
refresh_token = str(result.get("refresh_token") or "").strip()
|
|
272
|
+
_save_tokens(token, refresh_token or None, server_url=_SERVER_URL_OVERRIDE if server else None)
|
|
220
273
|
|
|
221
274
|
active_server = _api_url()
|
|
222
275
|
print(f"Login successful.")
|
|
@@ -233,10 +286,11 @@ def cmd_logout(_args: argparse.Namespace) -> None:
|
|
|
233
286
|
cfg = _load_config()
|
|
234
287
|
if "token" in cfg:
|
|
235
288
|
del cfg["token"]
|
|
289
|
+
cfg.pop("refresh_token", None)
|
|
236
290
|
_save_config(cfg)
|
|
237
291
|
cleared = True
|
|
238
292
|
if cleared:
|
|
239
|
-
print("Logged out.
|
|
293
|
+
print("Logged out. Tokens removed.")
|
|
240
294
|
else:
|
|
241
295
|
print("No token found.")
|
|
242
296
|
|
|
@@ -255,6 +309,7 @@ def cmd_config_show(_args: argparse.Namespace) -> None:
|
|
|
255
309
|
source = f"~/.forgexa/config"
|
|
256
310
|
print(f"Server URL : {active_url} (source: {source})")
|
|
257
311
|
print(f"Auth token : {'set' if token else 'not set'}")
|
|
312
|
+
print(f"Refresh tok: {'set' if cfg.get('refresh_token') else 'not set'}")
|
|
258
313
|
print(f"Config file: {_config_path()}")
|
|
259
314
|
|
|
260
315
|
|
|
@@ -273,8 +328,14 @@ def cmd_config_set(args: argparse.Namespace) -> None:
|
|
|
273
328
|
sys.exit(1)
|
|
274
329
|
|
|
275
330
|
|
|
276
|
-
def
|
|
277
|
-
|
|
331
|
+
def _get_runtimes(include_all: bool = False) -> list[dict]:
|
|
332
|
+
path = "/runtimes" if include_all else "/runtimes/me"
|
|
333
|
+
runtimes = _get(path)
|
|
334
|
+
return runtimes if isinstance(runtimes, list) else []
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def cmd_daemon_status(args: argparse.Namespace) -> None:
|
|
338
|
+
runtimes = _get_runtimes(include_all=getattr(args, "all", False))
|
|
278
339
|
if not runtimes:
|
|
279
340
|
print("No daemons registered.")
|
|
280
341
|
return
|
|
@@ -363,8 +424,8 @@ def cmd_daemon_start(args: argparse.Namespace) -> None:
|
|
|
363
424
|
main_sync()
|
|
364
425
|
|
|
365
426
|
|
|
366
|
-
def cmd_runtimes_list(
|
|
367
|
-
runtimes =
|
|
427
|
+
def cmd_runtimes_list(args: argparse.Namespace) -> None:
|
|
428
|
+
runtimes = _get_runtimes(include_all=getattr(args, "all", False))
|
|
368
429
|
if not runtimes:
|
|
369
430
|
print("No runtimes registered.")
|
|
370
431
|
return
|
|
@@ -596,11 +657,11 @@ def main() -> None:
|
|
|
596
657
|
sub.add_parser("version", help="Show CLI version")
|
|
597
658
|
|
|
598
659
|
# auth
|
|
599
|
-
login_p = sub.add_parser("login", help="Login and save
|
|
660
|
+
login_p = sub.add_parser("login", help="Login and save session tokens")
|
|
600
661
|
login_p.add_argument("--server", metavar="URL", help="Server URL to connect to (saved to ~/.forgexa/config)")
|
|
601
662
|
login_p.add_argument("--email", help="Email address")
|
|
602
663
|
login_p.add_argument("--password", help="Password")
|
|
603
|
-
sub.add_parser("logout", help="Remove saved
|
|
664
|
+
sub.add_parser("logout", help="Remove saved session tokens")
|
|
604
665
|
|
|
605
666
|
# config
|
|
606
667
|
config_p = sub.add_parser("config", help="View or change CLI configuration")
|
|
@@ -616,13 +677,15 @@ def main() -> None:
|
|
|
616
677
|
daemon_start_p = daemon_sub.add_parser("start", help="Start local daemon (discovers and registers AI agents)")
|
|
617
678
|
daemon_start_p.add_argument("-d", "--detach", action="store_true", help="Run in background")
|
|
618
679
|
daemon_start_p.add_argument("--server-url", default=None, help="Server URL to connect to")
|
|
619
|
-
daemon_sub.add_parser("status", help="Show
|
|
680
|
+
daemon_status_p = daemon_sub.add_parser("status", help="Show your daemon statuses (from server)")
|
|
681
|
+
daemon_status_p.add_argument("--all", action="store_true", help="List all runtimes (platform admin only)")
|
|
620
682
|
daemon_sub.add_parser("stop", help="Stop local daemon (sends SIGTERM)")
|
|
621
683
|
|
|
622
684
|
# runtimes
|
|
623
685
|
rt_p = sub.add_parser("runtimes", help="Runtime management")
|
|
624
686
|
rt_sub = rt_p.add_subparsers(dest="rt_cmd")
|
|
625
|
-
rt_sub.add_parser("list", help="List
|
|
687
|
+
rt_list_p = rt_sub.add_parser("list", help="List your runtimes")
|
|
688
|
+
rt_list_p.add_argument("--all", action="store_true", help="List all runtimes (platform admin only)")
|
|
626
689
|
|
|
627
690
|
# workspace
|
|
628
691
|
ws_p = sub.add_parser("workspace", help="Workspace management")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: forgexa-cli
|
|
3
|
-
Version: 1.11.
|
|
3
|
+
Version: 1.11.4
|
|
4
4
|
Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
|
|
5
5
|
Author-email: Jason Sun <dev.winds@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -100,9 +100,9 @@ forgexa board --project <project-id>
|
|
|
100
100
|
| `forgexa budget --workspace <id>` | Budget overview |
|
|
101
101
|
| `forgexa daemon start` | Start local daemon (discover agents, run tasks) |
|
|
102
102
|
| `forgexa daemon start -d` | Start daemon in background |
|
|
103
|
-
| `forgexa daemon status` | Show daemon statuses |
|
|
103
|
+
| `forgexa daemon status` | Show your daemon statuses |
|
|
104
104
|
| `forgexa daemon stop` | Stop local daemon |
|
|
105
|
-
| `forgexa runtimes list` | List runtimes |
|
|
105
|
+
| `forgexa runtimes list` | List your runtimes |
|
|
106
106
|
| `forgexa version` | Show CLI version |
|
|
107
107
|
|
|
108
108
|
## Configuration
|
|
@@ -144,11 +144,20 @@ forgexa-daemon
|
|
|
144
144
|
### Other Daemon Commands
|
|
145
145
|
|
|
146
146
|
```bash
|
|
147
|
-
# Check daemon status (from server)
|
|
147
|
+
# Check your daemon status (from server)
|
|
148
148
|
forgexa daemon status
|
|
149
149
|
|
|
150
|
+
# Platform admin: list all runtimes
|
|
151
|
+
forgexa daemon status --all
|
|
152
|
+
|
|
150
153
|
# Stop background daemon
|
|
151
154
|
forgexa daemon stop
|
|
155
|
+
|
|
156
|
+
# List your runtimes
|
|
157
|
+
forgexa runtimes list
|
|
158
|
+
|
|
159
|
+
# Platform admin: list all runtimes
|
|
160
|
+
forgexa runtimes list --all
|
|
152
161
|
```
|
|
153
162
|
|
|
154
163
|
### Supported AI Agents
|
|
@@ -10,4 +10,5 @@ forgexa_cli.egg-info/SOURCES.txt
|
|
|
10
10
|
forgexa_cli.egg-info/dependency_links.txt
|
|
11
11
|
forgexa_cli.egg-info/entry_points.txt
|
|
12
12
|
forgexa_cli.egg-info/requires.txt
|
|
13
|
-
forgexa_cli.egg-info/top_level.txt
|
|
13
|
+
forgexa_cli.egg-info/top_level.txt
|
|
14
|
+
tests/test_auth_and_runtime_commands.py
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
import urllib.error
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from forgexa_cli import daemon, main
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UrlopenResponse:
|
|
16
|
+
def __init__(self, payload):
|
|
17
|
+
self._payload = payload
|
|
18
|
+
|
|
19
|
+
def read(self) -> bytes:
|
|
20
|
+
return json.dumps(self._payload).encode("utf-8")
|
|
21
|
+
|
|
22
|
+
def __enter__(self):
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
def __exit__(self, exc_type, exc, tb):
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HttpxResponse:
|
|
30
|
+
def __init__(self, status_code: int, payload: dict):
|
|
31
|
+
self.status_code = status_code
|
|
32
|
+
self._payload = payload
|
|
33
|
+
self.request = httpx.Request("POST", "https://api.example.com/api/v1/runtimes/register")
|
|
34
|
+
|
|
35
|
+
def json(self) -> dict:
|
|
36
|
+
return self._payload
|
|
37
|
+
|
|
38
|
+
def raise_for_status(self) -> None:
|
|
39
|
+
if self.status_code >= 400:
|
|
40
|
+
raise httpx.HTTPStatusError(
|
|
41
|
+
f"HTTP {self.status_code}",
|
|
42
|
+
request=self.request,
|
|
43
|
+
response=self,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _http_error(url: str, status_code: int, payload: dict) -> urllib.error.HTTPError:
|
|
48
|
+
return urllib.error.HTTPError(
|
|
49
|
+
url,
|
|
50
|
+
status_code,
|
|
51
|
+
payload.get("detail", "error"),
|
|
52
|
+
hdrs=None,
|
|
53
|
+
fp=io.BytesIO(json.dumps(payload).encode("utf-8")),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_login_persists_refresh_token(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
58
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
59
|
+
monkeypatch.setattr(main, "_SERVER_URL_OVERRIDE", None)
|
|
60
|
+
monkeypatch.setattr(
|
|
61
|
+
main,
|
|
62
|
+
"_post",
|
|
63
|
+
lambda path, data=None: {
|
|
64
|
+
"access_token": "access-1",
|
|
65
|
+
"refresh_token": "refresh-1",
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
args = SimpleNamespace(
|
|
70
|
+
server="https://api.example.com",
|
|
71
|
+
email="user@example.com",
|
|
72
|
+
password="secret",
|
|
73
|
+
)
|
|
74
|
+
main.cmd_login(args)
|
|
75
|
+
|
|
76
|
+
cfg = json.loads((tmp_path / ".forgexa" / "config").read_text())
|
|
77
|
+
assert cfg["token"] == "access-1"
|
|
78
|
+
assert cfg["refresh_token"] == "refresh-1"
|
|
79
|
+
assert cfg["server_url"] == "https://api.example.com"
|
|
80
|
+
assert (tmp_path / ".forgexa" / "token").read_text() == "access-1"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_get_retries_once_after_refresh_on_401(
|
|
84
|
+
tmp_path: Path,
|
|
85
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
86
|
+
) -> None:
|
|
87
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
88
|
+
monkeypatch.setattr(main, "_SERVER_URL_OVERRIDE", None)
|
|
89
|
+
config_dir = tmp_path / ".forgexa"
|
|
90
|
+
config_dir.mkdir()
|
|
91
|
+
(config_dir / "config").write_text(
|
|
92
|
+
json.dumps(
|
|
93
|
+
{
|
|
94
|
+
"server_url": "https://api.example.com",
|
|
95
|
+
"token": "old-access",
|
|
96
|
+
"refresh_token": "refresh-1",
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
(config_dir / "token").write_text("old-access")
|
|
101
|
+
|
|
102
|
+
import urllib.request
|
|
103
|
+
|
|
104
|
+
runtime_called = {"count": 0}
|
|
105
|
+
|
|
106
|
+
def fake_urlopen(req, timeout=0):
|
|
107
|
+
url = req.full_url
|
|
108
|
+
if url.endswith("/api/v1/runtimes/me"):
|
|
109
|
+
runtime_called["count"] += 1
|
|
110
|
+
if runtime_called["count"] == 1:
|
|
111
|
+
raise _http_error(url, 401, {"detail": "Invalid token"})
|
|
112
|
+
assert req.headers.get("Authorization") == "Bearer new-access"
|
|
113
|
+
return UrlopenResponse([
|
|
114
|
+
{
|
|
115
|
+
"id": "12345678",
|
|
116
|
+
"daemon_id": "demo-daemon",
|
|
117
|
+
"status": "online",
|
|
118
|
+
}
|
|
119
|
+
])
|
|
120
|
+
if url.endswith("/api/v1/auth/refresh"):
|
|
121
|
+
assert json.loads(req.data.decode("utf-8")) == {"refresh_token": "refresh-1"}
|
|
122
|
+
return UrlopenResponse(
|
|
123
|
+
{
|
|
124
|
+
"access_token": "new-access",
|
|
125
|
+
"refresh_token": "refresh-2",
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
raise AssertionError(f"Unexpected URL: {url}")
|
|
129
|
+
|
|
130
|
+
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
|
131
|
+
|
|
132
|
+
result = main._get("/runtimes/me")
|
|
133
|
+
|
|
134
|
+
assert isinstance(result, list)
|
|
135
|
+
assert runtime_called["count"] == 2
|
|
136
|
+
cfg = json.loads((config_dir / "config").read_text())
|
|
137
|
+
assert cfg["token"] == "new-access"
|
|
138
|
+
assert cfg["refresh_token"] == "refresh-2"
|
|
139
|
+
assert (config_dir / "token").read_text() == "new-access"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_runtime_commands_use_user_scope_by_default(
|
|
143
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
144
|
+
) -> None:
|
|
145
|
+
calls: list[bool] = []
|
|
146
|
+
monkeypatch.setattr(
|
|
147
|
+
main,
|
|
148
|
+
"_get_runtimes",
|
|
149
|
+
lambda include_all=False: calls.append(include_all) or [],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
main.cmd_daemon_status(SimpleNamespace(all=False))
|
|
153
|
+
main.cmd_runtimes_list(SimpleNamespace(all=False))
|
|
154
|
+
main.cmd_runtimes_list(SimpleNamespace(all=True))
|
|
155
|
+
|
|
156
|
+
assert calls == [False, False, True]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@pytest.mark.asyncio
|
|
160
|
+
async def test_daemon_refreshes_access_token_from_refresh_token(
|
|
161
|
+
tmp_path: Path,
|
|
162
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
163
|
+
) -> None:
|
|
164
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
165
|
+
config_dir = tmp_path / ".forgexa"
|
|
166
|
+
config_dir.mkdir()
|
|
167
|
+
(config_dir / "config").write_text(
|
|
168
|
+
json.dumps(
|
|
169
|
+
{
|
|
170
|
+
"token": "stale-access",
|
|
171
|
+
"refresh_token": "refresh-1",
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
(config_dir / "token").write_text("stale-access")
|
|
176
|
+
|
|
177
|
+
conn = daemon.ServerConnection("https://api.example.com", "stale-access", "daemon-1")
|
|
178
|
+
try:
|
|
179
|
+
async def fake_post(url, json=None, headers=None, timeout=None):
|
|
180
|
+
assert url.endswith("/api/v1/auth/refresh")
|
|
181
|
+
assert json == {"refresh_token": "refresh-1"}
|
|
182
|
+
return HttpxResponse(
|
|
183
|
+
200,
|
|
184
|
+
{
|
|
185
|
+
"access_token": "fresh-access",
|
|
186
|
+
"refresh_token": "refresh-2",
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
monkeypatch.setattr(conn.client, "post", fake_post)
|
|
191
|
+
|
|
192
|
+
refreshed = await conn.refresh_access_token()
|
|
193
|
+
|
|
194
|
+
assert refreshed is True
|
|
195
|
+
assert conn.api_token == "fresh-access"
|
|
196
|
+
assert conn.client.headers["Authorization"] == "Bearer fresh-access"
|
|
197
|
+
cfg = json.loads((config_dir / "config").read_text())
|
|
198
|
+
assert cfg["token"] == "fresh-access"
|
|
199
|
+
assert cfg["refresh_token"] == "refresh-2"
|
|
200
|
+
assert (config_dir / "token").read_text() == "fresh-access"
|
|
201
|
+
finally:
|
|
202
|
+
await conn.client.aclose()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@pytest.mark.asyncio
|
|
206
|
+
async def test_daemon_register_retries_once_after_refresh(
|
|
207
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
208
|
+
) -> None:
|
|
209
|
+
conn = daemon.ServerConnection("https://api.example.com", "stale-access", "daemon-1")
|
|
210
|
+
try:
|
|
211
|
+
async def fake_refresh_access_token() -> bool:
|
|
212
|
+
conn.api_token = "fresh-access"
|
|
213
|
+
conn.client.headers["Authorization"] = "Bearer fresh-access"
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
responses = iter(
|
|
217
|
+
[
|
|
218
|
+
HttpxResponse(401, {"detail": "Invalid token"}),
|
|
219
|
+
HttpxResponse(200, {"runtime_id": "runtime-1"}),
|
|
220
|
+
]
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
async def fake_post(url, json=None, headers=None, timeout=None):
|
|
224
|
+
response = next(responses)
|
|
225
|
+
if response.status_code == 200:
|
|
226
|
+
assert json["api_token"] == "fresh-access"
|
|
227
|
+
return response
|
|
228
|
+
|
|
229
|
+
monkeypatch.setattr(conn, "refresh_access_token", fake_refresh_access_token)
|
|
230
|
+
monkeypatch.setattr(conn.client, "post", fake_post)
|
|
231
|
+
|
|
232
|
+
await conn.register([], 2)
|
|
233
|
+
|
|
234
|
+
assert conn.runtime_id == "runtime-1"
|
|
235
|
+
finally:
|
|
236
|
+
await conn.client.aclose()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|