diff --git a/.github/workflows/windows-distribution.yml b/.github/workflows/windows-distribution.yml index 6c9026a..d501036 100644 --- a/.github/workflows/windows-distribution.yml +++ b/.github/workflows/windows-distribution.yml @@ -38,11 +38,15 @@ jobs: working-directory: packaging run: pyinstaller --clean --noconfirm --distpath ../dist --workpath ../build interlink.spec + - name: Add temporary interlink compatibility alias + shell: pwsh + run: Copy-Item .\dist\interly.exe .\dist\interlink.exe + - name: Smoke test executable shell: pwsh run: | - $version = & .\dist\interlink.exe --version - if ($LASTEXITCODE -ne 0 -or $version -ne "Interly 0.5.0") { + $version = & .\dist\interly.exe --version + if ($LASTEXITCODE -ne 0 -or $version -ne "Interly 0.5.1") { throw "Standalone executable smoke test failed: $version" } @@ -52,25 +56,26 @@ jobs: - name: Build Windows installer shell: pwsh run: | - & "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" "/DMyAppVersion=0.5.0" packaging\interly.iss + & "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" "/DMyAppVersion=0.5.1" packaging\interly.iss if ($LASTEXITCODE -ne 0) { throw "Inno Setup failed." } - name: Generate WinGet manifests shell: pwsh run: | $sha = (Get-FileHash .\dist\InterlySetup-x64.exe -Algorithm SHA256).Hash - $url = "https://github.com/interlinkglobal/Interly/releases/download/v0.5.0/InterlySetup-x64.exe" - python packaging\render_winget.py --version 0.5.0 --installer-url $url --sha256 $sha --output dist\winget - Compress-Archive -Path dist\winget\* -DestinationPath dist\Interly-0.5.0-winget-manifests.zip + $url = "https://github.com/interlinkglobal/Interly/releases/download/v0.5.1/InterlySetup-x64.exe" + python packaging\render_winget.py --version 0.5.1 --installer-url $url --sha256 $sha --output dist\winget + Compress-Archive -Path dist\winget\* -DestinationPath dist\Interly-0.5.1-winget-manifests.zip - name: Upload build artifact uses: actions/upload-artifact@v4 with: name: Interly-Windows-x64 path: | + dist/interly.exe dist/interlink.exe dist/InterlySetup-x64.exe - dist/Interly-0.5.0-winget-manifests.zip + dist/Interly-0.5.1-winget-manifests.zip - name: Publish GitHub release if: startsWith(github.ref, 'refs/tags/v') @@ -78,6 +83,7 @@ jobs: with: generate_release_notes: true files: | + dist/interly.exe dist/interlink.exe dist/InterlySetup-x64.exe - dist/Interly-0.5.0-winget-manifests.zip + dist/Interly-0.5.1-winget-manifests.zip diff --git a/README.md b/README.md index aafd62a..bc0f30a 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ winget install --id InterlinkGlobal.Interly --exact Then launch Interly from any Command Prompt: ```cmd -interlink +interly ``` The WinGet package contains a standalone Windows executable and does not require Python, pipx, @@ -48,9 +48,9 @@ Upgrade later with: update ``` -Run `update` at any Interly `You:` prompt. Interly compares the installed Git commit with its -development branch, upgrades the existing pipx installation only when needed, and asks you to -restart Interly after a successful update. +Run `update` at any Interly `You:` prompt. Standalone installations try WinGet first and fall +back to a SHA-256-verified GitHub Release installer while catalogue publication is pending. +Development installations upgrade through pipx. Restart Interly after a successful update. ## Current capabilities diff --git a/install.ps1 b/install.ps1 index 528b595..3e97526 100644 --- a/install.ps1 +++ b/install.ps1 @@ -198,21 +198,21 @@ if ($LASTEXITCODE -ne 0) { throw "Interly installation failed." } -$interlinkPath = $null -$interlink = Get-Command interlink -ErrorAction SilentlyContinue -if (-not $interlink) { +$interlyPath = $null +$interly = Get-Command interly -ErrorAction SilentlyContinue +if (-not $interly) { $pipxBin = & $pythonCommand @pythonArguments -m pipx environment --value PIPX_BIN_DIR - $candidate = Join-Path $pipxBin.Trim() "interlink.exe" + $candidate = Join-Path $pipxBin.Trim() "interly.exe" if (Test-Path $candidate) { - $interlinkPath = $candidate + $interlyPath = $candidate } } else { - $interlinkPath = $interlink.Source + $interlyPath = $interly.Source } -if (-not $interlinkPath) { - throw "Interly installed, but interlink.exe could not be found. Open a new terminal and run interlink." +if (-not $interlyPath) { + throw "Interly installed, but interly.exe could not be found. Open a new terminal and run interly." } Write-Step "Installation verified. Launching Interly" -& $interlinkPath +& $interlyPath diff --git a/packaging/interlink.spec b/packaging/interlink.spec index 6b68cd5..89aec78 100644 --- a/packaging/interlink.spec +++ b/packaging/interlink.spec @@ -20,7 +20,7 @@ exe = EXE( a.binaries, a.datas, [], - name="interlink", + name="interly", debug=False, bootloader_ignore_signals=False, strip=False, diff --git a/packaging/interly.iss b/packaging/interly.iss index 771a932..47d5aba 100644 --- a/packaging/interly.iss +++ b/packaging/interly.iss @@ -1,10 +1,10 @@ #ifndef MyAppVersion - #define MyAppVersion "0.5.0" + #define MyAppVersion "0.5.1" #endif #define MyAppName "Interly" #define MyAppPublisher "Interlink Global Technologies" -#define MyAppExeName "interlink.exe" +#define MyAppExeName "interly.exe" [Setup] AppId={{6CB4E41F-F5A4-4F9D-B05F-C0565EBD99E7} @@ -27,6 +27,7 @@ ChangesEnvironment=yes UninstallDisplayIcon={app}\{#MyAppExeName} [Files] +Source: "..\dist\interly.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "..\dist\interlink.exe"; DestDir: "{app}"; Flags: ignoreversion [Icons] diff --git a/pyproject.toml b/pyproject.toml index 61277e4..b37eecb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "interly" -version = "0.5.0" +version = "0.5.1" description = "A permission-aware Windows computer agent powered by Groq" readme = "README.md" license = "MIT" @@ -32,6 +32,7 @@ distribution = ["pyinstaller==6.21.0"] [project.scripts] brocode = "computer_agent.__main__:main" +interly = "computer_agent.__main__:main" interlink = "computer_agent.__main__:main" [project.urls] diff --git a/src/computer_agent/__init__.py b/src/computer_agent/__init__.py index 7faffa1..f1a9caa 100644 --- a/src/computer_agent/__init__.py +++ b/src/computer_agent/__init__.py @@ -1,3 +1,3 @@ """Local computer agent package.""" -__version__ = "0.5.0" +__version__ = "0.5.1" diff --git a/src/computer_agent/chat.py b/src/computer_agent/chat.py index a67f5ad..4d76911 100644 --- a/src/computer_agent/chat.py +++ b/src/computer_agent/chat.py @@ -1,6 +1,7 @@ """The Stage 1 terminal conversation loop.""" from collections.abc import Callable +from time import monotonic from typing import Any from playwright.sync_api import Error as PlaywrightError @@ -38,6 +39,8 @@ "browser_read_page": 5, } MAX_MODEL_ROUNDS_PER_MESSAGE = 12 +SET_FREE_COMMAND = "set-free" +MAX_SET_FREE_MINUTES = 30 def run_chat( @@ -51,6 +54,7 @@ def run_chat( """Chat until the user exits, then return the conversation history.""" messages: list[dict[str, Any]] = [] session_approvals: set[str] = set() + free_until: float | None = None write_output("Interlink is ready. Type 'exit' to stop.") while True: @@ -84,6 +88,26 @@ def run_chat( write_output(update_interly()) continue + if user_text.casefold().startswith(f"{SET_FREE_COMMAND} "): + value = user_text[len(SET_FREE_COMMAND) :].strip() + try: + minutes = int(value) + if minutes < 0 or minutes > MAX_SET_FREE_MINUTES: + raise ValueError + except ValueError: + write_output("Usage: set-free <1-30 whole minutes>, or set-free 0 to disable.") + continue + if minutes == 0: + free_until = None + write_output("Automatic command approval disabled.") + else: + free_until = monotonic() + minutes * 60 + write_output( + f"Automatic command approval enabled for {minutes} minute" + f"{'s' if minutes != 1 else ''}. Emergency stop remains active." + ) + continue + if not user_text: continue if emergency_stop and emergency_stop.requested(): @@ -144,7 +168,11 @@ def run_chat( if warning: write_output(warning) approval_group = SESSION_APPROVAL_GROUPS.get(request.name) - if approval_group in session_approvals: + free_active = free_until is not None and monotonic() < free_until + if free_until is not None and not free_active: + free_until = None + write_output("Automatic command approval period ended; prompts restored.") + if free_active or approval_group in session_approvals: approved = True else: if request.name in SENSITIVE_LOCAL_TOOLS: diff --git a/src/computer_agent/updater.py b/src/computer_agent/updater.py index 0dc95bd..4abf9b3 100644 --- a/src/computer_agent/updater.py +++ b/src/computer_agent/updater.py @@ -1,13 +1,18 @@ """Self-update support for standalone and pipx installations.""" +import hashlib import json import shutil import subprocess import sys +import tempfile from importlib.metadata import PackageNotFoundError, distribution +from pathlib import Path import httpx +from computer_agent import __version__ + PACKAGE_NAME = "interly" WINGET_PACKAGE_ID = "InterlinkGlobal.Interly" UPDATE_BRANCH = "agent/next-ten-roadmap" @@ -15,6 +20,9 @@ "https://api.github.com/repos/interlinkglobal/Interly/git/ref/heads/" f"{UPDATE_BRANCH}" ) +LATEST_RELEASE_URL = "https://api.github.com/repos/interlinkglobal/Interly/releases/latest" +INSTALLER_NAME = "InterlySetup-x64.exe" +MAX_INSTALLER_BYTES = 200 * 1024 * 1024 def installed_commit() -> str | None: @@ -88,38 +96,113 @@ def update_interly() -> str: def update_standalone() -> str: - """Ask WinGet to upgrade an installed standalone Interly package.""" + """Try WinGet, then use a verified GitHub Release installer as fallback.""" winget = shutil.which("winget") - if not winget: - return ( - "Update unavailable: Windows Package Manager was not found. " - "The current installation was not changed." - ) + if winget: + try: + completed = subprocess.run( + [ + winget, + "upgrade", + "--id", + WINGET_PACKAGE_ID, + "--exact", + "--accept-package-agreements", + "--accept-source-agreements", + "--disable-interactivity", + ], + capture_output=True, + text=True, + errors="replace", + timeout=300, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + completed = None + if completed is not None and completed.returncode == 0: + output = (completed.stdout or completed.stderr).strip() + return ( + f"WinGet finished checking Interly.\n{output}\n" + "Type exit, then run interly again to use an installed update." + ) + try: - completed = subprocess.run( - [ - winget, - "upgrade", - "--id", - WINGET_PACKAGE_ID, - "--exact", - "--accept-package-agreements", - "--accept-source-agreements", - "--disable-interactivity", - ], - capture_output=True, - text=True, - errors="replace", - timeout=300, - check=False, + version, installer_url, expected_digest = latest_release_installer() + if version == __version__: + return f"Interly is already current ({version})." + installer = download_release_installer(installer_url, expected_digest) + subprocess.Popen( + [str(installer), "/SILENT", "/NORESTART", "/CLOSEAPPLICATIONS"], + close_fds=True, ) - except (OSError, subprocess.TimeoutExpired) as error: - return f"Update failed safely: {error}" - - output = (completed.stdout or completed.stderr).strip() - if completed.returncode != 0: - return f"WinGet could not update Interly; nothing was removed.\n{output}" + except (httpx.HTTPError, OSError, RuntimeError, subprocess.SubprocessError) as error: + return f"Update failed safely; the current installation was kept: {error}" return ( - f"WinGet finished checking Interly.\n{output}\n" - "Type exit, then run interlink again to use an installed update." + f"Interly {version} was downloaded, verified, and its installer was started. " + "Finish the installer, type exit, then run interly again." ) + + +def latest_release_installer() -> tuple[str, str, str]: + """Return the latest release version, installer URL, and GitHub SHA-256 digest.""" + response = httpx.get( + LATEST_RELEASE_URL, + headers={"Accept": "application/vnd.github+json", "User-Agent": "Interly updater"}, + timeout=15.0, + ) + response.raise_for_status() + release = response.json() + version = str(release.get("tag_name", "")).removeprefix("v") + if not version: + raise RuntimeError("GitHub returned no release version.") + asset = next( + (item for item in release.get("assets", []) if item.get("name") == INSTALLER_NAME), + None, + ) + if not asset: + raise RuntimeError("The latest release has no Windows installer.") + url = str(asset.get("browser_download_url", "")) + digest = str(asset.get("digest", "")) + expected_prefix = "https://github.com/interlinkglobal/Interly/releases/download/" + if not url.startswith(expected_prefix) or not digest.startswith("sha256:"): + raise RuntimeError("The release installer could not be verified.") + return version, url, digest.removeprefix("sha256:").casefold() + + +def download_release_installer(url: str, expected_digest: str) -> Path: + """Download a bounded installer to a temporary file and verify its SHA-256 digest.""" + destination = Path(tempfile.gettempdir()) / "InterlySetup-update-x64.exe" + temporary = destination.with_suffix(".download") + digest = hashlib.sha256() + size = 0 + try: + with temporary.open("wb") as output, httpx.stream( + "GET", + url, + headers={"User-Agent": "Interly updater"}, + follow_redirects=True, + timeout=60.0, + ) as response: + response.raise_for_status() + for chunk in response.iter_bytes(): + size += len(chunk) + if size > MAX_INSTALLER_BYTES: + raise RuntimeError("The release installer exceeded the size limit.") + digest.update(chunk) + output.write(chunk) + if digest.hexdigest() != expected_digest: + raise RuntimeError("The release installer failed SHA-256 verification.") + temporary.replace(destination) + return destination + except (httpx.HTTPError, OSError, RuntimeError): + temporary.unlink(missing_ok=True) + raise + + +def digest_file(path: Path) -> str: + """Return a file's lowercase SHA-256 digest.""" + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() diff --git a/tests/test_chat.py b/tests/test_chat.py index 0081e8b..fa0d890 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -105,6 +105,89 @@ def reply(self, _messages: list[dict[str, object]]) -> ModelTurn: assert "denied" in str(tool_message["content"]) +@patch("computer_agent.chat.execute_tool", return_value="done") +@patch("computer_agent.chat.monotonic", return_value=100.0) +def test_set_free_temporarily_skips_approval_prompts( + _monotonic: object, execute: object +) -> None: + class ToolCallingModel: + def __init__(self) -> None: + self.calls = 0 + + def reply(self, _messages: list[dict[str, object]]) -> ModelTurn: + self.calls += 1 + if self.calls == 1: + request = ToolRequest(id="call-1", name="get_current_time", arguments="{}") + return ModelTurn( + content=None, + tool_requests=[request], + assistant_message={"role": "assistant", "content": None}, + ) + return ModelTurn( + content="Finished", + tool_requests=[], + assistant_message={"role": "assistant", "content": "Finished"}, + ) + + prompts: list[str] = [] + answers = iter(["set-free 5", "What time is it?", "exit"]) + + run_chat( + ToolCallingModel(), + lambda prompt: prompts.append(prompt) or next(answers), + lambda _text: None, + ) + + assert execute.call_count == 1 + assert not any(prompt.startswith("Allow?") for prompt in prompts) + + +@patch("computer_agent.chat.execute_tool") +def test_set_free_zero_restores_approval_prompts(execute: object) -> None: + class ToolCallingModel: + def __init__(self) -> None: + self.calls = 0 + + def reply(self, _messages: list[dict[str, object]]) -> ModelTurn: + self.calls += 1 + if self.calls == 1: + request = ToolRequest(id="call-1", name="get_current_time", arguments="{}") + return ModelTurn( + content=None, + tool_requests=[request], + assistant_message={"role": "assistant", "content": None}, + ) + return ModelTurn( + content="Finished", + tool_requests=[], + assistant_message={"role": "assistant", "content": "Finished"}, + ) + + answers = iter(["set-free 5", "set-free 0", "What time is it?", "n", "exit"]) + history = run_chat( + ToolCallingModel(), + lambda _prompt: next(answers), + lambda _text: None, + ) + + assert execute.call_count == 0 + tool_message = next(message for message in history if message["role"] == "tool") + assert "denied" in str(tool_message["content"]) + + +def test_set_free_rejects_values_above_thirty_minutes() -> None: + output: list[str] = [] + answers = iter(["set-free 31", "exit"]) + + run_chat( + OfflineModel(), + lambda _prompt: next(answers), + output.append, + ) + + assert "Usage: set-free <1-30 whole minutes>, or set-free 0 to disable." in output + + @patch("computer_agent.chat.execute_tool", return_value="safe web result") def test_web_access_can_be_approved_for_session(execute: object) -> None: class WebModel: diff --git a/tests/test_cli.py b/tests/test_cli.py index 3747e5d..f948bcd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -29,8 +29,8 @@ def test_working_groq_key_is_validated_before_save(groq: object, save: object) - save.assert_called_once_with("new-key") -@patch("computer_agent.__main__.sys.argv", ["interlink", "--version"]) +@patch("computer_agent.__main__.sys.argv", ["interly", "--version"]) def test_version_flag_exits_before_setup(capsys: object) -> None: main() - assert "Interly 0.5.0" in capsys.readouterr().out + assert "Interly 0.5.1" in capsys.readouterr().out diff --git a/tests/test_distribution.py b/tests/test_distribution.py index 9d466ae..02ebd20 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -16,9 +16,9 @@ def load_renderer() -> object: def test_winget_renderer_creates_valid_release_manifests(tmp_path: Path) -> None: renderer = load_renderer() sha256 = "ab" * 32 - url = "https://github.com/interlinkglobal/Interly/releases/download/v0.5.0/InterlySetup-x64.exe" + url = "https://github.com/interlinkglobal/Interly/releases/download/v0.5.1/InterlySetup-x64.exe" - paths = renderer.render("0.5.0", url, sha256, tmp_path) + paths = renderer.render("0.5.1", url, sha256, tmp_path) assert len(paths) == 3 installer = (tmp_path / "InterlinkGlobal.Interly.installer.yaml").read_text() @@ -33,6 +33,8 @@ def test_distribution_files_keep_standalone_and_fallback_routes() -> None: readme = (ROOT / "README.md").read_text() assert "pyinstaller" in workflow.casefold() + assert "dist/interly.exe" in workflow + assert "dist/interlink.exe" in workflow assert "InterlySetup-x64.exe" in workflow assert "winget install --id InterlinkGlobal.Interly" in readme assert "install.ps1" in readme diff --git a/tests/test_installer.py b/tests/test_installer.py index 742438f..b12e8ad 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -14,7 +14,7 @@ def test_windows_installer_bootstraps_required_components() -> None: assert "-m pipx ensurepath" in script assert "-m pipx install --force $InterlySpec" in script assert "agent/next-ten-roadmap" in script - assert "& $interlinkPath" in script + assert "& $interlyPath" in script def test_readme_exposes_one_cmd_compatible_install_command() -> None: diff --git a/tests/test_updater.py b/tests/test_updater.py index 753e84e..f7da19f 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -1,8 +1,15 @@ +import hashlib import json from types import SimpleNamespace from unittest.mock import patch -from computer_agent.updater import installed_commit, update_interly, update_standalone +from computer_agent.updater import ( + digest_file, + installed_commit, + latest_release_installer, + update_interly, + update_standalone, +) @patch("computer_agent.updater.distribution") @@ -73,3 +80,68 @@ def test_standalone_update_uses_winget(run: object, _which: object) -> None: timeout=300, check=False, ) + + +@patch("computer_agent.updater.subprocess.Popen") +@patch("computer_agent.updater.download_release_installer") +@patch("computer_agent.updater.latest_release_installer") +@patch("computer_agent.updater.shutil.which", return_value="C:/Windows/winget.exe") +@patch("computer_agent.updater.subprocess.run") +def test_standalone_falls_back_to_verified_release_when_winget_cannot_find_package( + run: object, + _which: object, + release: object, + download: object, + popen: object, +) -> None: + run.return_value = SimpleNamespace(returncode=1, stdout="", stderr="No package found") + release.return_value = ( + "0.5.2", + "https://github.com/interlinkglobal/Interly/releases/download/v0.5.2/InterlySetup-x64.exe", + "ab" * 32, + ) + download.return_value = "C:/Temp/InterlySetup-update-x64.exe" + + result = update_standalone() + + assert "downloaded, verified" in result + download.assert_called_once() + popen.assert_called_once_with( + [ + "C:/Temp/InterlySetup-update-x64.exe", + "/SILENT", + "/NORESTART", + "/CLOSEAPPLICATIONS", + ], + close_fds=True, + ) + + +@patch("computer_agent.updater.httpx.get") +def test_latest_release_requires_official_installer_and_sha256(get: object) -> None: + get.return_value.json.return_value = { + "tag_name": "v0.5.1", + "assets": [ + { + "name": "InterlySetup-x64.exe", + "browser_download_url": ( + "https://github.com/interlinkglobal/Interly/releases/download/" + "v0.5.1/InterlySetup-x64.exe" + ), + "digest": f"sha256:{'ab' * 32}", + } + ], + } + + assert latest_release_installer() == ( + "0.5.1", + "https://github.com/interlinkglobal/Interly/releases/download/v0.5.1/InterlySetup-x64.exe", + "ab" * 32, + ) + + +def test_digest_file_returns_sha256(tmp_path: object) -> None: + path = tmp_path / "installer.exe" + path.write_bytes(b"verified installer") + + assert digest_file(path) == hashlib.sha256(b"verified installer").hexdigest()