forgexa-cli 1.14.10__tar.gz → 1.15.0__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.14.10 → forgexa_cli-1.15.0}/PKG-INFO +1 -1
- {forgexa_cli-1.14.10 → forgexa_cli-1.15.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.14.10 → forgexa_cli-1.15.0}/forgexa_cli/daemon.py +266 -31
- {forgexa_cli-1.14.10 → forgexa_cli-1.15.0}/forgexa_cli/main.py +112 -6
- {forgexa_cli-1.14.10 → forgexa_cli-1.15.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.14.10 → forgexa_cli-1.15.0}/pyproject.toml +1 -1
- {forgexa_cli-1.14.10 → forgexa_cli-1.15.0}/tests/test_auth_and_runtime_commands.py +56 -56
- {forgexa_cli-1.14.10 → forgexa_cli-1.15.0}/README.md +0 -0
- {forgexa_cli-1.14.10 → forgexa_cli-1.15.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.14.10 → forgexa_cli-1.15.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.14.10 → forgexa_cli-1.15.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.14.10 → forgexa_cli-1.15.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.14.10 → forgexa_cli-1.15.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.14.10 → forgexa_cli-1.15.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.14.10 → forgexa_cli-1.15.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.14.10 → forgexa_cli-1.15.0}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.15.0"
|
|
@@ -523,7 +523,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
523
523
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
524
524
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
525
525
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
526
|
-
DAEMON_VERSION = "1.
|
|
526
|
+
DAEMON_VERSION = "1.15.0"
|
|
527
527
|
|
|
528
528
|
|
|
529
529
|
def _detect_client_type() -> str:
|
|
@@ -939,6 +939,27 @@ def _daemon_extract_claude_output(raw: str) -> str:
|
|
|
939
939
|
return raw
|
|
940
940
|
|
|
941
941
|
|
|
942
|
+
# OpenCode requires v1.4.0+ for --dangerously-skip-permissions (added 2026-04-08).
|
|
943
|
+
# Without it, headless opencode run blocks on permission prompts and exits with code 1.
|
|
944
|
+
_OPENCODE_MIN_VERSION = (1, 4, 0)
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _parse_semver(v: str) -> "tuple[int, int, int] | None":
|
|
948
|
+
"""Parse a semver string into a comparable (major, minor, patch) tuple.
|
|
949
|
+
|
|
950
|
+
Strips a leading 'v' and ignores pre-release/build suffixes so that
|
|
951
|
+
'1.4.0-beta.1' and 'v1.4.0' both return (1, 4, 0).
|
|
952
|
+
Returns None when the string cannot be parsed (e.g. 'unknown', 'latest'),
|
|
953
|
+
so callers can skip the version check rather than erroneously rejecting
|
|
954
|
+
a valid agent whose version string is non-standard.
|
|
955
|
+
"""
|
|
956
|
+
import re as _re
|
|
957
|
+
m = _re.match(r"^v?(\d+)\.(\d+)\.(\d+)", v.strip())
|
|
958
|
+
if m:
|
|
959
|
+
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
|
960
|
+
return None
|
|
961
|
+
|
|
962
|
+
|
|
942
963
|
def _daemon_extract_opencode_output(raw: str) -> str:
|
|
943
964
|
"""Extract text content from OpenCode JSONL output."""
|
|
944
965
|
parts: list[str] = []
|
|
@@ -1239,18 +1260,18 @@ def _get_design_output_for_type(req_type: str) -> str:
|
|
|
1239
1260
|
Used by _validate_outputs and _required_deliverable_paths to avoid
|
|
1240
1261
|
hardcoding 'design.md' for all node_type='design' nodes — spike produces
|
|
1241
1262
|
'research.md' instead.
|
|
1263
|
+
|
|
1264
|
+
Reads the explicit `output_filename` field on the PhaseConfig dataclass
|
|
1265
|
+
(set in type_workflow_profiles.py) rather than using a fragile regex over
|
|
1266
|
+
the prompt template text.
|
|
1242
1267
|
"""
|
|
1243
1268
|
try:
|
|
1244
1269
|
from app.services.type_workflow_profiles import get_profile
|
|
1245
1270
|
profile = get_profile(req_type)
|
|
1246
1271
|
for phase in profile.execution_phases:
|
|
1247
1272
|
if phase.node_type == "design":
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
import re as _re
|
|
1251
|
-
m = _re.search(r"{doc_dir}/([\w.-]+)", tmpl)
|
|
1252
|
-
if m:
|
|
1253
|
-
return m.group(1)
|
|
1273
|
+
if phase.output_filename:
|
|
1274
|
+
return phase.output_filename
|
|
1254
1275
|
break
|
|
1255
1276
|
except Exception:
|
|
1256
1277
|
pass
|
|
@@ -1735,18 +1756,30 @@ class WorkspaceManager:
|
|
|
1735
1756
|
"""
|
|
1736
1757
|
import shutil
|
|
1737
1758
|
import stat as _stat
|
|
1759
|
+
import sys as _sys
|
|
1738
1760
|
|
|
1739
1761
|
if not path.exists():
|
|
1740
1762
|
return
|
|
1741
1763
|
|
|
1742
1764
|
def _handle_readonly(func, entry_path, excinfo):
|
|
1743
1765
|
try:
|
|
1744
|
-
os.chmod(entry_path, _stat.S_IWRITE | _stat.S_IREAD | _stat.
|
|
1766
|
+
os.chmod(entry_path, _stat.S_IWRITE | _stat.S_IREAD | _stat.S_IEXEC)
|
|
1745
1767
|
func(entry_path)
|
|
1746
|
-
except Exception:
|
|
1747
|
-
|
|
1768
|
+
except Exception as _chmod_err:
|
|
1769
|
+
logger.debug(
|
|
1770
|
+
"_safe_rmtree: failed to force-remove %s: %s",
|
|
1771
|
+
entry_path, _chmod_err,
|
|
1772
|
+
)
|
|
1748
1773
|
|
|
1749
|
-
|
|
1774
|
+
# Python 3.12 deprecated onerror= in favour of onexc=.
|
|
1775
|
+
# The signatures differ: onerror gets (exc_type, exc_val, tb),
|
|
1776
|
+
# onexc gets (func, path, exc_instance).
|
|
1777
|
+
if _sys.version_info >= (3, 12):
|
|
1778
|
+
def _onexc(func, p, exc):
|
|
1779
|
+
_handle_readonly(func, p, (type(exc), exc, exc.__traceback__))
|
|
1780
|
+
shutil.rmtree(path, onexc=_onexc)
|
|
1781
|
+
else:
|
|
1782
|
+
shutil.rmtree(path, onerror=_handle_readonly)
|
|
1750
1783
|
|
|
1751
1784
|
# Windows fallback: reserved device names survive shutil.rmtree.
|
|
1752
1785
|
# cmd /c rmdir /s /q with the \\?\ extended-length prefix bypasses
|
|
@@ -1755,16 +1788,19 @@ class WorkspaceManager:
|
|
|
1755
1788
|
try:
|
|
1756
1789
|
import subprocess
|
|
1757
1790
|
resolved = str(path.resolve())
|
|
1758
|
-
#
|
|
1759
|
-
#
|
|
1760
|
-
#
|
|
1761
|
-
if
|
|
1791
|
+
# Build the correct extended-length prefix:
|
|
1792
|
+
# UNC paths (\\server\share) → \\?\UNC\server\share
|
|
1793
|
+
# Regular paths → \\?\C:\path
|
|
1794
|
+
if resolved.startswith("\\\\"):
|
|
1795
|
+
unc = "\\\\?\\UNC\\" + resolved[2:]
|
|
1796
|
+
elif not resolved.startswith("\\\\?\\"):
|
|
1762
1797
|
unc = "\\\\?\\" + resolved
|
|
1763
1798
|
else:
|
|
1764
1799
|
unc = resolved
|
|
1765
1800
|
result = subprocess.run(
|
|
1766
1801
|
["cmd", "/c", "rmdir", "/s", "/q", unc],
|
|
1767
1802
|
capture_output=True,
|
|
1803
|
+
stdin=subprocess.DEVNULL,
|
|
1768
1804
|
timeout=60,
|
|
1769
1805
|
)
|
|
1770
1806
|
if result.returncode != 0 and path.exists():
|
|
@@ -2279,6 +2315,14 @@ class WorkspaceManager:
|
|
|
2279
2315
|
"revert(analysis",
|
|
2280
2316
|
"initial commit",
|
|
2281
2317
|
)
|
|
2318
|
+
# These look like docs/* but contain actual design/architecture work —
|
|
2319
|
+
# treat them as implementation commits so fresh-start never discards them.
|
|
2320
|
+
_IMPL_DOCS_PREFIXES = (
|
|
2321
|
+
"docs(design",
|
|
2322
|
+
"docs(rfc",
|
|
2323
|
+
"docs(architecture",
|
|
2324
|
+
"docs(spec",
|
|
2325
|
+
)
|
|
2282
2326
|
try:
|
|
2283
2327
|
raw = await self._git(
|
|
2284
2328
|
"log", "--format=%s",
|
|
@@ -2295,6 +2339,11 @@ class WorkspaceManager:
|
|
|
2295
2339
|
|
|
2296
2340
|
for subject in subjects:
|
|
2297
2341
|
lower = subject.lower()
|
|
2342
|
+
# First check: is it an explicit design/rfc/architecture doc commit?
|
|
2343
|
+
# These look like "docs(design)" but carry real design artifacts and
|
|
2344
|
+
# must never be discarded on a fresh-start reset.
|
|
2345
|
+
if any(lower.startswith(p) for p in _IMPL_DOCS_PREFIXES):
|
|
2346
|
+
return True # Design/architecture commit — treat as implementation
|
|
2298
2347
|
if not any(lower.startswith(p) for p in _ANALYSIS_ONLY_PREFIXES):
|
|
2299
2348
|
return True # Found at least one implementation commit
|
|
2300
2349
|
|
|
@@ -2543,6 +2592,22 @@ class WorkspaceManager:
|
|
|
2543
2592
|
|
|
2544
2593
|
if fresh_start:
|
|
2545
2594
|
logger.info("Fresh start: resetting %s to origin/%s", ws_path, default_branch)
|
|
2595
|
+
# Safety: create a backup tag before resetting, so commits can
|
|
2596
|
+
# be recovered even if the remote branch was deleted and the
|
|
2597
|
+
# _branch_has_impl_commits heuristic misfired.
|
|
2598
|
+
import time as _time
|
|
2599
|
+
_backup_tag = f"backup/pre-fresh-start-{int(_time.time())}"
|
|
2600
|
+
try:
|
|
2601
|
+
await self._git("tag", _backup_tag, "HEAD", cwd=ws_path)
|
|
2602
|
+
logger.info(
|
|
2603
|
+
"Created safety tag %s before fresh re-analysis reset on %s",
|
|
2604
|
+
_backup_tag, branch_name,
|
|
2605
|
+
)
|
|
2606
|
+
except RuntimeError as _tag_err:
|
|
2607
|
+
logger.warning(
|
|
2608
|
+
"Could not create backup tag %s before fresh-start reset: %s",
|
|
2609
|
+
_backup_tag, _tag_err,
|
|
2610
|
+
)
|
|
2546
2611
|
# Use checkout -B to create or reset the branch to origin/default_branch.
|
|
2547
2612
|
# Plain `checkout branch_name` fails when the branch doesn't exist locally.
|
|
2548
2613
|
try:
|
|
@@ -3109,7 +3174,17 @@ class WorkspaceManager:
|
|
|
3109
3174
|
except Exception:
|
|
3110
3175
|
pass
|
|
3111
3176
|
else:
|
|
3112
|
-
|
|
3177
|
+
# Use a persistent per-daemon known_hosts file under ~/.forgexa.
|
|
3178
|
+
# This provides TOFU (Trust On First Use) protection: the first
|
|
3179
|
+
# connection stores the host key; subsequent connections verify it.
|
|
3180
|
+
# Using /dev/null discards all host keys on every git operation,
|
|
3181
|
+
# leaving every connection open to MITM attacks.
|
|
3182
|
+
_kh_dir = Path.home() / ".forgexa"
|
|
3183
|
+
try:
|
|
3184
|
+
_kh_dir.mkdir(parents=True, exist_ok=True)
|
|
3185
|
+
except OSError:
|
|
3186
|
+
pass
|
|
3187
|
+
_known_hosts_null = str(_kh_dir / "known_hosts")
|
|
3113
3188
|
env = {
|
|
3114
3189
|
**os.environ,
|
|
3115
3190
|
"GIT_SSH_COMMAND": (
|
|
@@ -3169,6 +3244,7 @@ class WorkspaceManager:
|
|
|
3169
3244
|
# we use taskkill /T there instead of killpg.
|
|
3170
3245
|
proc = await asyncio.create_subprocess_exec(
|
|
3171
3246
|
"git", *longpath_args, *git_prefix_args, *args,
|
|
3247
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
3172
3248
|
stdout=asyncio.subprocess.PIPE,
|
|
3173
3249
|
stderr=asyncio.subprocess.PIPE,
|
|
3174
3250
|
cwd=str(cwd) if cwd else None,
|
|
@@ -4155,7 +4231,12 @@ class ProcessManager:
|
|
|
4155
4231
|
model_override = os.environ.get("FACTORY_CLAUDE_MODEL")
|
|
4156
4232
|
if model_override:
|
|
4157
4233
|
env["ANTHROPIC_MODEL"] = model_override
|
|
4158
|
-
|
|
4234
|
+
|
|
4235
|
+
# Per-task isolated Claude home prevents cross-task state leakage
|
|
4236
|
+
# (cache/XDG data, session state) between concurrent task executions.
|
|
4237
|
+
import tempfile
|
|
4238
|
+
_claude_home = tempfile.mkdtemp(prefix=f"claude-{task_id[:8]}-")
|
|
4239
|
+
env.update(self._prepare_claude_environment(sandbox_root=Path(_claude_home)))
|
|
4159
4240
|
|
|
4160
4241
|
try:
|
|
4161
4242
|
proc = await asyncio.create_subprocess_exec(
|
|
@@ -4243,6 +4324,8 @@ class ProcessManager:
|
|
|
4243
4324
|
)
|
|
4244
4325
|
finally:
|
|
4245
4326
|
self.active_processes.pop(task_id, None)
|
|
4327
|
+
# Clean up per-task isolated Claude home (suppress errors — best-effort)
|
|
4328
|
+
WorkspaceManager._safe_rmtree(Path(_claude_home))
|
|
4246
4329
|
|
|
4247
4330
|
async def _run_codex(
|
|
4248
4331
|
self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
|
|
@@ -4318,6 +4401,35 @@ class ProcessManager:
|
|
|
4318
4401
|
'Failed to run the query PRAGMA wal_checkpoint(PASSIVE)' errors and
|
|
4319
4402
|
exit code 1 when multiple tasks run simultaneously.
|
|
4320
4403
|
"""
|
|
4404
|
+
# --dangerously-skip-permissions was introduced in opencode v1.4.0 (2026-04-08).
|
|
4405
|
+
# Older versions silently ignore the flag but then block on permission prompts
|
|
4406
|
+
# in headless mode (no TTY), eventually exiting with code 1 after a long wait.
|
|
4407
|
+
# Fail fast here so the error is immediately actionable.
|
|
4408
|
+
_oc_ver = _parse_semver(agent.version)
|
|
4409
|
+
if _oc_ver is None:
|
|
4410
|
+
# Non-standard version string (e.g. 'unknown', 'latest') — skip version
|
|
4411
|
+
# check rather than incorrectly rejecting a potentially valid agent.
|
|
4412
|
+
logger.warning(
|
|
4413
|
+
"Could not parse opencode version %r; skipping version check",
|
|
4414
|
+
agent.version,
|
|
4415
|
+
)
|
|
4416
|
+
elif _oc_ver < _OPENCODE_MIN_VERSION:
|
|
4417
|
+
_min_str = ".".join(str(x) for x in _OPENCODE_MIN_VERSION)
|
|
4418
|
+
return TaskResult(
|
|
4419
|
+
status="failed",
|
|
4420
|
+
exit_code=-1,
|
|
4421
|
+
stdout="",
|
|
4422
|
+
stderr="",
|
|
4423
|
+
error=(
|
|
4424
|
+
f"OpenCode {agent.version} is too old for headless execution. "
|
|
4425
|
+
f"Version {_min_str}+ is required: --dangerously-skip-permissions "
|
|
4426
|
+
f"(which auto-approves permission prompts in non-interactive mode) "
|
|
4427
|
+
f"was added in v{_min_str} on 2026-04-08. "
|
|
4428
|
+
f"Upgrade with: opencode upgrade"
|
|
4429
|
+
),
|
|
4430
|
+
failure_code="agent_version_incompatible",
|
|
4431
|
+
)
|
|
4432
|
+
|
|
4321
4433
|
cmd = [
|
|
4322
4434
|
agent.command, "run",
|
|
4323
4435
|
"--format", "json",
|
|
@@ -4519,8 +4631,13 @@ class ProcessManager:
|
|
|
4519
4631
|
return result
|
|
4520
4632
|
|
|
4521
4633
|
@staticmethod
|
|
4522
|
-
def _prepare_claude_environment(
|
|
4523
|
-
"
|
|
4634
|
+
def _prepare_claude_environment(
|
|
4635
|
+
sandbox_root: "Path | None" = None,
|
|
4636
|
+
) -> dict[str, str]:
|
|
4637
|
+
"""Prepare writable Claude config/data/cache dirs for isolated execution.
|
|
4638
|
+
|
|
4639
|
+
When *sandbox_root* is given (per-task isolation via mkdtemp), uses that
|
|
4640
|
+
directory. When None, falls back to the shared legacy path under ~/.forgexa.
|
|
4524
4641
|
|
|
4525
4642
|
The daemon service runs with ProtectSystem=strict and only allows
|
|
4526
4643
|
writes under ~/.forgexa plus the workspace root. Claude Code writes
|
|
@@ -4532,7 +4649,8 @@ class ProcessManager:
|
|
|
4532
4649
|
the same writable sandbox, and mirror only the small set of user
|
|
4533
4650
|
config files it needs for headless execution.
|
|
4534
4651
|
"""
|
|
4535
|
-
sandbox_root
|
|
4652
|
+
if sandbox_root is None:
|
|
4653
|
+
sandbox_root = Path.home() / ".forgexa" / "claude-home"
|
|
4536
4654
|
config_root = sandbox_root / "config"
|
|
4537
4655
|
data_root = sandbox_root / "data"
|
|
4538
4656
|
cache_root = sandbox_root / "cache"
|
|
@@ -4614,6 +4732,22 @@ class ProcessManager:
|
|
|
4614
4732
|
if not source_root.exists():
|
|
4615
4733
|
return str(target_root)
|
|
4616
4734
|
|
|
4735
|
+
# P3-3.5: Prune old session-state WAL/SHM files and log files > 7 days.
|
|
4736
|
+
# These accumulate indefinitely without cleanup.
|
|
4737
|
+
import time as _time
|
|
4738
|
+
_prune_cutoff = _time.time() - 7 * 86400 # 7 days
|
|
4739
|
+
for _prune_dir_name in ("session-state", "logs"):
|
|
4740
|
+
_prune_dir = target_root / _prune_dir_name
|
|
4741
|
+
if not _prune_dir.is_dir():
|
|
4742
|
+
continue
|
|
4743
|
+
for _old_file in _prune_dir.iterdir():
|
|
4744
|
+
try:
|
|
4745
|
+
if _old_file.is_file() and _old_file.stat().st_mtime < _prune_cutoff:
|
|
4746
|
+
_old_file.unlink(missing_ok=True)
|
|
4747
|
+
logger.debug("Pruned old Copilot home file: %s", _old_file)
|
|
4748
|
+
except Exception as _prune_err:
|
|
4749
|
+
logger.debug("Could not prune %s: %s", _old_file, _prune_err)
|
|
4750
|
+
|
|
4617
4751
|
# Collect names present in source for stale-file detection.
|
|
4618
4752
|
source_names: set[str] = {child.name for child in source_root.iterdir()}
|
|
4619
4753
|
|
|
@@ -4778,6 +4912,19 @@ class ProcessManager:
|
|
|
4778
4912
|
) + ' -p "<prompt>"'
|
|
4779
4913
|
logger.debug("Copilot invocation for task %s: %s", task_id, _cmd_display)
|
|
4780
4914
|
|
|
4915
|
+
# On Windows, CREATE_NO_WINDOW ensures GetConsoleWindow() returns NULL for
|
|
4916
|
+
# the child process, even when the parent (daemon or Tauri app) has a console
|
|
4917
|
+
# window attached. Copilot CLI 1.0.68+ added a secondary interactive-mode
|
|
4918
|
+
# check via GetConsoleWindow(): if a console window is visible, it uses only
|
|
4919
|
+
# the first line of -p as a brief context note and reports "No task was
|
|
4920
|
+
# provided" for the rest of the prompt — silently discarding the full task.
|
|
4921
|
+
# stdin=DEVNULL (below) covers the primary check (GetConsoleMode on stdin);
|
|
4922
|
+
# CREATE_NO_WINDOW covers this secondary check so Copilot always sees a fully
|
|
4923
|
+
# headless environment and processes -p as the complete task specification.
|
|
4924
|
+
_win_flags: dict = {}
|
|
4925
|
+
if sys.platform == "win32":
|
|
4926
|
+
_win_flags["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
4927
|
+
|
|
4781
4928
|
try:
|
|
4782
4929
|
proc = await asyncio.create_subprocess_exec(
|
|
4783
4930
|
*cmd,
|
|
@@ -4799,6 +4946,7 @@ class ProcessManager:
|
|
|
4799
4946
|
env=env,
|
|
4800
4947
|
limit=100 * 1024 * 1024,
|
|
4801
4948
|
start_new_session=True,
|
|
4949
|
+
**_win_flags,
|
|
4802
4950
|
)
|
|
4803
4951
|
self.active_processes[task_id] = proc
|
|
4804
4952
|
stdout, stderr, returncode = await self._stream_process(
|
|
@@ -4947,8 +5095,8 @@ class ProcessManager:
|
|
|
4947
5095
|
)
|
|
4948
5096
|
finally:
|
|
4949
5097
|
self.active_processes.pop(task_id, None)
|
|
4950
|
-
if _is_outer_call:
|
|
4951
|
-
|
|
5098
|
+
if _is_outer_call and _isolated_copilot_home:
|
|
5099
|
+
WorkspaceManager._safe_rmtree(Path(_isolated_copilot_home))
|
|
4952
5100
|
|
|
4953
5101
|
async def _run_generic(
|
|
4954
5102
|
self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
|
|
@@ -4970,7 +5118,7 @@ class ProcessManager:
|
|
|
4970
5118
|
*cmd,
|
|
4971
5119
|
stdout=asyncio.subprocess.PIPE,
|
|
4972
5120
|
stderr=asyncio.subprocess.PIPE,
|
|
4973
|
-
stdin=asyncio.subprocess.PIPE if stdin_input else
|
|
5121
|
+
stdin=asyncio.subprocess.PIPE if stdin_input else asyncio.subprocess.DEVNULL,
|
|
4974
5122
|
cwd=str(cwd),
|
|
4975
5123
|
env=env,
|
|
4976
5124
|
limit=100 * 1024 * 1024, # 100MB line buffer for large agent output
|
|
@@ -6648,6 +6796,10 @@ class RuntimeDaemon:
|
|
|
6648
6796
|
logger.info("Daemon ready. Connected to %d server(s). Polling for tasks...",
|
|
6649
6797
|
len(self.connections))
|
|
6650
6798
|
|
|
6799
|
+
# Clean up stale Copilot/Claude temp directories left by prior OOM-killed
|
|
6800
|
+
# daemon processes. This runs once at startup so /tmp doesn't fill up.
|
|
6801
|
+
await self._cleanup_stale_tmp_dirs()
|
|
6802
|
+
|
|
6651
6803
|
# 3. Main loop
|
|
6652
6804
|
try:
|
|
6653
6805
|
while not self._shutdown:
|
|
@@ -6992,7 +7144,6 @@ class RuntimeDaemon:
|
|
|
6992
7144
|
Using 1-second ticks keeps the UI responsive without flooding
|
|
6993
7145
|
the backend. Empty ticks are skipped to reduce HTTP traffic.
|
|
6994
7146
|
"""
|
|
6995
|
-
import math as _math
|
|
6996
7147
|
tick = 0
|
|
6997
7148
|
while not progress_stop.is_set():
|
|
6998
7149
|
await asyncio.sleep(1)
|
|
@@ -7426,9 +7577,22 @@ class RuntimeDaemon:
|
|
|
7426
7577
|
if commit_result:
|
|
7427
7578
|
# Propagate push/commit errors in metrics so they're visible
|
|
7428
7579
|
result.metrics.update(commit_result)
|
|
7580
|
+
# Commit failure means changes were never committed — the task
|
|
7581
|
+
# produced no persisted output. Mark as failed so the
|
|
7582
|
+
# orchestrator does not treat this as a successful node and
|
|
7583
|
+
# propagate stale state to downstream nodes.
|
|
7584
|
+
if commit_result.get("commit_error"):
|
|
7585
|
+
commit_err = commit_result["commit_error"]
|
|
7586
|
+
result.status = "failed"
|
|
7587
|
+
result.failure_code = result.failure_code or "auto_commit_failed"
|
|
7588
|
+
result.error = f"Auto-commit failed: {commit_err}"
|
|
7589
|
+
logger.error(
|
|
7590
|
+
"Task %s: auto-commit failed — changes not persisted: %s",
|
|
7591
|
+
task.task_id, commit_err,
|
|
7592
|
+
)
|
|
7429
7593
|
# Push failure is a real problem for downstream nodes — mark
|
|
7430
7594
|
# as failed so the orchestrator can retry (transient network).
|
|
7431
|
-
|
|
7595
|
+
elif commit_result.get("push_error"):
|
|
7432
7596
|
push_err = commit_result["push_error"]
|
|
7433
7597
|
result.status = "failed"
|
|
7434
7598
|
if commit_result.get("push_error_code") == "forbidden_branch_merge":
|
|
@@ -9511,15 +9675,21 @@ class RuntimeDaemon:
|
|
|
9511
9675
|
current_branch: str,
|
|
9512
9676
|
default_branch: str,
|
|
9513
9677
|
) -> list[str]:
|
|
9514
|
-
"""Return merge commits
|
|
9678
|
+
"""Return merge commits that pull default-branch changes into the requirement branch.
|
|
9679
|
+
|
|
9680
|
+
Requirement branches must stay linear and requirement-scoped. Only
|
|
9681
|
+
merges whose parent chain includes a commit from ``origin/{default_branch}``
|
|
9682
|
+
are rejected — these indicate an explicit merge/rebase that imported
|
|
9683
|
+
default-branch changes (and potentially other requirements' changes).
|
|
9515
9684
|
|
|
9516
|
-
|
|
9517
|
-
|
|
9518
|
-
|
|
9519
|
-
fix branch). Those pushes are rejected by the caller.
|
|
9685
|
+
Legitimate merges (e.g. AI-assisted conflict resolution that merges
|
|
9686
|
+
two sub-feature branches) are allowed through because neither parent
|
|
9687
|
+
is an ancestor of the default branch.
|
|
9520
9688
|
"""
|
|
9521
9689
|
git = self.workspace_manager._git
|
|
9522
9690
|
|
|
9691
|
+
# Collect all unpushed merge commits first
|
|
9692
|
+
all_merge_shas: list[str] = []
|
|
9523
9693
|
try:
|
|
9524
9694
|
await git("rev-parse", "--verify", f"origin/{current_branch}", cwd=workspace_path)
|
|
9525
9695
|
out = await git(
|
|
@@ -9534,8 +9704,42 @@ class RuntimeDaemon:
|
|
|
9534
9704
|
)
|
|
9535
9705
|
except RuntimeError:
|
|
9536
9706
|
return []
|
|
9707
|
+
all_merge_shas = [line.strip() for line in out.splitlines() if line.strip()]
|
|
9708
|
+
if not all_merge_shas:
|
|
9709
|
+
return []
|
|
9537
9710
|
|
|
9538
|
-
|
|
9711
|
+
# Filter: only flag merges where at least one parent is an ancestor of
|
|
9712
|
+
# origin/{default_branch}. This distinguishes "merged default branch in"
|
|
9713
|
+
# (forbidden) from "merged two local sub-branches" (legitimate).
|
|
9714
|
+
forbidden: list[str] = []
|
|
9715
|
+
for sha in all_merge_shas:
|
|
9716
|
+
try:
|
|
9717
|
+
parents_out = await git(
|
|
9718
|
+
"show", "-s", "--format=%P", sha, cwd=workspace_path,
|
|
9719
|
+
)
|
|
9720
|
+
parents = parents_out.strip().split()
|
|
9721
|
+
except RuntimeError:
|
|
9722
|
+
# Cannot determine parents — treat as forbidden (fail-safe)
|
|
9723
|
+
forbidden.append(sha)
|
|
9724
|
+
continue
|
|
9725
|
+
|
|
9726
|
+
is_default_merge = False
|
|
9727
|
+
for parent in parents:
|
|
9728
|
+
try:
|
|
9729
|
+
await git(
|
|
9730
|
+
"merge-base", "--is-ancestor", parent,
|
|
9731
|
+
f"origin/{default_branch}", cwd=workspace_path,
|
|
9732
|
+
)
|
|
9733
|
+
# This parent is an ancestor of the default branch — the
|
|
9734
|
+
# merge imported default-branch changes → forbidden
|
|
9735
|
+
is_default_merge = True
|
|
9736
|
+
break
|
|
9737
|
+
except RuntimeError:
|
|
9738
|
+
continue # Parent is not on the default branch — OK
|
|
9739
|
+
if is_default_merge:
|
|
9740
|
+
forbidden.append(sha)
|
|
9741
|
+
|
|
9742
|
+
return forbidden
|
|
9539
9743
|
|
|
9540
9744
|
async def _ai_resolve_conflicts(
|
|
9541
9745
|
self, workspace_path: Path, default_branch: str, task: TaskInfo,
|
|
@@ -10010,6 +10214,37 @@ class RuntimeDaemon:
|
|
|
10010
10214
|
for a in self.agents
|
|
10011
10215
|
]
|
|
10012
10216
|
|
|
10217
|
+
async def _cleanup_stale_tmp_dirs(self) -> None:
|
|
10218
|
+
"""Remove stale Copilot/Claude temp dirs from prior OOM-killed daemon runs.
|
|
10219
|
+
|
|
10220
|
+
Both GitHub Copilot and Claude agents use mkdtemp(prefix="copilot-…") /
|
|
10221
|
+
mkdtemp(prefix="claude-…") directories. When the daemon is killed before
|
|
10222
|
+
cleanup these directories accumulate in /tmp. We clean anything older
|
|
10223
|
+
than 1 hour at startup.
|
|
10224
|
+
"""
|
|
10225
|
+
import glob
|
|
10226
|
+
import tempfile
|
|
10227
|
+
import time as _time
|
|
10228
|
+
|
|
10229
|
+
tmp_dir = tempfile.gettempdir()
|
|
10230
|
+
patterns = [
|
|
10231
|
+
"copilot-*",
|
|
10232
|
+
"claude-*",
|
|
10233
|
+
]
|
|
10234
|
+
cutoff = _time.time() - 3600 # 1 hour
|
|
10235
|
+
|
|
10236
|
+
for pattern in patterns:
|
|
10237
|
+
for d in glob.glob(os.path.join(tmp_dir, pattern)):
|
|
10238
|
+
try:
|
|
10239
|
+
if not os.path.isdir(d):
|
|
10240
|
+
continue
|
|
10241
|
+
mtime = os.path.getmtime(d)
|
|
10242
|
+
if mtime < cutoff:
|
|
10243
|
+
self.workspace_manager._safe_rmtree(Path(d))
|
|
10244
|
+
logger.info("Cleaned up stale agent temp dir: %s", d)
|
|
10245
|
+
except Exception as e:
|
|
10246
|
+
logger.debug("Could not clean temp dir %s: %s", d, e)
|
|
10247
|
+
|
|
10013
10248
|
async def _shutdown_gracefully(self):
|
|
10014
10249
|
"""Graceful shutdown."""
|
|
10015
10250
|
logger.info("Shutting down daemon...")
|
|
@@ -73,6 +73,75 @@ except ImportError:
|
|
|
73
73
|
_SERVER_URL_OVERRIDE: str | None = None
|
|
74
74
|
|
|
75
75
|
|
|
76
|
+
# ── Banner ────────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
_LOGO_ART = """\
|
|
79
|
+
███████╗ ██████╗ ██████╗ ██████╗ ███████╗██╗ ██╗ █████╗
|
|
80
|
+
██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝╚██╗██╔╝██╔══██╗
|
|
81
|
+
█████╗ ██║ ██║██████╔╝██║ ███╗█████╗ ╚███╔╝ ███████║
|
|
82
|
+
██╔══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ ██╔██╗ ██╔══██║
|
|
83
|
+
██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗██╔╝ ██╗██║ ██║
|
|
84
|
+
╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _supports_color() -> bool:
|
|
88
|
+
"""Return True when stdout can render ANSI colors."""
|
|
89
|
+
if not sys.stdout.isatty():
|
|
90
|
+
return False
|
|
91
|
+
if os.environ.get("NO_COLOR"):
|
|
92
|
+
return False
|
|
93
|
+
if os.environ.get("TERM") in ("dumb", ""):
|
|
94
|
+
return False
|
|
95
|
+
# Windows: older cmd.exe and PowerShell don't support ANSI escape sequences.
|
|
96
|
+
# Only enable color when running in a known ANSI-capable environment.
|
|
97
|
+
if sys.platform == "win32":
|
|
98
|
+
# Windows Terminal sets WT_SESSION; ConEmu/ANSICON set ANSICON;
|
|
99
|
+
# VS Code terminal sets TERM_PROGRAM=vscode.
|
|
100
|
+
if not (
|
|
101
|
+
os.environ.get("WT_SESSION")
|
|
102
|
+
or os.environ.get("ANSICON")
|
|
103
|
+
or os.environ.get("TERM_PROGRAM") == "vscode"
|
|
104
|
+
or os.environ.get("COLORTERM") in ("truecolor", "24bit", "256color")
|
|
105
|
+
):
|
|
106
|
+
return False
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _brand_color() -> str:
|
|
111
|
+
"""Return the best available ANSI escape for Forgexa brand blue (#096dd9).
|
|
112
|
+
|
|
113
|
+
Priority:
|
|
114
|
+
1. 24-bit truecolor — exact match (COLORTERM=truecolor|24bit)
|
|
115
|
+
2. Bold blue — 16-color fallback
|
|
116
|
+
"""
|
|
117
|
+
colorterm = os.environ.get("COLORTERM", "").lower()
|
|
118
|
+
if colorterm in ("truecolor", "24bit"):
|
|
119
|
+
return "\033[38;2;9;109;217m" # #096dd9 exact
|
|
120
|
+
return "\033[1;34m" # bold blue — 16-color fallback
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _print_banner() -> None:
|
|
124
|
+
"""Print the Forgexa logo + tagline.
|
|
125
|
+
|
|
126
|
+
Shown only on bare invocation and top-level --help/-h.
|
|
127
|
+
Respects NO_COLOR, TERM=dumb, and non-TTY environments.
|
|
128
|
+
"""
|
|
129
|
+
from forgexa_cli import __version__
|
|
130
|
+
|
|
131
|
+
if _supports_color():
|
|
132
|
+
blue = _brand_color()
|
|
133
|
+
reset = "\033[0m"
|
|
134
|
+
logo = f"{blue}{_LOGO_ART}{reset}"
|
|
135
|
+
tagline = f" {blue}AI-Driven Software Factory · v{__version__}{reset}"
|
|
136
|
+
else:
|
|
137
|
+
logo = _LOGO_ART
|
|
138
|
+
tagline = f" AI-Driven Software Factory · v{__version__}"
|
|
139
|
+
|
|
140
|
+
print(logo, file=sys.stderr)
|
|
141
|
+
print(tagline, file=sys.stderr)
|
|
142
|
+
print(file=sys.stderr)
|
|
143
|
+
|
|
144
|
+
|
|
76
145
|
# ── Config file helpers (~/.forgexa/config) ──
|
|
77
146
|
|
|
78
147
|
def _config_path() -> Path:
|
|
@@ -168,6 +237,7 @@ def _refresh_access_token() -> bool:
|
|
|
168
237
|
try:
|
|
169
238
|
with httpx.Client(timeout=15) as client:
|
|
170
239
|
resp = client.post(url, json={"refresh_token": refresh_token})
|
|
240
|
+
resp.raise_for_status()
|
|
171
241
|
payload = resp.json()
|
|
172
242
|
except Exception:
|
|
173
243
|
return False
|
|
@@ -445,9 +515,19 @@ def _write_update_cache(latest_version: str) -> None:
|
|
|
445
515
|
|
|
446
516
|
|
|
447
517
|
def _version_is_newer(candidate: str, reference: str) -> bool:
|
|
448
|
-
"""Return True if candidate is strictly newer than reference
|
|
518
|
+
"""Return True if candidate is strictly newer than reference.
|
|
519
|
+
|
|
520
|
+
Strips pre-release/build suffixes (e.g. '1.14.3a1', '1.14.3.post1')
|
|
521
|
+
before comparison so that release version 1.14.3 is treated as newer
|
|
522
|
+
than pre-release 1.14.3a1, not equal.
|
|
523
|
+
"""
|
|
524
|
+
import re as _re
|
|
525
|
+
|
|
449
526
|
def _parts(v: str) -> tuple[int, ...]:
|
|
450
|
-
|
|
527
|
+
# Strip pre-release/build suffixes: take only the leading numeric segments
|
|
528
|
+
clean = _re.split(r"[^0-9.]", v)[0].rstrip(".")
|
|
529
|
+
return tuple(int(x) for x in clean.split(".")[:4] if x.isdigit())
|
|
530
|
+
|
|
451
531
|
try:
|
|
452
532
|
return _parts(candidate) > _parts(reference)
|
|
453
533
|
except Exception:
|
|
@@ -626,12 +706,24 @@ def _request_json(
|
|
|
626
706
|
timeout=timeout,
|
|
627
707
|
allow_refresh=False,
|
|
628
708
|
)
|
|
629
|
-
body_text = e.response.text
|
|
709
|
+
body_text = e.response.text[:500]
|
|
710
|
+
# Mask secrets that may leak from server 5xx responses
|
|
711
|
+
import re as _re
|
|
712
|
+
body_text = _re.sub(
|
|
713
|
+
r'(password|token|secret|key|auth)["\']?\s*[:=]\s*["\']?[^\s&,"\']+',
|
|
714
|
+
r'\1=***',
|
|
715
|
+
body_text,
|
|
716
|
+
flags=_re.IGNORECASE,
|
|
717
|
+
)
|
|
630
718
|
print(f"Error {e.response.status_code}: {body_text}", file=sys.stderr)
|
|
631
|
-
|
|
719
|
+
# Use distinct exit codes so callers / CI scripts can distinguish error types:
|
|
720
|
+
# 1 → generic / server error (4xx except auth, 5xx)
|
|
721
|
+
# 2 → authentication failure (401 / 403)
|
|
722
|
+
status = e.response.status_code
|
|
723
|
+
sys.exit(2 if status in (401, 403) else 1)
|
|
632
724
|
except httpx.RequestError as e:
|
|
633
725
|
print(f"Connection error: {e}\nServer: {_api_url()}", file=sys.stderr)
|
|
634
|
-
sys.exit(
|
|
726
|
+
sys.exit(3) # 3 → network / connection error
|
|
635
727
|
|
|
636
728
|
|
|
637
729
|
def _get(path: str) -> dict | list:
|
|
@@ -765,7 +857,12 @@ def _get_runtimes(include_all: bool = False) -> list[dict]:
|
|
|
765
857
|
|
|
766
858
|
|
|
767
859
|
def _get_runtimes_silent(include_all: bool = False) -> list[dict]:
|
|
768
|
-
"""Like _get_runtimes() but returns [] instead of sys.exit on any error.
|
|
860
|
+
"""Like _get_runtimes() but returns [] instead of sys.exit on any error.
|
|
861
|
+
|
|
862
|
+
On 401, tries once to refresh the token (same as _request_json) rather
|
|
863
|
+
than silently returning [] — the original bug was that 401 caused a 12s
|
|
864
|
+
polling timeout because the stale token was never refreshed.
|
|
865
|
+
"""
|
|
769
866
|
import httpx
|
|
770
867
|
|
|
771
868
|
path = "/runtimes" if include_all else "/runtimes/me"
|
|
@@ -773,6 +870,11 @@ def _get_runtimes_silent(include_all: bool = False) -> list[dict]:
|
|
|
773
870
|
try:
|
|
774
871
|
with httpx.Client(headers=_headers(), timeout=10) as client:
|
|
775
872
|
resp = client.get(url)
|
|
873
|
+
if resp.status_code == 401 and _refresh_access_token():
|
|
874
|
+
# Retry once with the new token
|
|
875
|
+
with httpx.Client(headers=_headers(), timeout=10) as retry_client:
|
|
876
|
+
resp = retry_client.get(url)
|
|
877
|
+
resp.raise_for_status()
|
|
776
878
|
result = resp.json() if resp.content else []
|
|
777
879
|
return result if isinstance(result, list) else []
|
|
778
880
|
except Exception:
|
|
@@ -1396,6 +1498,10 @@ def main() -> None:
|
|
|
1396
1498
|
|
|
1397
1499
|
active_url = _api_url() # resolved before parsing (for help text)
|
|
1398
1500
|
|
|
1501
|
+
# Show logo on bare invocation or top-level --help / -h
|
|
1502
|
+
if len(sys.argv) == 1 or (len(sys.argv) >= 2 and sys.argv[1] in ("-h", "--help")):
|
|
1503
|
+
_print_banner()
|
|
1504
|
+
|
|
1399
1505
|
parser = argparse.ArgumentParser(
|
|
1400
1506
|
prog="forgexa",
|
|
1401
1507
|
description="Forgexa CLI — communicates with the Forgexa server via REST API.",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import io
|
|
4
3
|
import contextlib
|
|
4
|
+
import io
|
|
5
5
|
import json
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from types import SimpleNamespace
|
|
8
|
-
import
|
|
8
|
+
from unittest.mock import MagicMock
|
|
9
9
|
|
|
10
10
|
import httpx
|
|
11
11
|
import pytest
|
|
@@ -13,27 +13,15 @@ import pytest
|
|
|
13
13
|
from forgexa_cli import daemon, main
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
class UrlopenResponse:
|
|
17
|
-
def __init__(self, payload):
|
|
18
|
-
self._payload = payload
|
|
19
|
-
|
|
20
|
-
def read(self) -> bytes:
|
|
21
|
-
return json.dumps(self._payload).encode("utf-8")
|
|
22
|
-
|
|
23
|
-
def __enter__(self):
|
|
24
|
-
return self
|
|
25
|
-
|
|
26
|
-
def __exit__(self, exc_type, exc, tb):
|
|
27
|
-
return False
|
|
28
|
-
|
|
29
|
-
|
|
30
16
|
class HttpxResponse:
|
|
31
|
-
def __init__(self, status_code: int, payload: dict):
|
|
17
|
+
def __init__(self, status_code: int, payload: dict | list):
|
|
32
18
|
self.status_code = status_code
|
|
33
19
|
self._payload = payload
|
|
34
|
-
self.request = httpx.Request("
|
|
20
|
+
self.request = httpx.Request("GET", "https://api.example.com/api/v1/runtimes/me")
|
|
21
|
+
self.content = json.dumps(payload).encode("utf-8")
|
|
22
|
+
self.text = json.dumps(payload)
|
|
35
23
|
|
|
36
|
-
def json(self) -> dict:
|
|
24
|
+
def json(self) -> dict | list:
|
|
37
25
|
return self._payload
|
|
38
26
|
|
|
39
27
|
def raise_for_status(self) -> None:
|
|
@@ -45,16 +33,6 @@ class HttpxResponse:
|
|
|
45
33
|
)
|
|
46
34
|
|
|
47
35
|
|
|
48
|
-
def _http_error(url: str, status_code: int, payload: dict) -> urllib.error.HTTPError:
|
|
49
|
-
return urllib.error.HTTPError(
|
|
50
|
-
url,
|
|
51
|
-
status_code,
|
|
52
|
-
payload.get("detail", "error"),
|
|
53
|
-
hdrs=None,
|
|
54
|
-
fp=io.BytesIO(json.dumps(payload).encode("utf-8")),
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
|
|
58
36
|
def test_login_persists_refresh_token(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
59
37
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
60
38
|
monkeypatch.setattr(main, "_SERVER_URL_OVERRIDE", None)
|
|
@@ -100,40 +78,62 @@ def test_get_retries_once_after_refresh_on_401(
|
|
|
100
78
|
)
|
|
101
79
|
(config_dir / "token").write_text("old-access")
|
|
102
80
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
81
|
+
# httpx.Client is created once per request call. Track creation order:
|
|
82
|
+
# call #1 — initial GET (with old-access token) → 401
|
|
83
|
+
# call #2 — POST to /auth/refresh (no auth header) → returns new tokens
|
|
84
|
+
# call #3 — retry GET (with new-access token) → returns runtimes list
|
|
85
|
+
client_call_count = {"n": 0}
|
|
86
|
+
runtime_call_count = {"n": 0}
|
|
87
|
+
|
|
88
|
+
class FakeClient:
|
|
89
|
+
def __init__(self, **kwargs):
|
|
90
|
+
client_call_count["n"] += 1
|
|
91
|
+
self._call_num = client_call_count["n"]
|
|
92
|
+
self._headers = kwargs.get("headers", {})
|
|
93
|
+
|
|
94
|
+
def __enter__(self):
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def __exit__(self, *args):
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
def request(self, method, url, **kw):
|
|
101
|
+
# GET /runtimes/me — first time 401, second time success
|
|
102
|
+
assert "/runtimes/me" in url
|
|
103
|
+
runtime_call_count["n"] += 1
|
|
104
|
+
if runtime_call_count["n"] == 1:
|
|
105
|
+
# Verify old token was used
|
|
106
|
+
assert "old-access" in self._headers.get("Authorization", "")
|
|
107
|
+
resp = HttpxResponse(401, {"detail": "Invalid token"})
|
|
108
|
+
resp.request = httpx.Request(method, url)
|
|
109
|
+
raise httpx.HTTPStatusError(
|
|
110
|
+
"HTTP 401",
|
|
111
|
+
request=resp.request,
|
|
112
|
+
response=resp,
|
|
113
|
+
)
|
|
114
|
+
# Second call: verify new token was used
|
|
115
|
+
assert "new-access" in self._headers.get("Authorization", "")
|
|
116
|
+
return HttpxResponse(
|
|
117
|
+
200,
|
|
118
|
+
[{"id": "12345678", "daemon_id": "demo-daemon", "status": "online"}],
|
|
119
|
+
)
|
|
106
120
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
{
|
|
116
|
-
"id": "12345678",
|
|
117
|
-
"daemon_id": "demo-daemon",
|
|
118
|
-
"status": "online",
|
|
119
|
-
}
|
|
120
|
-
])
|
|
121
|
-
if url.endswith("/api/v1/auth/refresh"):
|
|
122
|
-
assert json.loads(req.data.decode("utf-8")) == {"refresh_token": "refresh-1"}
|
|
123
|
-
return UrlopenResponse(
|
|
124
|
-
{
|
|
125
|
-
"access_token": "new-access",
|
|
126
|
-
"refresh_token": "refresh-2",
|
|
127
|
-
}
|
|
121
|
+
def post(self, url, **kw):
|
|
122
|
+
# POST /auth/refresh
|
|
123
|
+
assert "/auth/refresh" in url
|
|
124
|
+
body = kw.get("json", {})
|
|
125
|
+
assert body.get("refresh_token") == "refresh-1"
|
|
126
|
+
return HttpxResponse(
|
|
127
|
+
200,
|
|
128
|
+
{"access_token": "new-access", "refresh_token": "refresh-2"},
|
|
128
129
|
)
|
|
129
|
-
raise AssertionError(f"Unexpected URL: {url}")
|
|
130
130
|
|
|
131
|
-
monkeypatch.setattr(
|
|
131
|
+
monkeypatch.setattr(httpx, "Client", FakeClient)
|
|
132
132
|
|
|
133
133
|
result = main._get("/runtimes/me")
|
|
134
134
|
|
|
135
135
|
assert isinstance(result, list)
|
|
136
|
-
assert
|
|
136
|
+
assert runtime_call_count["n"] == 2
|
|
137
137
|
cfg = json.loads((config_dir / "config").read_text())
|
|
138
138
|
assert cfg["token"] == "new-access"
|
|
139
139
|
assert cfg["refresh_token"] == "refresh-2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|