forgexa-cli 1.14.11__tar.gz → 1.15.1__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.11 → forgexa_cli-1.15.1}/PKG-INFO +1 -1
- {forgexa_cli-1.14.11 → forgexa_cli-1.15.1}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.14.11 → forgexa_cli-1.15.1}/forgexa_cli/daemon.py +275 -39
- {forgexa_cli-1.14.11 → forgexa_cli-1.15.1}/forgexa_cli/main.py +57 -12
- {forgexa_cli-1.14.11 → forgexa_cli-1.15.1}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.14.11 → forgexa_cli-1.15.1}/pyproject.toml +1 -1
- {forgexa_cli-1.14.11 → forgexa_cli-1.15.1}/tests/test_auth_and_runtime_commands.py +56 -56
- {forgexa_cli-1.14.11 → forgexa_cli-1.15.1}/README.md +0 -0
- {forgexa_cli-1.14.11 → forgexa_cli-1.15.1}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.14.11 → forgexa_cli-1.15.1}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.14.11 → forgexa_cli-1.15.1}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.14.11 → forgexa_cli-1.15.1}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.14.11 → forgexa_cli-1.15.1}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.14.11 → forgexa_cli-1.15.1}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.14.11 → forgexa_cli-1.15.1}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.14.11 → forgexa_cli-1.15.1}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.15.1"
|
|
@@ -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.1"
|
|
527
527
|
|
|
528
528
|
|
|
529
529
|
def _detect_client_type() -> str:
|
|
@@ -944,18 +944,20 @@ def _daemon_extract_claude_output(raw: str) -> str:
|
|
|
944
944
|
_OPENCODE_MIN_VERSION = (1, 4, 0)
|
|
945
945
|
|
|
946
946
|
|
|
947
|
-
def _parse_semver(v: str) -> tuple[int, int, int]:
|
|
947
|
+
def _parse_semver(v: str) -> "tuple[int, int, int] | None":
|
|
948
948
|
"""Parse a semver string into a comparable (major, minor, patch) tuple.
|
|
949
949
|
|
|
950
950
|
Strips a leading 'v' and ignores pre-release/build suffixes so that
|
|
951
951
|
'1.4.0-beta.1' and 'v1.4.0' both return (1, 4, 0).
|
|
952
|
-
Returns
|
|
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.
|
|
953
955
|
"""
|
|
954
956
|
import re as _re
|
|
955
957
|
m = _re.match(r"^v?(\d+)\.(\d+)\.(\d+)", v.strip())
|
|
956
958
|
if m:
|
|
957
959
|
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
|
958
|
-
return
|
|
960
|
+
return None
|
|
959
961
|
|
|
960
962
|
|
|
961
963
|
def _daemon_extract_opencode_output(raw: str) -> str:
|
|
@@ -1258,18 +1260,18 @@ def _get_design_output_for_type(req_type: str) -> str:
|
|
|
1258
1260
|
Used by _validate_outputs and _required_deliverable_paths to avoid
|
|
1259
1261
|
hardcoding 'design.md' for all node_type='design' nodes — spike produces
|
|
1260
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.
|
|
1261
1267
|
"""
|
|
1262
1268
|
try:
|
|
1263
1269
|
from app.services.type_workflow_profiles import get_profile
|
|
1264
1270
|
profile = get_profile(req_type)
|
|
1265
1271
|
for phase in profile.execution_phases:
|
|
1266
1272
|
if phase.node_type == "design":
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
import re as _re
|
|
1270
|
-
m = _re.search(r"{doc_dir}/([\w.-]+)", tmpl)
|
|
1271
|
-
if m:
|
|
1272
|
-
return m.group(1)
|
|
1273
|
+
if phase.output_filename:
|
|
1274
|
+
return phase.output_filename
|
|
1273
1275
|
break
|
|
1274
1276
|
except Exception:
|
|
1275
1277
|
pass
|
|
@@ -1742,10 +1744,10 @@ class WorkspaceManager:
|
|
|
1742
1744
|
The Win32 API intercepts these names before they reach the NTFS
|
|
1743
1745
|
driver, so os.unlink / shutil.rmtree / PowerShell Remove-Item all
|
|
1744
1746
|
fail with a permissions or access error. The solution is the
|
|
1745
|
-
|
|
1747
|
+
\\?\\ extended-length path prefix, which instructs Windows to
|
|
1746
1748
|
skip Win32 name parsing and pass the path directly to the NT
|
|
1747
1749
|
kernel file-system layer. 'cmd /c rmdir /s /q' reliably handles
|
|
1748
|
-
this when given a
|
|
1750
|
+
this when given a \\?\\ prefixed path.
|
|
1749
1751
|
|
|
1750
1752
|
Deletion strategy (each stage only runs if the path still exists):
|
|
1751
1753
|
1. shutil.rmtree with onerror chmod — handles read-only objects.
|
|
@@ -1754,36 +1756,51 @@ class WorkspaceManager:
|
|
|
1754
1756
|
"""
|
|
1755
1757
|
import shutil
|
|
1756
1758
|
import stat as _stat
|
|
1759
|
+
import sys as _sys
|
|
1757
1760
|
|
|
1758
1761
|
if not path.exists():
|
|
1759
1762
|
return
|
|
1760
1763
|
|
|
1761
1764
|
def _handle_readonly(func, entry_path, excinfo):
|
|
1762
1765
|
try:
|
|
1763
|
-
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)
|
|
1764
1767
|
func(entry_path)
|
|
1765
|
-
except Exception:
|
|
1766
|
-
|
|
1768
|
+
except Exception as _chmod_err:
|
|
1769
|
+
logger.debug(
|
|
1770
|
+
"_safe_rmtree: failed to force-remove %s: %s",
|
|
1771
|
+
entry_path, _chmod_err,
|
|
1772
|
+
)
|
|
1767
1773
|
|
|
1768
|
-
|
|
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)
|
|
1769
1783
|
|
|
1770
1784
|
# Windows fallback: reserved device names survive shutil.rmtree.
|
|
1771
|
-
# cmd /c rmdir /s /q with the
|
|
1785
|
+
# cmd /c rmdir /s /q with the \\?\\ extended-length prefix bypasses
|
|
1772
1786
|
# Win32 name interception and reaches the NTFS layer directly.
|
|
1773
1787
|
if path.exists() and sys.platform == "win32":
|
|
1774
1788
|
try:
|
|
1775
1789
|
import subprocess
|
|
1776
1790
|
resolved = str(path.resolve())
|
|
1777
|
-
#
|
|
1778
|
-
#
|
|
1779
|
-
#
|
|
1780
|
-
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("\\\\?\\"):
|
|
1781
1797
|
unc = "\\\\?\\" + resolved
|
|
1782
1798
|
else:
|
|
1783
1799
|
unc = resolved
|
|
1784
1800
|
result = subprocess.run(
|
|
1785
1801
|
["cmd", "/c", "rmdir", "/s", "/q", unc],
|
|
1786
1802
|
capture_output=True,
|
|
1803
|
+
stdin=subprocess.DEVNULL,
|
|
1787
1804
|
timeout=60,
|
|
1788
1805
|
)
|
|
1789
1806
|
if result.returncode != 0 and path.exists():
|
|
@@ -2298,6 +2315,14 @@ class WorkspaceManager:
|
|
|
2298
2315
|
"revert(analysis",
|
|
2299
2316
|
"initial commit",
|
|
2300
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
|
+
)
|
|
2301
2326
|
try:
|
|
2302
2327
|
raw = await self._git(
|
|
2303
2328
|
"log", "--format=%s",
|
|
@@ -2314,6 +2339,11 @@ class WorkspaceManager:
|
|
|
2314
2339
|
|
|
2315
2340
|
for subject in subjects:
|
|
2316
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
|
|
2317
2347
|
if not any(lower.startswith(p) for p in _ANALYSIS_ONLY_PREFIXES):
|
|
2318
2348
|
return True # Found at least one implementation commit
|
|
2319
2349
|
|
|
@@ -2562,6 +2592,22 @@ class WorkspaceManager:
|
|
|
2562
2592
|
|
|
2563
2593
|
if fresh_start:
|
|
2564
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
|
+
)
|
|
2565
2611
|
# Use checkout -B to create or reset the branch to origin/default_branch.
|
|
2566
2612
|
# Plain `checkout branch_name` fails when the branch doesn't exist locally.
|
|
2567
2613
|
try:
|
|
@@ -3128,7 +3174,17 @@ class WorkspaceManager:
|
|
|
3128
3174
|
except Exception:
|
|
3129
3175
|
pass
|
|
3130
3176
|
else:
|
|
3131
|
-
|
|
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")
|
|
3132
3188
|
env = {
|
|
3133
3189
|
**os.environ,
|
|
3134
3190
|
"GIT_SSH_COMMAND": (
|
|
@@ -3188,6 +3244,7 @@ class WorkspaceManager:
|
|
|
3188
3244
|
# we use taskkill /T there instead of killpg.
|
|
3189
3245
|
proc = await asyncio.create_subprocess_exec(
|
|
3190
3246
|
"git", *longpath_args, *git_prefix_args, *args,
|
|
3247
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
3191
3248
|
stdout=asyncio.subprocess.PIPE,
|
|
3192
3249
|
stderr=asyncio.subprocess.PIPE,
|
|
3193
3250
|
cwd=str(cwd) if cwd else None,
|
|
@@ -4174,7 +4231,12 @@ class ProcessManager:
|
|
|
4174
4231
|
model_override = os.environ.get("FACTORY_CLAUDE_MODEL")
|
|
4175
4232
|
if model_override:
|
|
4176
4233
|
env["ANTHROPIC_MODEL"] = model_override
|
|
4177
|
-
|
|
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)))
|
|
4178
4240
|
|
|
4179
4241
|
try:
|
|
4180
4242
|
proc = await asyncio.create_subprocess_exec(
|
|
@@ -4262,6 +4324,8 @@ class ProcessManager:
|
|
|
4262
4324
|
)
|
|
4263
4325
|
finally:
|
|
4264
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))
|
|
4265
4329
|
|
|
4266
4330
|
async def _run_codex(
|
|
4267
4331
|
self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
|
|
@@ -4342,7 +4406,14 @@ class ProcessManager:
|
|
|
4342
4406
|
# in headless mode (no TTY), eventually exiting with code 1 after a long wait.
|
|
4343
4407
|
# Fail fast here so the error is immediately actionable.
|
|
4344
4408
|
_oc_ver = _parse_semver(agent.version)
|
|
4345
|
-
if _oc_ver
|
|
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:
|
|
4346
4417
|
_min_str = ".".join(str(x) for x in _OPENCODE_MIN_VERSION)
|
|
4347
4418
|
return TaskResult(
|
|
4348
4419
|
status="failed",
|
|
@@ -4560,8 +4631,13 @@ class ProcessManager:
|
|
|
4560
4631
|
return result
|
|
4561
4632
|
|
|
4562
4633
|
@staticmethod
|
|
4563
|
-
def _prepare_claude_environment(
|
|
4564
|
-
"
|
|
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.
|
|
4565
4641
|
|
|
4566
4642
|
The daemon service runs with ProtectSystem=strict and only allows
|
|
4567
4643
|
writes under ~/.forgexa plus the workspace root. Claude Code writes
|
|
@@ -4573,7 +4649,8 @@ class ProcessManager:
|
|
|
4573
4649
|
the same writable sandbox, and mirror only the small set of user
|
|
4574
4650
|
config files it needs for headless execution.
|
|
4575
4651
|
"""
|
|
4576
|
-
sandbox_root
|
|
4652
|
+
if sandbox_root is None:
|
|
4653
|
+
sandbox_root = Path.home() / ".forgexa" / "claude-home"
|
|
4577
4654
|
config_root = sandbox_root / "config"
|
|
4578
4655
|
data_root = sandbox_root / "data"
|
|
4579
4656
|
cache_root = sandbox_root / "cache"
|
|
@@ -4655,6 +4732,22 @@ class ProcessManager:
|
|
|
4655
4732
|
if not source_root.exists():
|
|
4656
4733
|
return str(target_root)
|
|
4657
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
|
+
|
|
4658
4751
|
# Collect names present in source for stale-file detection.
|
|
4659
4752
|
source_names: set[str] = {child.name for child in source_root.iterdir()}
|
|
4660
4753
|
|
|
@@ -4807,10 +4900,47 @@ class ProcessManager:
|
|
|
4807
4900
|
]
|
|
4808
4901
|
if reasoning and not _retry_without_effort:
|
|
4809
4902
|
cmd += ["--effort", reasoning]
|
|
4810
|
-
cmd += ["-C", str(cwd), "-p", prompt]
|
|
4811
4903
|
if model_override:
|
|
4812
4904
|
cmd = [agent.command, "--model", model_override] + cmd[1:]
|
|
4813
4905
|
|
|
4906
|
+
# On Windows, batch/cmd wrappers (e.g. copilot.BAT from the VS Code
|
|
4907
|
+
# Copilot Chat extension) invoke cmd.exe when run via CreateProcess.
|
|
4908
|
+
# cmd.exe has an ~8191-char command-line limit; long multi-line prompts
|
|
4909
|
+
# are silently truncated so the agent runs but never sees the output
|
|
4910
|
+
# file instructions — producing no required deliverables.
|
|
4911
|
+
# Fix: write the full prompt to a workspace-local file and pass a short
|
|
4912
|
+
# single-line read-instruction via -p to stay under the limit.
|
|
4913
|
+
_task_file: Path | None = None
|
|
4914
|
+
_effective_prompt = prompt
|
|
4915
|
+
if sys.platform == "win32" and "\n" in prompt:
|
|
4916
|
+
_task_file = Path(cwd) / ".forgexa-task.md"
|
|
4917
|
+
try:
|
|
4918
|
+
_task_file.write_text(prompt, encoding="utf-8")
|
|
4919
|
+
# Prevent accidental git commit of this ephemeral file
|
|
4920
|
+
_git_exclude = Path(cwd) / ".git" / "info" / "exclude"
|
|
4921
|
+
if _git_exclude.exists():
|
|
4922
|
+
_excl = _git_exclude.read_text(encoding="utf-8", errors="replace")
|
|
4923
|
+
if ".forgexa-task.md" not in _excl:
|
|
4924
|
+
_git_exclude.write_text(
|
|
4925
|
+
_excl.rstrip("\n") + "\n.forgexa-task.md\n", encoding="utf-8",
|
|
4926
|
+
)
|
|
4927
|
+
_effective_prompt = (
|
|
4928
|
+
"Read the file .forgexa-task.md for your complete task "
|
|
4929
|
+
"specification. Follow all instructions in it exactly."
|
|
4930
|
+
)
|
|
4931
|
+
logger.debug(
|
|
4932
|
+
"Copilot task %s: wrote %d-char prompt to .forgexa-task.md",
|
|
4933
|
+
task_id, len(prompt),
|
|
4934
|
+
)
|
|
4935
|
+
except Exception as _tf_err:
|
|
4936
|
+
logger.warning(
|
|
4937
|
+
"Copilot task %s: could not write prompt file (%s); using -p directly",
|
|
4938
|
+
task_id, _tf_err,
|
|
4939
|
+
)
|
|
4940
|
+
_task_file = None
|
|
4941
|
+
|
|
4942
|
+
cmd += ["-C", str(cwd), "-p", _effective_prompt]
|
|
4943
|
+
|
|
4814
4944
|
# Log the exact invocation (redact prompt body) so operators can
|
|
4815
4945
|
# reproduce failures manually and diagnose CLI version incompatibilities.
|
|
4816
4946
|
_cmd_display = " ".join(
|
|
@@ -4819,6 +4949,19 @@ class ProcessManager:
|
|
|
4819
4949
|
) + ' -p "<prompt>"'
|
|
4820
4950
|
logger.debug("Copilot invocation for task %s: %s", task_id, _cmd_display)
|
|
4821
4951
|
|
|
4952
|
+
# On Windows, CREATE_NO_WINDOW ensures GetConsoleWindow() returns NULL for
|
|
4953
|
+
# the child process, even when the parent (daemon or Tauri app) has a console
|
|
4954
|
+
# window attached. Copilot CLI 1.0.68+ added a secondary interactive-mode
|
|
4955
|
+
# check via GetConsoleWindow(): if a console window is visible, it uses only
|
|
4956
|
+
# the first line of -p as a brief context note and reports "No task was
|
|
4957
|
+
# provided" for the rest of the prompt — silently discarding the full task.
|
|
4958
|
+
# stdin=DEVNULL (below) covers the primary check (GetConsoleMode on stdin);
|
|
4959
|
+
# CREATE_NO_WINDOW covers this secondary check so Copilot always sees a fully
|
|
4960
|
+
# headless environment and processes -p as the complete task specification.
|
|
4961
|
+
_win_flags: dict = {}
|
|
4962
|
+
if sys.platform == "win32":
|
|
4963
|
+
_win_flags["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
4964
|
+
|
|
4822
4965
|
try:
|
|
4823
4966
|
proc = await asyncio.create_subprocess_exec(
|
|
4824
4967
|
*cmd,
|
|
@@ -4840,6 +4983,7 @@ class ProcessManager:
|
|
|
4840
4983
|
env=env,
|
|
4841
4984
|
limit=100 * 1024 * 1024,
|
|
4842
4985
|
start_new_session=True,
|
|
4986
|
+
**_win_flags,
|
|
4843
4987
|
)
|
|
4844
4988
|
self.active_processes[task_id] = proc
|
|
4845
4989
|
stdout, stderr, returncode = await self._stream_process(
|
|
@@ -4988,8 +5132,13 @@ class ProcessManager:
|
|
|
4988
5132
|
)
|
|
4989
5133
|
finally:
|
|
4990
5134
|
self.active_processes.pop(task_id, None)
|
|
4991
|
-
if _is_outer_call:
|
|
4992
|
-
|
|
5135
|
+
if _is_outer_call and _isolated_copilot_home:
|
|
5136
|
+
WorkspaceManager._safe_rmtree(Path(_isolated_copilot_home))
|
|
5137
|
+
if _task_file is not None:
|
|
5138
|
+
try:
|
|
5139
|
+
_task_file.unlink(missing_ok=True)
|
|
5140
|
+
except Exception:
|
|
5141
|
+
pass
|
|
4993
5142
|
|
|
4994
5143
|
async def _run_generic(
|
|
4995
5144
|
self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
|
|
@@ -5011,7 +5160,7 @@ class ProcessManager:
|
|
|
5011
5160
|
*cmd,
|
|
5012
5161
|
stdout=asyncio.subprocess.PIPE,
|
|
5013
5162
|
stderr=asyncio.subprocess.PIPE,
|
|
5014
|
-
stdin=asyncio.subprocess.PIPE if stdin_input else
|
|
5163
|
+
stdin=asyncio.subprocess.PIPE if stdin_input else asyncio.subprocess.DEVNULL,
|
|
5015
5164
|
cwd=str(cwd),
|
|
5016
5165
|
env=env,
|
|
5017
5166
|
limit=100 * 1024 * 1024, # 100MB line buffer for large agent output
|
|
@@ -6689,6 +6838,10 @@ class RuntimeDaemon:
|
|
|
6689
6838
|
logger.info("Daemon ready. Connected to %d server(s). Polling for tasks...",
|
|
6690
6839
|
len(self.connections))
|
|
6691
6840
|
|
|
6841
|
+
# Clean up stale Copilot/Claude temp directories left by prior OOM-killed
|
|
6842
|
+
# daemon processes. This runs once at startup so /tmp doesn't fill up.
|
|
6843
|
+
await self._cleanup_stale_tmp_dirs()
|
|
6844
|
+
|
|
6692
6845
|
# 3. Main loop
|
|
6693
6846
|
try:
|
|
6694
6847
|
while not self._shutdown:
|
|
@@ -7033,7 +7186,6 @@ class RuntimeDaemon:
|
|
|
7033
7186
|
Using 1-second ticks keeps the UI responsive without flooding
|
|
7034
7187
|
the backend. Empty ticks are skipped to reduce HTTP traffic.
|
|
7035
7188
|
"""
|
|
7036
|
-
import math as _math
|
|
7037
7189
|
tick = 0
|
|
7038
7190
|
while not progress_stop.is_set():
|
|
7039
7191
|
await asyncio.sleep(1)
|
|
@@ -7467,9 +7619,22 @@ class RuntimeDaemon:
|
|
|
7467
7619
|
if commit_result:
|
|
7468
7620
|
# Propagate push/commit errors in metrics so they're visible
|
|
7469
7621
|
result.metrics.update(commit_result)
|
|
7622
|
+
# Commit failure means changes were never committed — the task
|
|
7623
|
+
# produced no persisted output. Mark as failed so the
|
|
7624
|
+
# orchestrator does not treat this as a successful node and
|
|
7625
|
+
# propagate stale state to downstream nodes.
|
|
7626
|
+
if commit_result.get("commit_error"):
|
|
7627
|
+
commit_err = commit_result["commit_error"]
|
|
7628
|
+
result.status = "failed"
|
|
7629
|
+
result.failure_code = result.failure_code or "auto_commit_failed"
|
|
7630
|
+
result.error = f"Auto-commit failed: {commit_err}"
|
|
7631
|
+
logger.error(
|
|
7632
|
+
"Task %s: auto-commit failed — changes not persisted: %s",
|
|
7633
|
+
task.task_id, commit_err,
|
|
7634
|
+
)
|
|
7470
7635
|
# Push failure is a real problem for downstream nodes — mark
|
|
7471
7636
|
# as failed so the orchestrator can retry (transient network).
|
|
7472
|
-
|
|
7637
|
+
elif commit_result.get("push_error"):
|
|
7473
7638
|
push_err = commit_result["push_error"]
|
|
7474
7639
|
result.status = "failed"
|
|
7475
7640
|
if commit_result.get("push_error_code") == "forbidden_branch_merge":
|
|
@@ -9552,15 +9717,21 @@ class RuntimeDaemon:
|
|
|
9552
9717
|
current_branch: str,
|
|
9553
9718
|
default_branch: str,
|
|
9554
9719
|
) -> list[str]:
|
|
9555
|
-
"""Return merge commits
|
|
9720
|
+
"""Return merge commits that pull default-branch changes into the requirement branch.
|
|
9556
9721
|
|
|
9557
|
-
Requirement branches must stay linear and requirement-scoped.
|
|
9558
|
-
|
|
9559
|
-
|
|
9560
|
-
|
|
9722
|
+
Requirement branches must stay linear and requirement-scoped. Only
|
|
9723
|
+
merges whose parent chain includes a commit from ``origin/{default_branch}``
|
|
9724
|
+
are rejected — these indicate an explicit merge/rebase that imported
|
|
9725
|
+
default-branch changes (and potentially other requirements' changes).
|
|
9726
|
+
|
|
9727
|
+
Legitimate merges (e.g. AI-assisted conflict resolution that merges
|
|
9728
|
+
two sub-feature branches) are allowed through because neither parent
|
|
9729
|
+
is an ancestor of the default branch.
|
|
9561
9730
|
"""
|
|
9562
9731
|
git = self.workspace_manager._git
|
|
9563
9732
|
|
|
9733
|
+
# Collect all unpushed merge commits first
|
|
9734
|
+
all_merge_shas: list[str] = []
|
|
9564
9735
|
try:
|
|
9565
9736
|
await git("rev-parse", "--verify", f"origin/{current_branch}", cwd=workspace_path)
|
|
9566
9737
|
out = await git(
|
|
@@ -9575,8 +9746,42 @@ class RuntimeDaemon:
|
|
|
9575
9746
|
)
|
|
9576
9747
|
except RuntimeError:
|
|
9577
9748
|
return []
|
|
9749
|
+
all_merge_shas = [line.strip() for line in out.splitlines() if line.strip()]
|
|
9750
|
+
if not all_merge_shas:
|
|
9751
|
+
return []
|
|
9752
|
+
|
|
9753
|
+
# Filter: only flag merges where at least one parent is an ancestor of
|
|
9754
|
+
# origin/{default_branch}. This distinguishes "merged default branch in"
|
|
9755
|
+
# (forbidden) from "merged two local sub-branches" (legitimate).
|
|
9756
|
+
forbidden: list[str] = []
|
|
9757
|
+
for sha in all_merge_shas:
|
|
9758
|
+
try:
|
|
9759
|
+
parents_out = await git(
|
|
9760
|
+
"show", "-s", "--format=%P", sha, cwd=workspace_path,
|
|
9761
|
+
)
|
|
9762
|
+
parents = parents_out.strip().split()
|
|
9763
|
+
except RuntimeError:
|
|
9764
|
+
# Cannot determine parents — treat as forbidden (fail-safe)
|
|
9765
|
+
forbidden.append(sha)
|
|
9766
|
+
continue
|
|
9767
|
+
|
|
9768
|
+
is_default_merge = False
|
|
9769
|
+
for parent in parents:
|
|
9770
|
+
try:
|
|
9771
|
+
await git(
|
|
9772
|
+
"merge-base", "--is-ancestor", parent,
|
|
9773
|
+
f"origin/{default_branch}", cwd=workspace_path,
|
|
9774
|
+
)
|
|
9775
|
+
# This parent is an ancestor of the default branch — the
|
|
9776
|
+
# merge imported default-branch changes → forbidden
|
|
9777
|
+
is_default_merge = True
|
|
9778
|
+
break
|
|
9779
|
+
except RuntimeError:
|
|
9780
|
+
continue # Parent is not on the default branch — OK
|
|
9781
|
+
if is_default_merge:
|
|
9782
|
+
forbidden.append(sha)
|
|
9578
9783
|
|
|
9579
|
-
return
|
|
9784
|
+
return forbidden
|
|
9580
9785
|
|
|
9581
9786
|
async def _ai_resolve_conflicts(
|
|
9582
9787
|
self, workspace_path: Path, default_branch: str, task: TaskInfo,
|
|
@@ -10051,6 +10256,37 @@ class RuntimeDaemon:
|
|
|
10051
10256
|
for a in self.agents
|
|
10052
10257
|
]
|
|
10053
10258
|
|
|
10259
|
+
async def _cleanup_stale_tmp_dirs(self) -> None:
|
|
10260
|
+
"""Remove stale Copilot/Claude temp dirs from prior OOM-killed daemon runs.
|
|
10261
|
+
|
|
10262
|
+
Both GitHub Copilot and Claude agents use mkdtemp(prefix="copilot-…") /
|
|
10263
|
+
mkdtemp(prefix="claude-…") directories. When the daemon is killed before
|
|
10264
|
+
cleanup these directories accumulate in /tmp. We clean anything older
|
|
10265
|
+
than 1 hour at startup.
|
|
10266
|
+
"""
|
|
10267
|
+
import glob
|
|
10268
|
+
import tempfile
|
|
10269
|
+
import time as _time
|
|
10270
|
+
|
|
10271
|
+
tmp_dir = tempfile.gettempdir()
|
|
10272
|
+
patterns = [
|
|
10273
|
+
"copilot-*",
|
|
10274
|
+
"claude-*",
|
|
10275
|
+
]
|
|
10276
|
+
cutoff = _time.time() - 3600 # 1 hour
|
|
10277
|
+
|
|
10278
|
+
for pattern in patterns:
|
|
10279
|
+
for d in glob.glob(os.path.join(tmp_dir, pattern)):
|
|
10280
|
+
try:
|
|
10281
|
+
if not os.path.isdir(d):
|
|
10282
|
+
continue
|
|
10283
|
+
mtime = os.path.getmtime(d)
|
|
10284
|
+
if mtime < cutoff:
|
|
10285
|
+
self.workspace_manager._safe_rmtree(Path(d))
|
|
10286
|
+
logger.info("Cleaned up stale agent temp dir: %s", d)
|
|
10287
|
+
except Exception as e:
|
|
10288
|
+
logger.debug("Could not clean temp dir %s: %s", d, e)
|
|
10289
|
+
|
|
10054
10290
|
async def _shutdown_gracefully(self):
|
|
10055
10291
|
"""Graceful shutdown."""
|
|
10056
10292
|
logger.info("Shutting down daemon...")
|
|
@@ -92,6 +92,18 @@ def _supports_color() -> bool:
|
|
|
92
92
|
return False
|
|
93
93
|
if os.environ.get("TERM") in ("dumb", ""):
|
|
94
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
|
|
95
107
|
return True
|
|
96
108
|
|
|
97
109
|
|
|
@@ -117,17 +129,17 @@ def _print_banner() -> None:
|
|
|
117
129
|
from forgexa_cli import __version__
|
|
118
130
|
|
|
119
131
|
if _supports_color():
|
|
120
|
-
|
|
132
|
+
blue = _brand_color()
|
|
121
133
|
reset = "\033[0m"
|
|
122
|
-
logo = f"{
|
|
123
|
-
tagline = f" {
|
|
134
|
+
logo = f"{blue}{_LOGO_ART}{reset}"
|
|
135
|
+
tagline = f" {blue}AI-Driven Software Factory · v{__version__}{reset}"
|
|
124
136
|
else:
|
|
125
137
|
logo = _LOGO_ART
|
|
126
138
|
tagline = f" AI-Driven Software Factory · v{__version__}"
|
|
127
139
|
|
|
128
|
-
print(logo)
|
|
129
|
-
print(tagline)
|
|
130
|
-
print()
|
|
140
|
+
print(logo, file=sys.stderr)
|
|
141
|
+
print(tagline, file=sys.stderr)
|
|
142
|
+
print(file=sys.stderr)
|
|
131
143
|
|
|
132
144
|
|
|
133
145
|
# ── Config file helpers (~/.forgexa/config) ──
|
|
@@ -225,6 +237,7 @@ def _refresh_access_token() -> bool:
|
|
|
225
237
|
try:
|
|
226
238
|
with httpx.Client(timeout=15) as client:
|
|
227
239
|
resp = client.post(url, json={"refresh_token": refresh_token})
|
|
240
|
+
resp.raise_for_status()
|
|
228
241
|
payload = resp.json()
|
|
229
242
|
except Exception:
|
|
230
243
|
return False
|
|
@@ -502,9 +515,19 @@ def _write_update_cache(latest_version: str) -> None:
|
|
|
502
515
|
|
|
503
516
|
|
|
504
517
|
def _version_is_newer(candidate: str, reference: str) -> bool:
|
|
505
|
-
"""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
|
+
|
|
506
526
|
def _parts(v: str) -> tuple[int, ...]:
|
|
507
|
-
|
|
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
|
+
|
|
508
531
|
try:
|
|
509
532
|
return _parts(candidate) > _parts(reference)
|
|
510
533
|
except Exception:
|
|
@@ -683,12 +706,24 @@ def _request_json(
|
|
|
683
706
|
timeout=timeout,
|
|
684
707
|
allow_refresh=False,
|
|
685
708
|
)
|
|
686
|
-
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
|
+
)
|
|
687
718
|
print(f"Error {e.response.status_code}: {body_text}", file=sys.stderr)
|
|
688
|
-
|
|
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)
|
|
689
724
|
except httpx.RequestError as e:
|
|
690
725
|
print(f"Connection error: {e}\nServer: {_api_url()}", file=sys.stderr)
|
|
691
|
-
sys.exit(
|
|
726
|
+
sys.exit(3) # 3 → network / connection error
|
|
692
727
|
|
|
693
728
|
|
|
694
729
|
def _get(path: str) -> dict | list:
|
|
@@ -822,7 +857,12 @@ def _get_runtimes(include_all: bool = False) -> list[dict]:
|
|
|
822
857
|
|
|
823
858
|
|
|
824
859
|
def _get_runtimes_silent(include_all: bool = False) -> list[dict]:
|
|
825
|
-
"""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
|
+
"""
|
|
826
866
|
import httpx
|
|
827
867
|
|
|
828
868
|
path = "/runtimes" if include_all else "/runtimes/me"
|
|
@@ -830,6 +870,11 @@ def _get_runtimes_silent(include_all: bool = False) -> list[dict]:
|
|
|
830
870
|
try:
|
|
831
871
|
with httpx.Client(headers=_headers(), timeout=10) as client:
|
|
832
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()
|
|
833
878
|
result = resp.json() if resp.content else []
|
|
834
879
|
return result if isinstance(result, list) else []
|
|
835
880
|
except Exception:
|
|
@@ -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
|