diff --git a/.github/workflows/desktop-notarized.yml b/.github/workflows/desktop-notarized.yml new file mode 100644 index 00000000..d97b0824 --- /dev/null +++ b/.github/workflows/desktop-notarized.yml @@ -0,0 +1,107 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +name: Notarized macOS installers + +on: + workflow_dispatch: + inputs: + notarize: + description: 'Sign and submit this exact release tag to Apple (requires protected environment approval)' + type: boolean + required: true + default: false + +permissions: + contents: read + +jobs: + macos: + if: >- + github.event_name == 'workflow_dispatch' && inputs.notarize == true && + github.repository == 'fireflyframework/firefly-weave' && startsWith(github.ref, 'refs/tags/v') + environment: macos-release-signing + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + target: aarch64-apple-darwin + - os: macos-15-intel + target: x86_64-apple-darwin + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@v6 + with: + version: '0.11.19' + python-version: '3.12.13' + - name: Verify exact release source without credentials + run: uv run --no-project python desktop/scripts/notarize_macos.py --verify-source + - uses: actions/setup-node@v4 + with: + node-version: '24.15.0' + cache: npm + cache-dependency-path: | + studio/package-lock.json + desktop/package-lock.json + - uses: dtolnay/rust-toolchain@1.96.0 + with: + components: rustfmt,clippy + targets: ${{ matrix.target }} + - name: Prepare locked tools + run: | + uv sync --locked --extra studio + uv pip install pyinstaller==6.16.0 + npm ci --prefix studio + npm ci --prefix desktop + - name: Verify source and compile Studio before loading credentials + run: | + uv run --no-sync python -m pytest tests/unit/test_macos_seal.py tests/unit/test_macos_notarization.py -q + npm run check --prefix studio + npm test --prefix studio + npm run build --prefix studio + cargo fmt --check --manifest-path desktop/src-tauri/Cargo.toml + cargo clippy --locked --manifest-path desktop/src-tauri/Cargo.toml -- -D warnings + cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml + - name: Sign, notarize, verify and collect exact installers + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }} + run: uv run --no-sync python desktop/scripts/notarize_macos.py --target ${{ matrix.target }} + - name: Remove temporary signing credentials even after interruption + if: always() + run: uv run --no-project python desktop/scripts/notarize_macos.py --cleanup + - name: Retain verified notarized installers only + uses: actions/upload-artifact@v4 + with: + name: weave-studio-${{ github.ref_name }}-${{ matrix.target }}-notarized + if-no-files-found: error + path: desktop/work/notarized-assets/** +# This opt-in workflow retains artifacts; it never creates or modifies a release. diff --git a/CHANGELOG.md b/CHANGELOG.md index 42477526..0aa7793a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ SPDX-License-Identifier: Apache-2.0 # Changelog +## 0.1.0a14 + +- Add a detached Docker development platform through `weave platform up`, + reusing owned databases, identity setup, workspace creation, and user roles. + Keep the foreground API mode available for existing installations. +- Prepare an explicit Developer ID signing and notarization path for macOS + releases. Apple credentials are required; ordinary builds remain ad-hoc signed. +- Pin the Agentic and Files 0.1.6 packages to core 0.1.0a14 without changing their + execution behavior or the database schema. + ## 0.1.0a13 - Retry explicit platform capacity rejections while a worker reads its task diff --git a/README.md b/README.md index 89d3abfd..bbd55fc1 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ maps service tasks, user tasks, gateways, and timers to Weave steps. ## What you get -The **v0.1.0a13 alpha** release provides the **API, CLI, Python SDK, and Studio** +The **v0.1.0a14 alpha** release provides the **API, CLI, Python SDK, and Studio** visual workspace, with human-task inboxes, email conversations, execution management, and administration of people and access. Run Weave as a standalone service or embed it in another product. @@ -81,7 +81,7 @@ See the illustrated [AI workflow guide](docs/guides/ai-workers.md) and Read the [capability matrix](docs/capabilities.md) for tested boundaries and live-provider checks that remain environment-specific. -Studio runs in your browser from the [installed CLI](docs/guides/studio.md#install-the-alpha13-browser-application) +Studio runs in your browser from the [installed CLI](docs/guides/studio.md#install-the-alpha14-browser-application) or as a [desktop app](docs/guides/desktop.md). The macOS desktop bundles are ad-hoc signed, not Developer ID signed or notarized, so macOS may ask you to approve them; do not use the alpha5 macOS installers, which were damaged. The @@ -119,7 +119,7 @@ integration code. Each has its own guide, so you can stop at the result you need ## Install and discover the CLI On macOS, Linux, or WSL, install **Python 3.12 or newer** with `venv` support, -then run this block in Bash or Zsh. It installs the pinned **v0.1.0a13 alpha** +then run this block in Bash or Zsh. It installs the pinned **v0.1.0a14 alpha** into your user account without `sudo`, Git, or Docker: ```sh @@ -127,12 +127,12 @@ into your user account without `sudo`, Git, or Docker: # Stop if downloading the installer fails. set -o pipefail curl --proto '=https' --tlsv1.2 -fsSL \ - https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a13/install.sh \ - | sh -s -- --version v0.1.0a13 + https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a14/install.sh \ + | sh -s -- --version v0.1.0a14 ) ``` -Expected: `Installed Firefly Weave 0.1.0a13:` followed by the command's path. Then +Expected: `Installed Firefly Weave 0.1.0a14:` followed by the command's path. Then make the default command directory available in this terminal and look around: ```sh @@ -146,7 +146,7 @@ weave help workflow weave docs platform ``` -Expected: `Firefly Weave 0.1.0a13`, the command overview, the `workflow` +Expected: `Firefly Weave 0.1.0a14`, the command overview, the `workflow` commands, and the address of the platform guide. You do not need to learn every command first: help explains each family and its next steps. The [installation guide](docs/installation.md) covers choosing Python, a permanent @@ -179,27 +179,27 @@ Compose files and setup helpers. Clone the tag that matches the CLI: ```sh # Keep the platform files at the same version as the CLI. -git clone --branch v0.1.0a13 --single-branch https://github.com/fireflyframework/firefly-weave.git +git clone --branch v0.1.0a14 --single-branch https://github.com/fireflyframework/firefly-weave.git cd firefly-weave -# Check prerequisites, then prepare private settings and dependencies once. +# Check prerequisites, then start a persistent Docker platform and a sign-in account. weave platform doctor -weave platform setup +weave platform up --username developer -# Keep this terminal open while the API runs. -weave platform start +# Confirm readiness and copy the printed sign-in command. +weave platform status ``` -Expected: `doctor` reports your CLI version, the checkout, and the Docker -context; `setup` finishes without errors; and `start` keeps printing API logs. -In a second terminal at that checkout, run `weave platform status`, then -`weave platform demo` to save a first real run, and open the printed `/docs` -address to explore the API. +Expected: the API and Keycloak are ready, the example workflow succeeded, and +`up` prints a generated password once. Docker keeps the API running after you +close the terminal. Follow the printed `weave auth setup` command, sign in with +your new account, then run `weave studio`. -To sign in as a person, open Studio against it, and run REST calls, follow -steps 5 to 8 of [the local platform guide](docs/guides/local-platform.md). For a -shared installation, start with -[the deployment map](docs/operations/remote-deployment.md). +The [Docker development guide](docs/guides/docker-development.md) explains each +step, the architecture, stopping and resuming, and administrator roles. Existing +foreground installations continue to use `weave platform start`; see the +[individual setup steps](docs/guides/local-platform.md). For a shared +installation, start with [the deployment map](docs/operations/remote-deployment.md). ## Continue when you need more @@ -265,7 +265,7 @@ path, and the detailed diagrams. ## Current release and limits -The recommended installation is **v0.1.0a13**, an **alpha** release. Download +The recommended installation is **v0.1.0a14**, an **alpha** release. Download packages and checksums from [GitHub Releases](https://github.com/fireflyframework/firefly-weave/releases). The documentation on a branch describes the source on that branch; a release tag diff --git a/desktop/RELEASE.md b/desktop/RELEASE.md index 48067e0a..81a912e8 100644 --- a/desktop/RELEASE.md +++ b/desktop/RELEASE.md @@ -17,16 +17,16 @@ SPDX-License-Identifier: Apache-2.0 --> # Desktop release versions -The alpha13 product version is `0.1.0-alpha.13` in npm, Cargo and Tauri. It corresponds -to Python `0.1.0a13` and the repository prerelease tag `v0.1.0a13`. +The alpha14 product version is `0.1.0-alpha.14` in npm, Cargo and Tauri. It corresponds +to Python `0.1.0a14` and the repository prerelease tag `v0.1.0a14`. -Windows MSI uses the explicit numeric version `0.1.13`. Tauri's pinned bundler rejects +Windows MSI uses the explicit numeric version `0.1.14`. Tauri's pinned bundler rejects nonnumeric prerelease identifiers when deriving MSI versions; its `windows.wix.version` override supplies the valid numeric installer version while filenames retain the product version. MSI compares only the first three fields. Future desktop alpha and stable releases must advance this numeric installer counter: the stable product `0.1.0` must not reset the MSI counter to `0.1.0`. The macOS internal bundle version also uses -numeric `0.1.13`; its displayed product version remains the alpha product version. +numeric `0.1.14`; its displayed product version remains the alpha product version. The Desktop installers workflow builds on four native runners and is dispatchable by an exact branch/tag ref. Versioned, target-specific unsigned artifacts contain only diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 56ca0a9f..7a07f3cf 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@firefly-weave/desktop", - "version": "0.1.0-alpha.13", + "version": "0.1.0-alpha.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@firefly-weave/desktop", - "version": "0.1.0-alpha.13", + "version": "0.1.0-alpha.14", "license": "Apache-2.0", "devDependencies": { "@tauri-apps/cli": "2.12.1" diff --git a/desktop/package.json b/desktop/package.json index 4386f089..913c189f 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@firefly-weave/desktop", - "version": "0.1.0-alpha.13", + "version": "0.1.0-alpha.14", "private": true, "license": "Apache-2.0", "scripts": { diff --git a/desktop/scripts/build_sidecar.py b/desktop/scripts/build_sidecar.py index 2f511dea..6ede5927 100644 --- a/desktop/scripts/build_sidecar.py +++ b/desktop/scripts/build_sidecar.py @@ -26,6 +26,17 @@ from pathlib import Path +def signing_arguments(identity: str | None, root: Path) -> list[str]: + if not identity: + return [] + return [ + "--codesign-identity", + identity, + "--osx-entitlements-file", + str(root / "desktop/src-tauri/entitlements.plist"), + ] + + def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--target") @@ -74,7 +85,7 @@ def main() -> None: ] identity = os.environ.get("APPLE_SIGNING_IDENTITY") if sys.platform == "darwin" and identity: - command[3:3] = ["--codesign-identity", identity] + command[3:3] = signing_arguments(identity, root) subprocess.run(command, check=True, cwd=root) extension = ".exe" if sys.platform == "win32" else "" source = work / "dist" / ("weave-studio-host" + extension) diff --git a/desktop/scripts/notarize_macos.py b/desktop/scripts/notarize_macos.py new file mode 100644 index 00000000..98abc679 --- /dev/null +++ b/desktop/scripts/notarize_macos.py @@ -0,0 +1,374 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Build notarized macOS installers only from an explicitly approved release tag.""" + +import argparse +import base64 +import hashlib +import json +import os +import re +import runpy +import shlex +import shutil +import signal +import subprocess +import sys +import tempfile +import tomllib +from contextlib import contextmanager +from pathlib import Path + +CREDENTIALS = ( + "APPLE_CERTIFICATE", + "APPLE_CERTIFICATE_PASSWORD", + "APPLE_SIGNING_IDENTITY", + "APPLE_TEAM_ID", + "APPLE_API_ISSUER", + "APPLE_API_KEY", + "APPLE_API_PRIVATE_KEY", +) +STATE = "weave-macos-signing-state.json" + + +def verify_source(root, env, version): + if ( + env.get("GITHUB_EVENT_NAME") != "workflow_dispatch" + or env.get("GITHUB_REPOSITORY") != "fireflyframework/firefly-weave" + or env.get("GITHUB_REF") != f"refs/tags/v{version}" + ): + raise RuntimeError("Signing requires an explicitly dispatched trusted release tag") + head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip() + tag = subprocess.check_output(["git", "rev-parse", f"refs/tags/v{version}^{{commit}}"], cwd=root, text=True).strip() + if head != tag or head != env.get("GITHUB_SHA"): + raise RuntimeError("Release tag, workflow commit and checked-out commit must match") + + +def validate_credentials(env): + missing = [name for name in CREDENTIALS if not env.get(name, "").strip()] + if missing: + raise RuntimeError("Missing signing credentials: " + ", ".join(missing)) + team = env["APPLE_TEAM_ID"] + if not re.fullmatch(r"[A-Z0-9]{10}", team) or not re.fullmatch( + r"Developer ID Application: .+ \(" + re.escape(team) + r"\)", env["APPLE_SIGNING_IDENTITY"] + ): + raise RuntimeError("A Developer ID Application identity from the expected team is required") + if not re.fullmatch(r"[A-Z0-9]{10}", env["APPLE_API_KEY"]): + raise RuntimeError("Invalid APPLE_API_KEY identifier") + if not re.fullmatch(r"[a-fA-F0-9-]{36}", env["APPLE_API_ISSUER"]): + raise RuntimeError("Invalid APPLE_API_ISSUER identifier") + if not env["APPLE_API_PRIVATE_KEY"].startswith("-----BEGIN PRIVATE KEY-----"): + raise RuntimeError("Invalid APPLE_API_PRIVATE_KEY format") + try: + base64.b64decode(env["APPLE_CERTIFICATE"], validate=True) + except ValueError: + raise RuntimeError("Invalid APPLE_CERTIFICATE encoding") from None + + +def quiet(command, **kwargs): + # Tool errors can contain passwords or private paths; never echo argv/output. + try: + return subprocess.run(command, check=True, capture_output=True, text=True, **kwargs) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + raise RuntimeError(f"{Path(command[0]).name} failed; signing was not completed") from None + + +def private_file(path, content): + with path.open("xb") as stream: + os.chmod(path, 0o600) + stream.write(content) + + +def cleanup(temp): + state = temp / STATE + if not state.exists(): + return + record = json.loads(state.read_text()) + directory = Path(record["directory"]) + if directory.parent != temp.resolve() or not directory.name.startswith("weave-signing-"): + raise RuntimeError("Refusing cleanup outside the owned signing directory") + try: + quiet(["security", "list-keychains", "-d", "user", "-s", *record["search_list"]], timeout=30) + if record["keychain_created"] and directory.exists(): + quiet(["security", "delete-keychain", str(directory / "signing.keychain-db")], timeout=30) + finally: + if directory.exists(): + shutil.rmtree(directory, ignore_errors=False) + state.unlink() + + +@contextmanager +def signing_session(env): + validate_credentials(env) + temp = Path(env["RUNNER_TEMP"]).resolve() + state = temp / STATE + if state.exists(): + raise RuntimeError("An earlier signing session requires cleanup") + previous = shlex.split(quiet(["security", "list-keychains", "-d", "user"], timeout=30).stdout) + directory = Path(tempfile.mkdtemp(prefix="weave-signing-", dir=temp)) + record = {"directory": str(directory), "search_list": previous, "keychain_created": False} + private_file(state, json.dumps(record).encode()) + keychain = directory / "signing.keychain-db" + password = os.urandom(32).hex() + try: + certificate = directory / "certificate.p12" + key = directory / "AuthKey.p8" + private_file(certificate, base64.b64decode(env["APPLE_CERTIFICATE"], validate=True)) + private_file(key, env["APPLE_API_PRIVATE_KEY"].encode()) + quiet(["security", "create-keychain", "-p", password, str(keychain)], timeout=30) + record["keychain_created"] = True + state.write_text(json.dumps(record)) + quiet(["security", "set-keychain-settings", "-lut", "21600", str(keychain)], timeout=30) + quiet(["security", "unlock-keychain", "-p", password, str(keychain)], timeout=30) + quiet( + [ + "security", + "import", + str(certificate), + "-k", + str(keychain), + "-P", + env["APPLE_CERTIFICATE_PASSWORD"], + "-T", + "/usr/bin/codesign", + ], + timeout=30, + ) + certificate.unlink() + quiet( + [ + "security", + "set-key-partition-list", + "-S", + "apple-tool:,apple:,codesign:", + "-s", + "-k", + password, + str(keychain), + ], + timeout=30, + ) + quiet(["security", "list-keychains", "-d", "user", "-s", str(keychain), *previous], timeout=30) + identities = quiet(["security", "find-identity", "-v", "-p", "codesigning", str(keychain)], timeout=30).stdout + found = re.findall(r'\b[0-9A-Fa-f]{40} "([^"]+)"', identities) + if found != [env["APPLE_SIGNING_IDENTITY"]]: + raise RuntimeError("Imported keychain must contain exactly the expected Developer ID Application identity") + child = {name: value for name, value in env.items() if not name.startswith("APPLE_")} + child.update({name: env[name] for name in ("APPLE_SIGNING_IDENTITY", "APPLE_API_ISSUER", "APPLE_API_KEY")}) + child["APPLE_API_KEY_PATH"] = str(key) + yield child, directory + finally: + cleanup(temp) + + +def verify_signature_details(details, identity, team, *, runtime=True): + lines = details.splitlines() + if ( + f"Authority={identity}" not in lines + or f"TeamIdentifier={team}" not in lines + or not any(line.startswith("Timestamp=") and line != "Timestamp=none" for line in lines) + or (runtime and not any("flags=" in line and "runtime" in line for line in lines)) + ): + raise RuntimeError("Developer ID signature, team, secure timestamp or hardened runtime mismatch") + + +def verify_signed(path, identity, team, *, runtime=True): + quiet(["codesign", "--verify", "--strict", "--verbose=4", str(path)], timeout=60) + details = quiet(["codesign", "--display", "--verbose=4", str(path)], timeout=60) + verify_signature_details(details.stdout + details.stderr, identity, team, runtime=runtime) + if runtime: + entitlements = quiet(["codesign", "--display", "--entitlements", ":-", str(path)], timeout=60) + import plistlib + + if entitlements.stdout.strip() and plistlib.loads(entitlements.stdout.encode()).get( + "com.apple.security.get-task-allow" + ): + raise RuntimeError("Debugging entitlement is forbidden in a release signature") + + +def accepted_submission(response): + if response.get("status") != "Accepted" or not response.get("id"): + raise RuntimeError("Apple notarization must finish with Accepted status") + return response["id"] + + +def verify_tickets(app, dmg, identity, team): + seals = runpy.run_path(str(Path(__file__).with_name("verify_macos_bundle.py"))) + seals["verify"](app) + for binary in sorted((app / "Contents/MacOS").iterdir()): + if binary.is_file(): + verify_signed(binary, identity, team) + verify_signed(app, identity, team) + verify_signed(dmg, identity, team, runtime=False) + for path in (app, dmg): + quiet(["xcrun", "stapler", "validate", str(path)], timeout=120) + quiet(["spctl", "--assess", "--type", "execute", "--verbose=4", str(app)], timeout=120) + quiet( + ["spctl", "--assess", "--type", "open", "--context", "context:primary-signature", "--verbose=4", str(dmg)], + timeout=120, + ) + expected = {path.name: digest(path) for path in (app / "Contents/MacOS").iterdir() if path.is_file()} + + def check_mounted(mounted): + actual = {path.name: digest(path) for path in (mounted / "Contents/MacOS").iterdir() if path.is_file()} + if actual != expected: + raise RuntimeError("The DMG must contain the exact verified application executables") + verify_signed(mounted, identity, team) + quiet(["xcrun", "stapler", "validate", str(mounted)], timeout=120) + quiet(["spctl", "--assess", "--type", "execute", "--verbose=4", str(mounted)], timeout=120) + + seals["verify_dmg"](dmg, trust_check=check_mounted) + + +def digest(path): + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def build(root, target, env): + config = json.loads((root / "desktop/src-tauri/tauri.conf.json").read_text()) + version = tomllib.loads((root / "pyproject.toml").read_text())["project"]["version"] + verify_source(root, env, version) + if sys.platform != "darwin" or target not in {"aarch64-apple-darwin", "x86_64-apple-darwin"}: + raise RuntimeError("Notarization requires a matching native macOS runner") + output = root / "desktop/work/notarized-assets" + if output.exists(): + raise RuntimeError("Refusing to reuse existing notarized assets") + with signing_session(env) as (child, directory): + override = directory / "tauri-signing.json" + override.write_text( + json.dumps( + {"bundle": {"macOS": {"signingIdentity": env["APPLE_SIGNING_IDENTITY"], "hardenedRuntime": True}}} + ) + ) + print("Freezing Developer ID host and building the notarized application", flush=True) + subprocess.run( + [sys.executable, str(root / "desktop/scripts/build_sidecar.py"), "--target", target], + check=True, + cwd=root, + env=child, + ) + subprocess.run( + ["npm", "run", "build", "--", "--target", target, "--bundles", "app,dmg", "--config", str(override)], + check=True, + cwd=root / "desktop", + env=child, + ) + bundle = root / "desktop/src-tauri/target" / target / "release/bundle" + app = bundle / "macos/Firefly Weave Studio.app" + dmgs = list((bundle / "dmg").glob("*.dmg")) + if len(dmgs) != 1: + raise RuntimeError("Expected exactly one DMG") + dmg = dmgs[0] + # Tauri submits and staples the app before building the DMG. The outer + # installer receives its own explicit Accepted result and stapled ticket. + quiet(["xcrun", "stapler", "validate", str(app)], timeout=120) + quiet(["codesign", "--force", "--timestamp", "--sign", env["APPLE_SIGNING_IDENTITY"], str(dmg)], timeout=120) + response = quiet( + [ + "xcrun", + "notarytool", + "submit", + str(dmg), + "--key", + child["APPLE_API_KEY_PATH"], + "--key-id", + env["APPLE_API_KEY"], + "--issuer", + env["APPLE_API_ISSUER"], + "--wait", + "--timeout", + "30m", + "--output-format", + "json", + ], + timeout=1900, + env=child, + ) + submission = accepted_submission(json.loads(response.stdout)) + quiet(["xcrun", "stapler", "staple", str(dmg)], timeout=120) + verify_tickets(app, dmg, env["APPLE_SIGNING_IDENTITY"], env["APPLE_TEAM_ID"]) + host = app / "Contents/MacOS/weave-studio-host" + subprocess.run( + [sys.executable, str(root / "desktop/scripts/smoke_sidecar.py"), str(host), "--expected-version", version], + check=True, + env=child, + cwd=root, + ) + metadata = { + "product_version": config["version"], + "python_version": version, + "target": target, + "commit": env["GITHUB_SHA"], + "tag": env["GITHUB_REF"].removeprefix("refs/tags/"), + "unsigned": False, + "signing": "developer-id", + "notarized": True, + "team_id": env["APPLE_TEAM_ID"], + "dmg_submission_id": submission, + "app_ticket_validated": True, + "dmg_ticket_validated": True, + "gatekeeper_assessed": True, + "dmg_sha256": digest(dmg), + } + host_metadata = json.loads((root / "desktop/work/sidecar-manifest.json").read_text()) + host_metadata.update( + { + "pre_sign_sha256": host_metadata["sha256"], + "pre_sign_filename": host_metadata["filename"], + "filename": host.name, + "sha256": digest(host), + "digest_scope": "signed-app-Contents/MacOS", + } + ) + # No distributable assets are retained if credential cleanup fails. + output.mkdir(parents=True) + shutil.copy2(dmg, output / f"firefly-weave-studio-{config['version']}-{target}.dmg") + (output / f"weave-studio-{target}-build.json").write_text(json.dumps(metadata, indent=2) + "\n") + (output / f"weave-studio-{target}-host.json").write_text(json.dumps(host_metadata, indent=2) + "\n") + subprocess.run([sys.executable, str(root / "desktop/scripts/hash_installers.py"), str(output)], check=True) + (output / "SHA256SUMS").rename(output / f"weave-studio-{target}-SHA256SUMS") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target") + parser.add_argument("--cleanup", action="store_true") + parser.add_argument("--verify-source", action="store_true") + args = parser.parse_args() + root = Path(__file__).resolve().parents[2] + if args.cleanup: + cleanup(Path(os.environ["RUNNER_TEMP"]).resolve()) + return + version = tomllib.loads((root / "pyproject.toml").read_text())["project"]["version"] + verify_source(root, os.environ, version) + if args.verify_source: + return + + def interrupted(signum, frame): + raise SystemExit("Signing interrupted; cleaning up temporary credentials") + + signal.signal(signal.SIGTERM, interrupted) + signal.signal(signal.SIGINT, interrupted) + build(root, args.target, os.environ) + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, subprocess.CalledProcessError) as exc: + raise SystemExit(str(exc) if isinstance(exc, RuntimeError) else "Release build failed") from None diff --git a/desktop/scripts/verify_macos_bundle.py b/desktop/scripts/verify_macos_bundle.py index fee5eddc..e3407b94 100644 --- a/desktop/scripts/verify_macos_bundle.py +++ b/desktop/scripts/verify_macos_bundle.py @@ -19,6 +19,7 @@ import argparse import plistlib import subprocess +from collections.abc import Callable from pathlib import Path @@ -34,7 +35,7 @@ def verify(app: Path) -> None: raise RuntimeError("Missing outer macOS resource seal") -def verify_dmg(dmg: Path) -> None: +def verify_dmg(dmg: Path, *, trust_check: Callable[[Path], None] | None = None) -> None: result = subprocess.run( ["hdiutil", "attach", "-readonly", "-nobrowse", "-plist", str(dmg)], check=True, @@ -48,6 +49,8 @@ def verify_dmg(dmg: Path) -> None: if len(apps) != 1: raise RuntimeError("Installer must contain exactly one application") verify(apps[0]) + if trust_check is not None: + trust_check(apps[0]) finally: subprocess.run(["hdiutil", "detach", device], check=True) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 6528a224..cb31697d 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -794,7 +794,7 @@ checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "firefly-weave-studio" -version = "0.1.0-alpha.13" +version = "0.1.0-alpha.14" dependencies = [ "serde", "serde_json", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 8d399dd1..97d03ee0 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -16,7 +16,7 @@ [package] name = "firefly-weave-studio" -version = "0.1.0-alpha.13" +version = "0.1.0-alpha.14" description = "Firefly Weave Studio desktop" authors = ["Firefly Software Foundation"] license = "Apache-2.0" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 2fe63fc7..be452bf5 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Firefly Weave Studio", - "version": "0.1.0-alpha.13", + "version": "0.1.0-alpha.14", "identifier": "io.getfirefly.weave.studio", "build": { "frontendDist": "../bootstrap" @@ -52,7 +52,7 @@ "y": 160 } }, - "bundleVersion": "0.1.13", + "bundleVersion": "0.1.14", "signingIdentity": "-", "entitlements": "entitlements.plist" }, @@ -61,7 +61,7 @@ "type": "downloadBootstrapper" }, "wix": { - "version": "0.1.13" + "version": "0.1.14" } }, "icon": [ diff --git a/docs/README.md b/docs/README.md index 5ce0b7d9..08d3f323 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,7 +47,7 @@ gives the steps and what you finish with. | An administrator | Install the platform, configure sign-in, and give people access | [Start a local platform](guides/local-platform.md) | [Identity and secrets](operations/identity-and-secrets.md), [remote deployment](operations/remote-deployment.md), [people and access](guides/people-and-access.md), and [configuration](operations/configuration.md) | | An operator | Keep runs healthy, fix incidents, upgrade, and back up | [Connect the CLI](guides/connect-to-api.md), then [manage runs](guides/execution-management.md) | [Incidents](reference/incident-operations.md), [troubleshooting](operations/troubleshooting.md), [observability](operations/observability.md), [upgrades](operations/upgrades.md), [backup](operations/backup-restore.md), and [debug-session retention](operations/retention.md) | -Start each path by [installing the CLI](installation.md) from the v0.1.0a13 +Start each path by [installing the CLI](installation.md) from the v0.1.0a14 release. [Who does what](guides/roles-and-lifecycle.md) explains these roles with an @@ -104,7 +104,7 @@ Run one command block, check its **Expected** result, then continue. Comments inside code blocks explain why each command is there. When a terminal shows server logs, keep it open and use a second terminal for client commands. -The guides pin the **v0.1.0a13 alpha** release, a preview, which includes every +The guides pin the **v0.1.0a14 alpha** release, a preview, which includes every feature they describe, such as saved platforms, REST calls without code, and the Studio editor. When a guide shows how to work with an alpha6 or earlier client or server, it says so. diff --git a/docs/capabilities.md b/docs/capabilities.md index 246ebad1..85ee0d43 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -51,8 +51,11 @@ permissions, and real delivery still need checking in your deployment. ## Which release this page describes Weave is an alpha workflow and integration platform. This documentation targets -**`0.1.0a13`**, with PyFly 26.9.15 and database schema revision -`0030_worker_presence`, unchanged from alpha12. Alpha13 corrects worker context +**`0.1.0a14`**, with PyFly 26.9.15 and database schema revision +`0030_worker_presence`, unchanged from alpha12. Alpha14 adds the detached Docker +development launcher and a credential-gated macOS notarization path. Actual Apple +notarization requires a Developer ID certificate and an accepted submission. +Alpha13 corrects worker context and credential admission retries. The alpha13 checks and historical alpha12 delivery evidence below are separate. Download packages and checksums from [GitHub Releases](https://github.com/fireflyframework/firefly-weave/releases). @@ -65,6 +68,19 @@ Rows marked **new in 0.1.0a7** describe features that alpha6 and earlier releases do not have. Alpha8 adds three migrations; see the [upgrade procedure](operations/upgrades.md). +## Alpha14 local development verification + +The Docker launcher was exercised on October 7, 2026, on macOS Apple Silicon +with Docker Desktop. This is a separate local environment from the Azure +deployment recorded below. + +| Verification | Verified scope | Limit | +| --- | --- | --- | +| Docker platform lifecycle | Built the retained server image, started the API and identity services, created a person and a successful saved run, then stopped and resumed the installation; account, run, and unrelated container identities were unchanged | One Docker Desktop context on one computer; no production high-availability or cloud provisioning claim | +| CLI sign-in | Installed CLI completed browser PKCE S256 against real local Keycloak, checked the authenticated person's workspace, and revoked and removed its isolated test credential | The private file credential store was used; the user's existing profile was not changed | +| Studio sign-in | Browser tests completed setup, real Keycloak sign-in, workspace selection, run and workflow reads, silent recovery after a Studio host restart, and sign-out with revocation; native test credentials were removed | Browser Studio on macOS with the system credential store; this does not establish the same interaction in every native desktop installer | +| Signing preparation | Unit tests exercise release-tag guards, credential cleanup, signature and ticket requirements, and mounted DMG verification | No Apple Developer ID identity or notarization submission was available; ordinary macOS installers remain ad-hoc signed | + ## Alpha13 verification The published tag is [`v0.1.0a13`](https://github.com/fireflyframework/firefly-weave/releases/tag/v0.1.0a13), diff --git a/docs/diagrams/docker-development.svg b/docs/diagrams/docker-development.svg new file mode 100644 index 00000000..062756cf --- /dev/null +++ b/docs/diagrams/docker-development.svg @@ -0,0 +1,49 @@ + + +Your Docker development platform +Studio and the CLI run on your computer. Sign in through local Keycloak, then use the token to call the Weave API. The API runs workflows and the scheduler and stores durable state in PostgreSQL. Keycloak has a separate database. Docker keeps the four services running in the background. Only loopback ports are exposed; stopping the platform retains its volumes. + + + + +One command. A platform you can sign in to. +Docker keeps the services running after you close the terminal. + + +Your computer: Studio and the CLI +Create workflows, inspect runs, and complete human tasks. +Start here: weave platform up --username developer + +1. Sign in with your local account +2. Use your authorized workspace + + + +KeycloakChecks your username and password.Development identity provider only.Other installations can use your CIAM. + + +Weave APIChecks your roles and runs workflows.Scheduler and execution history included.Interactive API reference at /docs. + + + +Identity databaseAccounts and sign-in settings.Workflow databaseDefinitions, runs, tasks, and grants. +ONE OWNED DOCKER PROJECT · Private volumes · Ports available only on localhost + +Stop with weave platform stop. Resume with weave platform up. Your data stays. + diff --git a/docs/guides/ai-workers.md b/docs/guides/ai-workers.md index cd505c0b..4e673e67 100644 --- a/docs/guides/ai-workers.md +++ b/docs/guides/ai-workers.md @@ -27,7 +27,7 @@ Use [Lumi](lumi.md) when *you*, the person using Studio, want an explanation or a proposed definition change. Lumi has separate configuration and does not execute workflow AI tasks. -The examples here use Weave **0.1.0a13** and the **0.1.5 Agentic worker package**, +The examples here use Weave **0.1.0a14** and the **0.1.6 Agentic worker package**, whose core dependency is pinned to that Weave version. The catalog references below retain their own `1.0.0` definition and task versions. diff --git a/docs/guides/desktop.md b/docs/guides/desktop.md index c1a6a2e2..63cdb379 100644 --- a/docs/guides/desktop.md +++ b/docs/guides/desktop.md @@ -44,31 +44,31 @@ start them: ## Choose an installer Choose a matching installer **actually attached** to the -[v0.1.0a13 release](https://github.com/fireflyframework/firefly-weave/releases/tag/v0.1.0a13). -The desktop product version is `0.1.0-alpha.13`; its bundled Python host is -`0.1.0a13`. macOS bundles use **ad-hoc signing** for resource integrity; they are +[v0.1.0a14 release](https://github.com/fireflyframework/firefly-weave/releases/tag/v0.1.0a14). +The desktop product version is `0.1.0-alpha.14`; its bundled Python host is +`0.1.0a14`. macOS bundles use **ad-hoc signing** for resource integrity; they are **not Developer ID signed and not notarized**. Windows installers remain unsigned. Build and frozen-host smoke checks do not establish interactive GUI testing on every operating system. Your organization's installation policy still applies. | Computer | Target | Installer filename | | --- | --- | --- | -| macOS Apple silicon | `aarch64-apple-darwin` | `firefly-weave-studio-0.1.0-alpha.13-aarch64-apple-darwin.dmg` | -| macOS Intel | `x86_64-apple-darwin` | `firefly-weave-studio-0.1.0-alpha.13-x86_64-apple-darwin.dmg` | -| Windows x64 | `x86_64-pc-windows-msvc` | `firefly-weave-studio-0.1.0-alpha.13-x86_64-pc-windows-msvc.exe` or `.msi` | -| Linux x64 | `x86_64-unknown-linux-gnu` | `firefly-weave-studio-0.1.0-alpha.13-x86_64-unknown-linux-gnu.deb` or `.AppImage` | +| macOS Apple silicon | `aarch64-apple-darwin` | `firefly-weave-studio-0.1.0-alpha.14-aarch64-apple-darwin.dmg` | +| macOS Intel | `x86_64-apple-darwin` | `firefly-weave-studio-0.1.0-alpha.14-x86_64-apple-darwin.dmg` | +| Windows x64 | `x86_64-pc-windows-msvc` | `firefly-weave-studio-0.1.0-alpha.14-x86_64-pc-windows-msvc.exe` or `.msi` | +| Linux x64 | `x86_64-unknown-linux-gnu` | `firefly-weave-studio-0.1.0-alpha.14-x86_64-unknown-linux-gnu.deb` or `.AppImage` | Download the chosen installer and its target's checksum inventory, `weave-studio-TARGET-SHA256SUMS`, from that same release. The matrix describes filenames, not a promise that every asset is already available. If your matching -asset is absent, use the [browser installation](studio.md#install-the-alpha13-browser-application) +asset is absent, use the [browser installation](studio.md#install-the-alpha14-browser-application) instead. Alpha4 Python releases do not contain Studio or desktop installers. **Do not use the alpha5 macOS installers.** Their outer application bundle lacked its resource seal, causing macOS to report that the application was damaged. Alpha6 added explicit ad-hoc bundle signing and a strict verification gate for both -the built application and the application enclosed in its DMG, and alpha13 keeps -them. Download the matching alpha13 asset rather than attempting to bypass the +the built application and the application enclosed in its DMG, and alpha14 keeps +them. Download the matching alpha14 asset rather than attempting to bypass the alpha5 failure. Ad-hoc integrity does not establish publisher trust or Gatekeeper acceptance. @@ -110,9 +110,9 @@ This follows Tauri's [sidecar model](https://v2.tauri.app/develop/sidecar/) and ```sh # Inspect the downloaded installer without opening it. cd ~/Downloads - shasum -a 256 firefly-weave-studio-0.1.0-alpha.13-aarch64-apple-darwin.dmg + shasum -a 256 firefly-weave-studio-0.1.0-alpha.14-aarch64-apple-darwin.dmg # Show the expected digest for this exact filename. - grep 'firefly-weave-studio-0.1.0-alpha.13-aarch64-apple-darwin.dmg$' weave-studio-aarch64-apple-darwin-SHA256SUMS + grep 'firefly-weave-studio-0.1.0-alpha.14-aarch64-apple-darwin.dmg$' weave-studio-aarch64-apple-darwin-SHA256SUMS ``` Both displayed digests must match. For Intel, replace `aarch64-apple-darwin` @@ -149,9 +149,9 @@ matching `weave-studio-x86_64-pc-windows-msvc-SHA256SUMS` inventory first. ```powershell # Inspect the downloaded EXE before running it. Set-Location "$HOME\Downloads" -Get-FileHash .\firefly-weave-studio-0.1.0-alpha.13-x86_64-pc-windows-msvc.exe -Algorithm SHA256 +Get-FileHash .\firefly-weave-studio-0.1.0-alpha.14-x86_64-pc-windows-msvc.exe -Algorithm SHA256 # Compare the digest with the line for this exact filename. -Select-String -Path .\weave-studio-x86_64-pc-windows-msvc-SHA256SUMS -Pattern 'firefly-weave-studio-0.1.0-alpha.13-x86_64-pc-windows-msvc.exe$' +Select-String -Path .\weave-studio-x86_64-pc-windows-msvc-SHA256SUMS -Pattern 'firefly-weave-studio-0.1.0-alpha.14-x86_64-pc-windows-msvc.exe$' ``` Expected: `Get-FileHash` prints a `Hash` value, and `Select-String` prints the @@ -159,8 +159,8 @@ inventory line for the same file. For MSI, substitute `.msi` in both commands. Compare the two digests without regard to letter case; stop on a mismatch. Double-click the verified installer, complete its setup, then open **Firefly Weave Studio** from the Start menu. The installer may need network access to provision WebView2. Unsigned-installation warnings remain -subject to your organization's policy. The MSI's internal version is `0.1.13`, a -monotonic Windows Installer counter; the displayed product remains alpha13. +subject to your organization's policy. The MSI's internal version is `0.1.14`, a +monotonic Windows Installer counter; the displayed product remains alpha14. ## Install on Linux @@ -171,8 +171,8 @@ application where your distribution supports its runtime. Download the matching ```sh # Enter the download directory, verify this exact file, then install only on success. cd ~/Downloads && - grep 'firefly-weave-studio-0.1.0-alpha.13-x86_64-unknown-linux-gnu.deb$' weave-studio-x86_64-unknown-linux-gnu-SHA256SUMS | sha256sum --check && - sudo apt install ./firefly-weave-studio-0.1.0-alpha.13-x86_64-unknown-linux-gnu.deb + grep 'firefly-weave-studio-0.1.0-alpha.14-x86_64-unknown-linux-gnu.deb$' weave-studio-x86_64-unknown-linux-gnu-SHA256SUMS | sha256sum --check && + sudo apt install ./firefly-weave-studio-0.1.0-alpha.14-x86_64-unknown-linux-gnu.deb ``` Expected: `sha256sum` prints the file name followed by `OK`, and only then does @@ -182,9 +182,9 @@ application launcher. For AppImage: ```sh # Verify this exact portable file; a failed or missing checksum prevents launch. cd ~/Downloads && - grep 'firefly-weave-studio-0.1.0-alpha.13-x86_64-unknown-linux-gnu.AppImage$' weave-studio-x86_64-unknown-linux-gnu-SHA256SUMS | sha256sum --check && - chmod +x firefly-weave-studio-0.1.0-alpha.13-x86_64-unknown-linux-gnu.AppImage && - ./firefly-weave-studio-0.1.0-alpha.13-x86_64-unknown-linux-gnu.AppImage + grep 'firefly-weave-studio-0.1.0-alpha.14-x86_64-unknown-linux-gnu.AppImage$' weave-studio-x86_64-unknown-linux-gnu-SHA256SUMS | sha256sum --check && + chmod +x firefly-weave-studio-0.1.0-alpha.14-x86_64-unknown-linux-gnu.AppImage && + ./firefly-weave-studio-0.1.0-alpha.14-x86_64-unknown-linux-gnu.AppImage ``` Linux requires the distribution's WebKitGTK runtime, and AppImage execution may @@ -479,7 +479,7 @@ with a text editor. **Untested platform cases.** On macOS 11.0 to 11.2 the webview lacks the download support the app relies on, so an export may save nothing even though the confirmation appears; use macOS 11.3 or later, or the -[browser installation](studio.md#install-the-alpha13-browser-application). macOS +[browser installation](studio.md#install-the-alpha14-browser-application). macOS may ask whether Firefly Weave Studio can use your Downloads folder the first time you export. On Windows, WebView2 may ask whether to allow the second of the two files. @@ -633,8 +633,8 @@ source checkout, you can run that same gate on an explicitly downloaded DMG: ```sh # Verify resource integrity without launching the application. -# Replace the path with the actual alpha13 installer you downloaded. -.venv/bin/python desktop/scripts/verify_macos_bundle.py /path/to/firefly-weave-studio-0.1.0-alpha.13-aarch64-apple-darwin.dmg +# Replace the path with the actual alpha14 installer you downloaded. +.venv/bin/python desktop/scripts/verify_macos_bundle.py /path/to/firefly-weave-studio-0.1.0-alpha.14-aarch64-apple-darwin.dmg ``` The build also smoke-tests the frozen host after Tauri signs it. Hardened runtime @@ -646,14 +646,91 @@ This is a packaging-integrity check, not a Gatekeeper or notarization test. A source checkout is needed only for this contributor verification command; ordinary installation follows the checksum and platform steps above. -For a production macOS release, configure the application and sidecar signing -identity plus notarization credentials through the release environment, following -[Tauri macOS signing](https://v2.tauri.app/distribute/sign/macos/). -`APPLE_SIGNING_IDENTITY` is passed to PyInstaller for the frozen host as well as -Tauri's own signing configuration. Use Tauri's documented Apple certificate, -password and notarization environment variables rather than committing credentials. -For Windows, configure a trusted signing certificate or an approved signing service -following [Tauri Windows signing](https://v2.tauri.app/distribute/sign/windows/). -Developer ID signing, Windows publisher signing, and notarization have not been -performed or verified by this implementation. Ad-hoc signing only protects bundle -integrity; it does not make a CI artifact a trusted production download. +### Opt-in Developer ID signing and notarization + +The regular **Desktop installers** workflow continues to build ad-hoc macOS +installers. The separate **Notarized macOS installers** workflow is prepared for +trusted release tags only. It is opt-in and retains verified artifacts; it does +not publish a release or replace existing downloads. The published alpha13 +installers remain ad-hoc signed and unnotarized. Preparing this workflow does not +change their trust status, and no Apple submission has been verified yet. + +An organization maintainer must first have an active Apple Developer Program +membership, a **Developer ID Application** certificate with its private key, +and an App Store Connect API key permitted to use notarization. An Apple +Development certificate, an App Store distribution certificate, or a certificate +without its private key is not sufficient. See [Apple's notarization +requirements](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution) +and [Tauri's macOS signing guide](https://v2.tauri.app/distribute/sign/macos/). + +Create the GitHub environment `macos-release-signing`, require a release +maintainer's approval, prevent self-approval where available, and restrict its +deployment rules to reviewed release tags. Keep these credentials in that +**environment**, not repository-wide secrets, source files, issue comments, or +workflow inputs: + +| Environment secret | Value | +| --- | --- | +| `APPLE_CERTIFICATE` | Base64-encoded `.p12` containing the Developer ID Application certificate and private key | +| `APPLE_CERTIFICATE_PASSWORD` | Password protecting that `.p12` | +| `APPLE_SIGNING_IDENTITY` | Exact `Developer ID Application: Organization (TEAMID)` identity | +| `APPLE_TEAM_ID` | The certificate's ten-character Apple team ID | +| `APPLE_API_ISSUER` | App Store Connect team API issuer UUID | +| `APPLE_API_KEY` | App Store Connect API key ID | +| `APPLE_API_PRIVATE_KEY` | Full private `.p8` key contents | + +The workflow deliberately supports team API keys with an issuer. It does not +silently fall back to Apple ID/password authentication. Missing credentials name +only the missing variables and stop the build. Never print the private key or +certificate password while diagnosing a failed run. + +After this workflow exists on `main` and on the reviewed release tag, dispatch it +**at that tag** and explicitly enable `notarize`: + +```sh +# Replace this example with the exact reviewed tag that contains the workflow. +gh workflow run desktop-notarized.yml --ref v0.1.0a14 -f notarize=true +``` + +Approve the protected environment only after checking the tag and commit. Both +macOS jobs independently require the canonical repository, a manual dispatch, +the version-matching tag, and identical workflow, checkout, and tag commit IDs. +Branch dispatches, pull requests, and fork workflows cannot enter the signing +job. The ordinary installer workflow never receives these secrets. + +Each native job imports exactly the expected Developer ID identity into a unique +temporary keychain. It preserves the original keychain search list and removes +the keychain, certificate, and private key on completion or failure; an +`always()` cleanup step also handles interrupted jobs on the disposable runner. +Secrets are passed only to the signing step and are excluded from retained +artifacts. Do not run this workflow on persistent self-hosted runners. + +The frozen PyInstaller host is signed during freezing, including the binaries +embedded in its one-file archive. The pinned PyInstaller enables hardened +runtime and secure timestamps with the supplied identity; the build passes the +existing entitlements explicitly. Tauri signs the nested host and application +with the same Developer ID, notarizes the app, and staples its ticket before +creating the DMG. The helper then signs and submits the DMG separately, requires +Apple's `Accepted` result, and staples the installer ticket. An uncertain or +failed submission stops the job; it is not automatically retried. + +Before collection, the gate verifies both executable signatures, the outer +resource seal, the expected team, secure timestamps, hardened runtime, and the +absence of a debugging entitlement. It validates the app and DMG tickets, +assesses both with Gatekeeper, and repeats the app checks inside the read-only +DMG mount. The mounted app must contain the exact verified executable bytes. +The signed frozen host must also pass its offline startup smoke test. + +Only then can the separate `*-notarized` artifact contain build metadata with +`signing: developer-id`, `unsigned: false`, and `notarized: true`, alongside the +commit, tag, team, DMG submission ID, hashes, and checksum inventory. Verify both +native target jobs and their receipts before publishing. Never infer that an +existing `*-unsigned` artifact or download became notarized because a newer +workflow exists. If Apple rejects a submission, inspect its submission ID through +Apple's notarization tools privately, correct the reported cause, and start a +new reviewed run; do not bypass the ticket gate. + +For Windows, configure a trusted signing certificate or an approved signing +service following [Tauri Windows signing](https://v2.tauri.app/distribute/sign/windows/). +Windows publisher signing is not implemented here. Ad-hoc signing protects +bundle integrity but does not make an installer a trusted production download. diff --git a/docs/guides/docker-development.md b/docs/guides/docker-development.md new file mode 100644 index 00000000..8cb56212 --- /dev/null +++ b/docs/guides/docker-development.md @@ -0,0 +1,168 @@ + + +# Keep a development platform running in Docker + +Use this route when you want to open Studio, sign in, and work with real saved +workflows without keeping an API terminal open. The CLI starts an isolated Docker +Compose project containing the API, PostgreSQL, Keycloak, and Keycloak's database. +The API also runs the scheduler. This is a single-computer development environment, +not a highly available production cluster. + +![Studio and CLI sign in through local Keycloak and call the Weave API; separate persistent databases retain identity and workflow data](../diagrams/docker-development.svg) + +## 1. Prepare your tools + +Install the [CLI](../installation.md), Python 3.12, `uv`, and Docker with +Compose 2.30 or newer. Start Docker before continuing. This local launcher supports +macOS and Linux Docker contexts that use a Unix socket. + +Keep a source checkout matching your CLI version: it supplies the pinned container +and database setup files. Run the commands below from that checkout. Do not modify +that checkout while this installation depends on it; use a separate checkout for +application development. + +```sh +# Confirm that the installed CLI matches the source version below. +weave --version +# Expected: Firefly Weave 0.1.0a14. + +# Download the matching operator files into a new, dedicated directory. +git clone --branch v0.1.0a14 --depth 1 \ + https://github.com/fireflyframework/firefly-weave.git weave-platform-a14 +cd weave-platform-a14 +``` + +## 2. Start the platform and create your account + +```sh +# Check the checkout and Docker before creating anything. +weave platform doctor + +# Prepare private settings, start the API in Docker, run the example, +# and create a person who can sign in. Choose your own username. +weave platform up --username developer +``` + +The first start downloads dependencies and builds the server image, so it takes +longer than later starts. The CLI prints the API address and sign-in instructions; +`platform status` also shows the interactive API docs address. Copy the generated password when it appears: +it is shown once, and subsequent starts do not change it. + +A default development account can author and run workflows, inspect executions, +and participate in human tasks. To also administer connections and reviewer groups, +choose these roles when creating the account: + +```sh +# Development only: create an account that can follow the administration guides. +# Use this INSTEAD of the first up command, with a new username. +weave platform up --username localadmin --role tenant_admin --role developer \ + --role deployer --role operator --role viewer --role task_participant \ + --role task_manager +``` + +Your installation lives in `.local/platform`. Its containers and volumes have +unique names; other Docker projects are not changed. Ports are selected when the +installation is created and published only on loopback. Keep this directory: +it contains private settings and identifies the data that later commands reuse. + +## 3. Connect and sign in + +```sh +# Find the API address and confirm both services are ready. +weave platform status +``` + +Copy the `Sign in` command printed by status. It uses the actual selected port: + +```sh +# Replace API_PORT with the port shown by status; it is not a fixed default. +weave auth setup http://127.0.0.1:API_PORT --name local + +# Verify the saved account and workspace after browser sign-in. +weave auth status + +# Open Studio using the saved platform. +weave studio +``` + +In the browser, sign in with the username and generated password from step 2. +The assistant asks you to review the platform and choose the demo workspace. +Do not use the Keycloak administrator account: your development person already +has the Weave roles required for the selected workspace. + +You can also connect directly from Studio: open **Connect to a platform**, paste +the API address, review the sign-in settings, then sign in and select the workspace. +The [connection guide](connect-to-api.md) explains each screen and where sessions +are stored. Keycloak is only this environment's development identity provider; +[remote installations can use compatible OIDC providers](../operations/identity-and-secrets.md#use-your-own-identity-provider). + +## 4. Check a real run and explore the API + +```sh +# Read the saved successful example. Repeating this does not create another run. +weave platform demo + +# Show recent API logs when diagnosing a startup or request problem. +weave platform logs +``` + +Open the `Docs URL` from status to inspect the API. The +[Swagger walkthrough](api-playground.md) explains how to authorize requests and +try them. You can now create workflows in Studio and use the same server from the +[Python SDK](sdk-tutorial.md). + +HTTP integrations, external workers, AI providers, and Lumi need their own +configuration and credentials. Starting the platform does not silently connect +external accounts or make paid model calls. Follow +[local HTTP integration setup](local-platform.md#8-run-built-in-http-connector-actions) +and the [AI worker guide](ai-workers.md) when you need them. + +## 5. Stop and resume without losing your work + +```sh +# Stop this installation's API and dependencies. Volumes and workflows remain. +weave platform stop + +# Resume the existing Docker installation; no new account or database is created. +weave platform up +``` + +You can close the terminal after `up` completes. To resume an existing installation, +always use its original directory and matching CLI. If you chose a custom directory, +place it before the subcommand every time: + +```sh +# Use one stable private location from any working directory. +weave platform --directory /absolute/path/to/weave-dev up --source /absolute/path/to/checkout +weave platform --directory /absolute/path/to/weave-dev status +``` + +## If something goes wrong + +| What you see | What to do | +| --- | --- | +| Docker is unavailable | Start Docker and repeat `doctor`; verify the selected context | +| Setup did not finish | Inspect the saved stage and private logs. Do not remove volumes or repeat database provisioning blindly | +| The directory belongs to a foreground installation | Continue using its `start` command, or choose a new directory for Docker mode; existing installations are not converted silently | +| The checkout or CLI changed | Return to the matching version, or create a new installation in a separate directory | +| A username already exists | Sign in with the original password; starting again does not reset accounts | +| You need API logs | Run `platform logs`; retain the private setup logs for earlier stages | + +For the individual setup steps and the foreground API workflow, see +[the detailed local-platform guide](local-platform.md). diff --git a/docs/guides/learning-path.md b/docs/guides/learning-path.md index 2ff38d5e..4512b5a9 100644 --- a/docs/guides/learning-path.md +++ b/docs/guides/learning-path.md @@ -65,7 +65,7 @@ have what you need. One person often has several roles, especially on a laptop. [Who does what](roles-and-lifecycle.md) explains the roles and the permissions each one needs. -**Every path starts with the v0.1.0a13 release.** [Install the CLI](../installation.md) +**Every path starts with the v0.1.0a14 release.** [Install the CLI](../installation.md) first. The release includes everything these paths use: the Studio editor and its API action builder, saved platforms (`weave auth setup`), REST calls without code, and `weave platform user`. @@ -75,7 +75,7 @@ code, and `weave platform user`. You draw processes, add approvals and API calls, and run them. **What you need:** the CLI and -[Studio's browser application](studio.md#install-the-alpha13-browser-application), +[Studio's browser application](studio.md#install-the-alpha14-browser-application), or the [desktop app](desktop.md), and, from step 4 on, a platform: your team's server address and an account, or a [local platform](local-platform.md), which needs Docker. @@ -84,7 +84,7 @@ needs Docker. | --- | --- | --- | | 1 | [Concepts](../concepts.md), including [Coming from BPM/BPMN](../concepts.md#coming-from-bpmbpmn) | The vocabulary: workflow, step, activation, run, and how BPMN ideas map to them | | 2 | [Your first workflow](../quickstart.md) | A workflow validated and simulated on your computer, with no platform | -| 3 | [Install Studio](studio.md#install-the-alpha13-browser-application), then [draw your first workflow](studio.md#try-it-draw-your-first-workflow) | A process drawn in the visual editor and checked as you edit | +| 3 | [Install Studio](studio.md#install-the-alpha14-browser-application), then [draw your first workflow](studio.md#try-it-draw-your-first-workflow) | A process drawn in the visual editor and checked as you edit | | 4 | [Start a local platform](local-platform.md), its steps 1 to 8, or [connect Studio](studio.md#connect-to-a-platform) to your team's platform | A platform where you can publish, run, and approve; on the local platform, create your person with every role, as its step 5 explains | | 5 | [Step reference](studio-step-reference.md) | The right properties for each kind of step | | 6 | [Human tasks](human-tasks.md) | An approval assigned to real reviewers; a task manager creates the reviewer binding with the CLI | diff --git a/docs/guides/local-platform.md b/docs/guides/local-platform.md index e5daf7a9..369a9796 100644 --- a/docs/guides/local-platform.md +++ b/docs/guides/local-platform.md @@ -40,6 +40,13 @@ and follow the [deployment guides](../operations/remote-deployment.md). For an offline first workflow, use the [quickstart](../quickstart.md); to use a platform that already exists, [connect the CLI to it](connect-to-api.md). +## Prefer Docker in the background? + +The [Docker development guide](docker-development.md) combines setup, detached API +startup, a demo workspace, and optional account creation in `weave platform up`. +Use that route when you want to close the terminal and keep working in Studio. +The steps below retain the foreground API route for developers inspecting server logs. + ## The route at a glance | Step | You run | Checkpoint | @@ -79,8 +86,8 @@ decides which features the platform has. With the alpha7 CLI from directory, and keep any checkout you already use for development untouched: ```sh -# Download the alpha13 release's operator files into a new directory. -git clone --branch v0.1.0a13 --single-branch https://github.com/fireflyframework/firefly-weave.git firefly-weave-local +# Download the alpha14 release's operator files into a new directory. +git clone --branch v0.1.0a14 --single-branch https://github.com/fireflyframework/firefly-weave.git firefly-weave-local # Run the following steps from that matching checkout. cd firefly-weave-local ``` @@ -344,8 +351,8 @@ Studio is the visual editor. Because the CLI and Studio share saved platforms, Studio opens already connected to the platform you saved in step 6. **First install Studio's browser application** if you have not yet: download -the matching alpha13 bundle and install it as in -[Install the alpha13 browser application](studio.md#install-the-alpha13-browser-application). +the matching alpha14 bundle and install it as in +[Install the alpha14 browser application](studio.md#install-the-alpha14-browser-application). Then start Studio: ```sh @@ -421,7 +428,10 @@ from step 5. Or, in Studio: [Call a REST API from a step](studio.md#call-a-rest- **The local executor has fixed limits.** It runs only the built-in HTTP connector, never connector packages. It calls only public HTTPS destinations: an API on your own computer or private network is refused. A new secret handle needs a restart; -replacing an existing handle's value does not. A connection whose secret handles +replacing an existing handle's value does not in this foreground mode. +For a [Docker installation](docker-development.md), run `platform start` after +adding, replacing, or removing a secret: its running container retains the +previous mounted copy until that restart succeeds. A connection whose secret handles it must read needs `weave platform integrations grant --connection REVISION_ID --access read` (or `write`). To stop running connector actions, run `weave platform integrations disable` and restart; the server keeps the release diff --git a/docs/guides/lumi.md b/docs/guides/lumi.md index 4eb4ffb3..b15870e1 100644 --- a/docs/guides/lumi.md +++ b/docs/guides/lumi.md @@ -29,7 +29,7 @@ Lumi has its own configuration per environment. It never reads a workflow's `llmProfiles`, and changing an LLM step does not change the assistant. The Studio **AI setup** examples and named provider-connection selection -shown here use Weave **0.1.0a13**. Use the matching **0.1.5 Agentic worker +shown here use Weave **0.1.0a14**. Use the matching **0.1.6 Agentic worker package** for the independently deployed Lumi gateway. ## Follow a question through review diff --git a/docs/guides/standalone.md b/docs/guides/standalone.md index f7656d3f..9daadc3e 100644 --- a/docs/guides/standalone.md +++ b/docs/guides/standalone.md @@ -96,18 +96,18 @@ checkout. If you do not already have it, run: ```sh # Clone the alpha7 release that matches the CLI, then install its locked dependencies. -git clone --branch v0.1.0a13 --single-branch \ +git clone --branch v0.1.0a14 --single-branch \ https://github.com/fireflyframework/firefly-weave.git cd firefly-weave uv sync --locked --python 3.12 ``` -The clone selects the same **v0.1.0a13** release as the CLI installation guide. +The clone selects the same **v0.1.0a14** release as the CLI installation guide. A detached-HEAD message is expected when Git opens a release tag; this tutorial does not require creating a branch or editing application source. If you already have a checkout, enter its root and run `git describe --tags --exact-match`. -For this released walkthrough, the result must be `v0.1.0a13`. If the checkout has +For this released walkthrough, the result must be `v0.1.0a14`. If the checkout has another version or local development work, preserve it and clone the release into a separate directory by adding a new directory name to the clone command above. Contributors intentionally using unreleased source should use that checkout's diff --git a/docs/guides/studio-step-reference.md b/docs/guides/studio-step-reference.md index 7efda1f8..35f40909 100644 --- a/docs/guides/studio-step-reference.md +++ b/docs/guides/studio-step-reference.md @@ -31,13 +31,13 @@ Studio writes the same versioned definition language that you can edit as YAML or build with the CLI and the Python SDK, so every choice here is visible in the **Source** tab. -**This page describes the Studio 0.1.0a13 editor.** The step kinds and native +**This page describes the Studio 0.1.0a14 editor.** The step kinds and native human tasks are also part of alpha6, but several controls described here, such as the canvas step picker, the searchable action picker, the **Value** / typed input rows with **Use data** and **Calculate…**, the schema designer, the API action builder, and the simulation setup and panel, are new in 0.1.0a7. An alpha6 or earlier browser bundle does not have them; to see the same screens, -[install the alpha13 browser application](studio.md#install-the-alpha13-browser-application). +[install the alpha14 browser application](studio.md#install-the-alpha14-browser-application). **How to use this page:** diff --git a/docs/guides/studio.md b/docs/guides/studio.md index 36fa9be7..7d5818dd 100644 --- a/docs/guides/studio.md +++ b/docs/guides/studio.md @@ -38,10 +38,10 @@ computer at `127.0.0.1`. That host keeps your saved platforms, which it shares with the `weave` command line, and keeps your sign-in in the operating system's credential store. The page never receives your tokens. -**This page describes Studio 0.1.0a13.** An alpha6 or earlier browser bundle +**This page describes Studio 0.1.0a14.** An alpha6 or earlier browser bundle shows an earlier connection assistant, which imports a login configuration file, and lacks the editor features and API actions described here. To follow this -page, [install the alpha13 browser application](#install-the-alpha13-browser-application). +page, [install the alpha14 browser application](#install-the-alpha14-browser-application). ![Studio and runtime boundaries](../diagrams/studio-and-runtime.svg) @@ -55,7 +55,7 @@ happens on the platform and keeps running after you close Studio. | Your goal | What must be running | Start here | | --- | --- | --- | | Open Studio as a native desktop app | A matching desktop build for your operating system | [Install desktop](desktop.md) | -| Draw, import, or validate a workflow on your computer | Studio's local host; no account | [Install Studio](#install-the-alpha13-browser-application), then [work locally](#work-locally-without-signing-in) | +| Draw, import, or validate a workflow on your computer | Studio's local host; no account | [Install Studio](#install-the-alpha14-browser-application), then [work locally](#work-locally-without-signing-in) | | Learn the editor with a small example | Studio's local host | [Try it: draw your first workflow](#try-it-draw-your-first-workflow) | | Save and run workflows on your laptop | Studio plus a separate local Weave platform | [Start the platform](local-platform.md), then [connect Studio](#connect-to-a-platform) | | Work with your team's platform | Studio on your computer and the server address from your administrator | [Connect to a platform](#connect-to-a-platform) | @@ -71,45 +71,45 @@ computer. You can create, import, edit, and validate workflow definitions. Connect when you want to save them to a shared platform, run processes, or handle tasks. This status does not mean your computer has lost its internet connection. -## Install the alpha13 browser application +## Install the alpha14 browser application Alpha7 includes the Studio host. Its browser assets are an optional, matching release bundle; the Python wheel does not embed the Angular application. Alpha4 does not include Studio. Download all files from the same -[v0.1.0a13 release](https://github.com/fireflyframework/firefly-weave/releases/tag/v0.1.0a13). +[v0.1.0a14 release](https://github.com/fireflyframework/firefly-weave/releases/tag/v0.1.0a14). The CLI installer does not automatically fetch or install the Studio ZIP. -Use Python 3.12 or newer. The alpha13 CLI installer includes the Studio host +Use Python 3.12 or newer. The alpha14 CLI installer includes the Studio host and authentication dependencies, but downloads no browser ZIP automatically. These Bash/Zsh commands need neither Node nor a source checkout: ```sh # Install the pinned CLI into its isolated installation directory. # Review the installer first using the download-and-inspect alternative in Installation. -curl --fail --location https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a13/install.sh \ - | sh -s -- --version v0.1.0a13 +curl --fail --location https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a14/install.sh \ + | sh -s -- --version v0.1.0a14 # Make the installed command available in this terminal. export PATH="$HOME/.local/bin:$PATH" # Check that the host version matches the browser bundle you will install. weave --version # Keep the downloaded optional browser bundle and its digest together. -mkdir weave-studio-alpha13 -cd weave-studio-alpha13 -curl --fail --location --remote-name https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a13/firefly-weave-studio-0.1.0a13.zip -curl --fail --location --remote-name https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a13/firefly-weave-studio-0.1.0a13.zip.sha256 +mkdir weave-studio-alpha14 +cd weave-studio-alpha14 +curl --fail --location --remote-name https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a14/firefly-weave-studio-0.1.0a14.zip +curl --fail --location --remote-name https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a14/firefly-weave-studio-0.1.0a14.zip.sha256 # Verify the bundle on macOS. Stop if the check fails. -shasum -a 256 --check firefly-weave-studio-0.1.0a13.zip.sha256 +shasum -a 256 --check firefly-weave-studio-0.1.0a14.zip.sha256 # On Linux, use sha256sum --check in place of shasum -a 256 --check. # Install only the bundle whose version and verified digest match this host. -weave studio install --bundle firefly-weave-studio-0.1.0a13.zip \ - --sha256 "$(awk '{print $1}' firefly-weave-studio-0.1.0a13.zip.sha256)" +weave studio install --bundle firefly-weave-studio-0.1.0a14.zip \ + --sha256 "$(awk '{print $1}' firefly-weave-studio-0.1.0a14.zip.sha256)" # Start the local host without opening a browser automatically. weave studio --no-browser ``` -Expected: `weave --version` prints `Firefly Weave 0.1.0a13`, the checksum line +Expected: `weave --version` prints `Firefly Weave 0.1.0a14`, the checksum line ends with `OK`, and the install prints `Studio installed at PATH. Start it with: weave studio`. The host then prints `Firefly Weave Studio · Local authoring (no platform selected)`, or `Firefly Weave Studio · Platform: NAME` when a saved @@ -131,7 +131,7 @@ or the [remote deployment guide](../operations/remote-deployment.md). **This route is for contributors,** and for trying changes made after the latest release; the release bundle above already has everything this page -describes. To reproduce the alpha13 release exactly, check out `v0.1.0a13` +describes. To reproduce the alpha14 release exactly, check out `v0.1.0a14` instead; development `main` can differ from released assets. From the repository root, install the development dependencies first. Node 24 @@ -167,7 +167,7 @@ while you use Studio. **Already installed a release browser bundle?** Installed bundles are kept per version, and a checkout can report the same version as the latest release, for -example `0.1.0a13`. A plain `weave studio` then keeps serving the installed +example `0.1.0a14`. A plain `weave studio` then keeps serving the installed release screens, and `weave studio install` keeps that bundle instead of replacing it. Always start a source build with `--assets studio/dist/studio/browser`. diff --git a/docs/installation.md b/docs/installation.md index 7136614c..fd926c69 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -27,18 +27,18 @@ The installer puts the CLI in its own Python environment, so your applications' packages are never affected. It includes local authoring, the API client with sign-in support, OpenAPI import, and the host that runs Studio. Studio's browser application is a separate download; see the -[Studio guide](guides/studio.md#install-the-alpha13-browser-application). +[Studio guide](guides/studio.md#install-the-alpha14-browser-application). ## Choose the installation path -**Recommended:** install the pinned **v0.1.0a13 alpha** below. You do not need Git, +**Recommended:** install the pinned **v0.1.0a14 alpha** below. You do not need Git, a source checkout, Docker, or `sudo`. An alpha is a preview release: use it to evaluate Weave, and check the [current limits](capabilities.md). | Your situation | Start here | | --- | --- | | You want to write, check, and simulate workflows, connect to your team's platform by its address (`weave auth setup`), or call REST APIs without code | [Install a release](#install-a-release), then [check the result](#check-the-result) | -| You want the visual editor in your browser | [Install Studio](guides/studio.md#install-the-alpha13-browser-application) | +| You want the visual editor in your browser | [Install Studio](guides/studio.md#install-the-alpha14-browser-application) | | You want a native desktop app | [Desktop installation](guides/desktop.md) | | You contribute code, or want to try changes made after the release | [Install the current source](#install-the-current-source) | | You already have the CLI | [Upgrade or select another version](#installation-locations-and-upgrades) | @@ -89,8 +89,8 @@ explicitly: # Stop if downloading the installer fails. set -o pipefail curl --proto '=https' --tlsv1.2 -fsSL \ - https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a13/install.sh \ - | sh -s -- --version v0.1.0a13 + https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a14/install.sh \ + | sh -s -- --version v0.1.0a14 ) ``` @@ -100,7 +100,7 @@ environment; and tests the new command before it makes it available. The first installation can take several minutes. No services start and no cloud resources are created. -Expected, at the end: `Installed Firefly Weave 0.1.0a13: PATH`, then `Run: PATH +Expected, at the end: `Installed Firefly Weave 0.1.0a14: PATH`, then `Run: PATH --help`, where `PATH` is the new command (by default `~/.local/bin/weave`). When that directory is not on your `PATH`, the installer also prints the `export` line to add. The last line says where earlier environments are kept for @@ -112,7 +112,7 @@ instead: ```sh # Download the installer to a file instead of running it directly. curl --proto '=https' --tlsv1.2 -fsSL \ - https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a13/install.sh \ + https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a14/install.sh \ -o weave-install.sh ``` @@ -121,7 +121,7 @@ editor, then run it: ```sh # Install the same pinned release with the reviewed file. -sh weave-install.sh --version v0.1.0a13 +sh weave-install.sh --version v0.1.0a14 ``` Expected: the same output as above. Keep the file if you want to reuse it for @@ -147,7 +147,7 @@ weave version --output json weave docs platform ``` -Expected: `Firefly Weave 0.1.0a13`; the help with its command groups and +Expected: `Firefly Weave 0.1.0a14`; the help with its command groups and examples; one JSON object with `version`, `apiVersion`, and `irVersion`; and the address of the platform guide. The Firefly banner appears only in the main help; it never appears in command results or JSON output. @@ -183,7 +183,7 @@ remove or move the Python interpreter the environment was built with. ### Select a version or release channel -The explicit `--version v0.1.0a13` makes an installation repeatable. To upgrade, +The explicit `--version v0.1.0a14` makes an installation repeatable. To upgrade, use the installer URL and `--version` value of the new release shown on [GitHub Releases](https://github.com/fireflyframework/firefly-weave/releases). Tags start with `v`; `weave --version` shows the package version without it. @@ -192,7 +192,7 @@ With a downloaded `weave-install.sh`, you can select a channel instead: | Command | What it installs | | --- | --- | -| `sh weave-install.sh --version v0.1.0a13` | Exactly this documented alpha | +| `sh weave-install.sh --version v0.1.0a14` | Exactly this documented alpha | | `sh weave-install.sh --prerelease` | The most recently published release, including alpha previews | | `sh weave-install.sh` | The latest stable release only | @@ -236,7 +236,7 @@ removal option: # Stop if downloading the installer fails. set -o pipefail curl --proto '=https' --tlsv1.2 -fsSL \ - https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a13/install.sh \ + https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a14/install.sh \ | sh -s -- --uninstall ) ``` @@ -257,7 +257,7 @@ delete them yourself if you want no trace. | What you see | Why | What to do | | --- | --- | --- | | A Python version or `venv` error | The selected Python is older than 3.12, or lacks `venv` | Set `WEAVE_INSTALL_PYTHON` to Python 3.12 or newer; install your distribution's `venv` package | -| No installable release was found | You asked for the latest stable release, and only alphas exist | Run the pinned `v0.1.0a13` command above, or add `--prerelease` | +| No installable release was found | You asked for the latest stable release, and only alphas exist | Run the pinned `v0.1.0a14` command above, or add `--prerelease` | | Missing installer assets | The selected release is older than this installer | Choose `v0.1.0a2` or later; never mix files from different releases | | A checksum mismatch | A downloaded file is not the published one | Stop, and download the complete release again from the same trusted source | | The installer refuses to replace `weave` | Another program already uses that command path | Choose a different `--bin-dir`, or inspect the old command before replacing it yourself | @@ -268,7 +268,7 @@ delete them yourself if you want no trace. This route installs **unreleased development source from `main`**, for contributors and for trying changes made after the latest release. You do not -need it for any feature this documentation describes: the v0.1.0a13 release +need it for any feature this documentation describes: the v0.1.0a14 release includes them. Behavior on `main` can change before the next release. Follow the documentation of the same checkout. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 149d99aa..23f6a1e7 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -99,7 +99,7 @@ platform; the others need the `client` extra and either a | Validate, compile, explain, simulate, or draw a local workflow | `workflow validate`, `compile`, `explain`, `simulate`, `graph` | Base package and explicit files; [authoring guide](../guides/workflow-authoring.md) | | Export the language schemas | `schema export` | Base package | | Describe an HTTP API without code, import OpenAPI, or build a connector package | `connector http-action`, `import-openapi`, `descriptor`, `init`, `validate`, `test`, `package` | Base package; publishing needs a platform; [no-code REST integration](../connectors/http-without-code.md) | -| Run durable workflows on your computer | `platform doctor`, `setup`, `start`, `status`, `demo`, `user`, `token`, `stop` | Matching checkout, uv, and local Docker; [local platform](../guides/local-platform.md) | +| Run durable workflows on your computer | `platform doctor`, `up`, `setup`, `start`, `status`, `logs`, `demo`, `user`, `token`, `stop` | Matching checkout, uv, and local Docker; [local platform](../guides/local-platform.md) | | Run built-in HTTP connector Actions locally | `platform integrations`, `platform secret` (new in 0.1.0a7) | A running local platform | | Draw workflows and work with tasks in your browser | `studio`, `studio install`, `studio configure` | The `studio` extra and a matching browser bundle; [Studio](../guides/studio.md) | | Connect to a platform, sign in, choose a workspace | `auth setup`, `login`, `status`, `logout`, `profiles`, `use`, `remove`, `workspace` | `client` extra; [connect the CLI](../guides/connect-to-api.md) | @@ -352,13 +352,16 @@ without executors. See [history and replay](history-and-replay.md). the API, PostgreSQL, and Keycloak on your computer. The default directory is `.local/platform`, relative to your current folder; reuse the same absolute path in every terminal. Follow [the local platform guide](../guides/local-platform.md) -before setup. +before setup. The [Docker route](../guides/docker-development.md) combines the +initial steps in one `up` command. | Command | Effect | | --- | --- | | `doctor [--source REPO] [--context NAME]` | Check the source version, uv, and local Docker without changing anything | +| `up [--source REPO] [--context NAME] [--subnet CIDR] [--username NAME] [--role ROLE ...]` | New in alpha14: prepare once and leave the API running in Docker; create the demo workspace and optionally a sign-in account; repeating preserves data and the account | +| `logs [--lines COUNT]` | Read the owned Docker API logs; default 100 lines, maximum 1,000 | | `setup [--source REPO] [--context NAME] [--subnet CIDR]` | Build and install an isolated server, start its own dependencies, and set up identity; `--subnet` sets an unused private (RFC 1918) IPv4 Docker subnet when the default pools are exhausted | -| `start` | Resume the dependencies and run the API in the foreground; Ctrl+C stops only the API | +| `start` | Resume the saved mode: detached API for a Docker installation, foreground API for a host installation | | `status` | Show the saved stage, API and identity readiness, URLs, whether a first run is saved, and the sign-in command | | `demo` | Create one authorized demo run; repeating it reuses the saved receipt | | `user --username NAME [--role ROLE ...]` | New in 0.1.0a7: create a development sign-in account, a linked person, and roles in the demo workspace | @@ -368,7 +371,7 @@ before setup. | `secret set --handle HANDLE [--value-stdin]` | New in 0.1.0a7: create or replace a development secret value behind a handle | | `secret list`, `secret remove --handle HANDLE` | New in 0.1.0a7: list handle names (never values), or delete one value | | `token` | Refresh the verified host token in its private file and print only the path | -| `stop` | Stop the dependencies after the foreground API exits; data is kept | +| `stop` | Stop the Docker API and dependencies; for host mode, stop the foreground API first. Data is kept | Every command except `start` accepts `--output json`. Setup shows a spinner only in an interactive terminal; set `WEAVE_NO_ANIMATION=1` for plain progress @@ -399,8 +402,10 @@ people connect with - `enable` needs the demo workspace and a running API; repeating it reuses what exists. -- Restart the API (Ctrl+C, then `start`) after `enable` and after adding a new - handle. A replaced value applies at the next credential use. +- Run `platform start` after `enable` or changing secret handles. In foreground + mode, first stop the API with Ctrl+C; a replaced value applies at its next use. + In Docker mode, also run `start` after replacing or removing a value: the running + container retains its previous secret snapshot until the restart succeeds. - Handles match `[a-z0-9][a-z0-9_.-]{0,63}`. `secret set` reads the value from a hidden prompt, or from piped standard input with `--value-stdin`, never from arguments; `--value-stdin` refuses a terminal. @@ -415,10 +420,10 @@ people connect with weave platform secret set --handle pets-api-key --value-stdin < "$HOME/.weave-secrets/pets-api-key" ``` -Expected: `Handle: pets-api-key`, then `Stored privately. Restart the API -(Ctrl-C, then start) so the demo environment can use this handle.` When the -handle already had a value, the second line starts with `Replaced privately.` -and no restart is needed. +Expected: `Handle: pets-api-key`, followed by mode-specific restart instructions. +When the handle already had a value, the message starts with `Replaced privately.` +Foreground mode reads the replacement at its next use; Docker mode requires +`platform start` to refresh the mounted copy. ## Studio commands diff --git a/docs/reference/local-runtime.md b/docs/reference/local-runtime.md index f337bf3e..aa025b2f 100644 --- a/docs/reference/local-runtime.md +++ b/docs/reference/local-runtime.md @@ -411,7 +411,7 @@ supervision, none of which the local setup scripts provision. The locked framework is the published **PyFly 26.9.15**; the exact wheel hash and upstream commit are in the [project metadata](../../pyproject.toml). The API -package version is `0.1.0a13`; a checkout of `main` can carry unreleased changes +package version is `0.1.0a14`; a checkout of `main` can carry unreleased changes on top of it. Validate readiness, an authorized workflow, and your [backup and restore procedure](../operations/backup-restore.md) in the environment you intend to operate. diff --git a/docs/reference/native-openapi.md b/docs/reference/native-openapi.md index f768b52f..665fe814 100644 --- a/docs/reference/native-openapi.md +++ b/docs/reference/native-openapi.md @@ -118,7 +118,7 @@ each of these. ## Versions and what the document proves - The document's `info.version` is the API version, `1`. It is separate from - the package version (`0.1.0a13`) and from the workflow language version + the package version (`0.1.0a14`) and from the workflow language version (`weave/v1alpha1`). - The exporter uses PyFly 26.9.15. The exact URL, SHA-256, and upstream provenance are in [project metadata](../../pyproject.toml) and the lockfile. diff --git a/mkdocs.yml b/mkdocs.yml index d1ebbbb8..5c6b5a31 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -95,7 +95,8 @@ nav: - Your first workflow: quickstart.md - Who does what: guides/roles-and-lifecycle.md - Deploy, start, and use the platform: guides/platform-overview.md - - Start a local platform: guides/local-platform.md + - Start a development platform with Docker: guides/docker-development.md + - Local setup in individual steps: guides/local-platform.md - Visual guide: visual-guide.md - Capabilities and limits: capabilities.md - Workflows and runs: diff --git a/pyproject.toml b/pyproject.toml index b65abaa4..cb5f0540 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ build-backend = "hatchling.build" [project] name = "firefly-weave" -version = "0.1.0a13" +version = "0.1.0a14" description = "API-first workflow orchestration and integration platform built on PyFly" readme = "README.md" license = "Apache-2.0" @@ -123,7 +123,7 @@ include = [ "/pyproject.toml", "/uv.lock", "/README.md", "/LICENSE", "/NOTICE", "/studio", "/desktop", "/CONTRIBUTING.md", "/SECURITY.md", "/CHANGELOG.md", "/Makefile", "/Dockerfile", "/install.sh", "/.dockerignore", "/.gitignore", "/.github/workflows/ci.yml", "/.github/workflows/docs.yml", "/mkdocs.yml", - "/.github/workflows/desktop.yml", + "/.github/workflows/desktop.yml", "/.github/workflows/desktop-notarized.yml", "/alembic.ini", "/pyfly.yaml", "/compose.yaml", "/compose.identity.yaml", "/compose.kafka.yaml", "/compose.runtime.yaml", "/compose.local-runtime.yaml", "/src", "/workers", "/migrations", "/examples", "/infra", "/docker", "/scripts", "/tests", "/assets", "/deploy", diff --git a/src/firefly_weave/__init__.py b/src/firefly_weave/__init__.py index c7c37300..417cca15 100644 --- a/src/firefly_weave/__init__.py +++ b/src/firefly_weave/__init__.py @@ -16,4 +16,4 @@ """Firefly Weave's offline contracts and workflow tooling.""" -__version__ = "0.1.0a13" +__version__ = "0.1.0a14" diff --git a/src/firefly_weave/cli/platform.py b/src/firefly_weave/cli/platform.py index d3ae9dd8..45fd5d35 100644 --- a/src/firefly_weave/cli/platform.py +++ b/src/firefly_weave/cli/platform.py @@ -78,7 +78,8 @@ def platform(ctx: click.Context, directory: Path) -> None: """Set up and run a local API with PostgreSQL and Keycloak. Requires a matching source checkout, uv, and local Docker with Compose. - Start with setup; then start holds the API in the foreground. In another + Use up for a persistent Docker API and demo workspace. For a foreground API, + start with setup; then start holds the API in the foreground. In another terminal, run demo to save your first real workflow execution, then user to create a development person who can sign in to Studio and the CLI. To run built-in HTTP connector actions, use integrations and secret. @@ -128,12 +129,18 @@ def operation() -> dict[str, Any]: @platform.command() @click.pass_obj def start(directory: Path) -> None: - """Resume dependencies and run the API here; Ctrl-C stops only the API. + """Resume the saved platform: detached Docker or foreground host API. Reuses the existing runtime and identity. Never migrates or provisions again. """ - click.echo("Starting the local API. Keep this terminal open; Ctrl-C stops the API.") - _call(lambda: lifecycle.start(directory, click.echo), "text") + + def operation() -> None: + mode = lifecycle._load(directory).get("mode", "host") + if mode == "host": + click.echo("Starting the local API. Keep this terminal open; Ctrl-C stops the API.") + lifecycle.start(directory, click.echo) + + _call(operation, "text") @platform.command() @@ -148,7 +155,7 @@ def status(directory: Path, output: str) -> None: @click.option("--output", type=click.Choice(["text", "json"]), default="text") @click.pass_obj def stop(directory: Path, output: str) -> None: - """Stop only this installation's dependencies after Ctrl-C stops its API. + """Stop this Docker platform, or host dependencies after Ctrl-C stops its API. Keeps containers, volumes, databases, credentials, and workflow data. """ @@ -344,7 +351,8 @@ def _show_secret(value: dict[str, Any]) -> None: def secret_set(directory: Path, handle: str, value_stdin: bool, output: str) -> None: """Create or replace the value behind a handle in the demo environment. - A new handle applies after the API restarts; a replaced value applies at + Docker handles and replacements apply after platform start refreshes the + API. In host mode, a new handle needs a restart; a replacement applies at the next credential use. """ @@ -393,3 +401,65 @@ def secret_list(directory: Path, output: str) -> None: def secret_remove(directory: Path, handle: str, output: str) -> None: """Delete the value behind a handle; a restart withdraws the handle.""" _call(lambda: lifecycle.secret_remove(directory, handle), output, _show_secret) + + +@platform.command() +@click.option("--source", type=click.Path(path_type=Path), default=Path("."), show_default=True) +@click.option("--context", help="Named local Docker context; existing installations keep their saved context.") +@click.option("--subnet", help="Optional unused RFC1918 IPv4 subnet for a new installation.") +@click.option("--username", help="Create the initial development sign-in account once.") +@click.option( + "--role", + "roles", + multiple=True, + type=click.Choice(lifecycle.PERSON_ROLES), + help="Role for the initial account; repeat for several roles. Requires --username.", +) +@click.option("--output", type=click.Choice(["text", "json"]), default="text") +@click.pass_obj +def up( + directory: Path, + source: Path, + context: str | None, + subnet: str | None, + username: str | None, + roles: tuple[str, ...], + output: str, +) -> None: + """Set up once and leave a Docker API, database and identity provider running. + + Saves a successful demo workflow. Repeating up resumes retained services; + it never resets an account, password, or database. Credentials are shown + only when creating the initial development account. + """ + from firefly_weave.cli.progress import progress + + def operation() -> dict[str, Any]: + with progress("Preparing the local Docker platform", enabled=output == "text") as update: + return lifecycle.up( + directory, source, context, subnet=subnet, username=username, roles=roles, progress=update + ) + + def render(value: dict[str, Any]) -> None: + click.echo("Docker platform is running; you can close this terminal.") + click.echo("API: " + value["api_url"]) + click.echo("Data and configuration: " + str(directory)) + account = value.get("account") + if account and not account.get("existing"): + _show_person(account) + elif account: + click.echo("Existing account retained: " + account["username"] + ". Its password was not reset.") + click.echo("Connect: weave auth setup " + value["api_url"]) + click.echo("Open Studio: weave studio") + click.echo("Logs: weave platform --directory " + shlex.quote(str(directory)) + " logs") + + _call(operation, output, render) + + +@platform.command() +@click.option("--lines", type=click.IntRange(1, 1000), default=100, show_default=True) +@click.option("--output", type=click.Choice(["text", "json"]), default="text") +@click.pass_obj +def logs(directory: Path, lines: int, output: str) -> None: + """Show recent logs from this installation's Docker API.""" + _call(lambda: lifecycle.logs(directory, lines), output, lambda value: click.echo(value["logs"], nl=False)) diff --git a/src/firefly_weave/sdk/platform.py b/src/firefly_weave/sdk/platform.py index 7831c117..decabdd1 100644 --- a/src/firefly_weave/sdk/platform.py +++ b/src/firefly_weave/sdk/platform.py @@ -49,6 +49,7 @@ "uv.lock", "compose.yaml", "compose.identity.yaml", + "compose.local-runtime.yaml", "infra/postgres/init.sql", "scripts/setup-local.py", "scripts/setup-identity.py", @@ -297,6 +298,8 @@ def _load(directory: Path, *, complete: bool = True) -> dict[str, Any]: state = strict_json(read_file(directory / "platform.json", 65536, private=True)) if not isinstance(state, dict) or state.get("format") != _FORMAT: raise PlatformError("This is not a local platform installation directory.") + if state.get("mode", "host") not in {"host", "docker"}: + raise PlatformError("Installation mode is invalid.") identifier = state.get("id", "") if not isinstance(identifier, str) or re.fullmatch(r"[a-f0-9]{24}", identifier) is None: raise PlatformError("Installation ownership metadata is invalid.") @@ -374,6 +377,10 @@ def _compose(state: dict[str, Any]) -> list[str]: if read_file(override, 4096, private=True) != _network_override(state): raise PlatformError("The saved network configuration has changed; no services were modified.") command.extend(["-f", str(override)]) + if state.get("mode") == "docker": + from firefly_weave.sdk import platform_docker + + command.extend(["-f", str(platform_docker.dependencies(state))]) return command @@ -486,7 +493,10 @@ def setup( *, subnet: str | None = None, progress: Callable[[str], None] | None = None, + mode: str = "host", ) -> dict[str, Any]: + if mode not in {"host", "docker"}: + raise PlatformError("Installation mode is invalid.") directory = real_path(directory) if directory.exists(): raise PlatformError( @@ -497,6 +507,7 @@ def setup( if subnet is not None: subnet = _check_subnet(state["context"], subnet) state["subnet"] = subnet + state["mode"] = mode directory.parent.mkdir(parents=True, exist_ok=True) directory.mkdir(mode=0o700) state.update(format=_FORMAT, id=uuid4().hex[:24], directory=str(directory), ports=_ports(), stage="reserved") @@ -659,7 +670,7 @@ def _session(state: dict[str, Any]) -> None: "WEAVE_POSTGRES_VOLUME": "weave-local-" + state["id"] + "-postgres", "WEAVE_KEYCLOAK_VOLUME": "weave-local-" + state["id"] + "-keycloak", "WEAVE_KEYCLOAK_TEST_URL": f"http://localhost:{ports['keycloak']}", - "WEAVE_API_URL": f"http://127.0.0.1:{ports['api']}", + "WEAVE_API_URL": _summary(state)["api_url"], } with os.fdopen( os.open(directory / "session.env", os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600), "w" @@ -695,11 +706,14 @@ def _demo_receipt(directory: Path) -> dict[str, Any]: def _summary(state: dict[str, Any]) -> dict[str, Any]: - url = f"http://127.0.0.1:{state['ports']['api']}" + mode = state.get("mode", "host") + port = state["ports"]["container_api" if mode == "docker" else "api"] + url = f"http://127.0.0.1:{port}" return { "ok": True, "directory": state["directory"], "stage": state["stage"], + "mode": mode, "api_url": url, "docs_url": url + "/docs", "context": state["context"], @@ -711,7 +725,12 @@ def status(directory: Path) -> dict[str, Any]: state = _load(directory, complete=False) _check_engine(state) result = _summary(state) - result["api_ready"] = _probe(result["api_url"] + "/health/ready") + if state.get("mode") == "docker": + from firefly_weave.sdk import platform_docker + + result["api_container"] = platform_docker.inspect(state) + owned_running = state.get("mode") != "docker" or result["api_container"]["state"] == "running" + result["api_ready"] = owned_running and _probe(result["api_url"] + "/health/ready") result["identity_ready"] = _probe( f"http://localhost:{state['ports']['keycloak']}/realms/weave/.well-known/openid-configuration", f"http://localhost:{state['ports']['keycloak']}/realms/weave", @@ -753,6 +772,11 @@ def start(directory: Path, notice: Callable[[str], None] | None = None) -> None: state = _load(directory) _check_engine(state) _verify_runtime(state) + if state.get("mode") == "docker": + from firefly_weave.sdk import platform_docker + + platform_docker.start(state, notice or (lambda message: None)) + return _check_api_port(state["ports"]["api"]) # Static server settings: handles and executors added later apply at the next start. execution = _execution_environment(state, notice or (lambda message: None)) @@ -820,6 +844,10 @@ def start(directory: Path, notice: Callable[[str], None] | None = None) -> None: def _runtime_build(state: dict[str, Any]) -> dict[str, Any]: """Ask the installed runtime for its build identity and built-in HTTP descriptor.""" + if state.get("mode") == "docker": + from firefly_weave.sdk import platform_docker + + return platform_docker.build_identity(state) return _parse_build(_run(state, "build-identity", [_python(state), "-I", "-c", _BUILD_PROBE])) @@ -931,7 +959,7 @@ def _execution_environment(state: dict[str, Any], notice: Callable[[str], None]) "release_id": integration["release_id"], "task_types": integration["task_types"], "capacity": integration["capacity"], - "build": "local-development", + "build": "image" if state.get("mode") == "docker" else "local-development", } env["WEAVE_NATIVE_IMAGE_DIGEST"] = integration["image_digest"] env["WEAVE_NATIVE_EXECUTORS"] = json.dumps([executor], separators=(",", ":")) @@ -1245,6 +1273,7 @@ def secret_set(directory: Path, handle: str, value: bytes) -> dict[str, Any]: The value is never printed, logged, or passed to a process; connections reference the handle. """ scope = secret_preflight(directory, handle) + docker = _load(directory).get("mode") == "docker" secret = _secret_value(value) with _exclusive(directory, ".secrets.lock", "Another secret command is active; retry after it ends."): store = cast(Path, _secret_store(directory, create=True)) @@ -1272,9 +1301,13 @@ def secret_set(directory: Path, handle: str, value: bytes) -> dict[str, Any]: "provider": "file", "scope": scope, "created": created, - "restart_required": created, + "restart_required": created or docker, "message": ( - "Stored privately. Restart the API (Ctrl-C, then start) so the demo environment can use this handle." + "Stored privately. The running Docker API keeps its previous mounted snapshot until " + + _command(directory, "start") + + " succeeds." + if docker + else "Stored privately. Restart the API (Ctrl-C, then start) so the demo environment can use this handle." if created else "Replaced privately. An API started after this handle existed reads the new value at its next use." ), @@ -1290,7 +1323,7 @@ def secret_list(directory: Path) -> dict[str, Any]: def secret_remove(directory: Path, handle: str) -> dict[str, Any]: """Delete one stored development secret value; a restart withdraws its grant.""" _check_handle(handle) - _load(directory) + docker = _load(directory).get("mode") == "docker" with _exclusive(directory, ".secrets.lock", "Another secret command is active; retry after it ends."): if handle not in _secret_handles(directory): raise PlatformError(f"No local secret named {handle} exists in this installation.") @@ -1300,7 +1333,13 @@ def secret_remove(directory: Path, handle: str) -> dict[str, Any]: "handle": handle, "removed": True, "restart_required": True, - "message": "Deleted the value. Restart the API (Ctrl-C, then start) to withdraw the handle.", + "message": ( + "Removed the stored handle. The running Docker API retains its mounted value until " + + _command(directory, "start") + + " succeeds. Prior snapshots remain in this private installation directory." + if docker + else "Deleted the value. Restart the API (Ctrl-C, then start) to withdraw the handle." + ), } @@ -1310,6 +1349,10 @@ def stop(directory: Path) -> dict[str, Any]: _check_engine(state) if not (directory / "postgres.env").is_file() or not (directory / "identity.env").is_file(): raise PlatformError("Setup did not reach dependency creation; no services need stopping.") + if state.get("mode") == "docker": + from firefly_weave.sdk import platform_docker + + platform_docker.stop(state) _run( state, "dependencies-stop", @@ -1649,3 +1692,67 @@ def user( "api_url": api, "next": ["weave auth setup " + api, "weave studio"], } + + +def up( + directory: Path, + source: Path, + context: str | None, + *, + subnet: str | None = None, + username: str | None = None, + roles: Sequence[str] = (), + progress: Callable[[str], None] | None = None, +) -> dict[str, Any]: + """Prepare once, then resume the retained Docker platform and its demo workspace.""" + if roles and username is None: + raise PlatformError("Account roles require --username.") + selected = _person_roles(roles) + if username is not None: + _check_username(username) + if not directory.exists(): + setup(directory, source, context, subnet=subnet, progress=progress, mode="docker") + state = _load(directory) + if state.get("mode", "host") != "docker": + raise PlatformError("This installation uses foreground host mode. Use start, or up with a new directory.") + if context is not None and context != state["context"] or subnet is not None and subnet != state.get("subnet"): + raise PlatformError("Use the installation's saved Docker context and subnet.") + with _exclusive(directory, ".up.lock", "Another platform up command is active."): + start(directory, progress) + first = demo(directory) + result = {**_summary(state), "first_run_saved": True, "run": first["receipt"]} + if username is not None: + path = directory / "up-user.json" + if path.exists(): + account = strict_json(read_file(path, 65536, private=True)) + if account.get("username") != username or account.get("stage") != "ready": + raise PlatformError( + "The initial account receipt differs or is incomplete. " + "Inspect it; use user for another account." + ) + saved_roles = account.get("roles", list(DEFAULT_PERSON_ROLES)) + if roles and list(selected) != saved_roles: + raise PlatformError( + "The existing account roles differ. They were not changed; review its access explicitly." + ) + result["account"] = {"username": username, "existing": True, "roles": saved_roles} + else: + _write(path, {"stage": "attempted", "username": username, "roles": list(selected)}) + account = user(directory, username, selected) + _write(path, {"stage": "ready", "username": username, "roles": list(selected)}, replace=True) + result["account"] = account + return result + + +def logs(directory: Path, lines: int = 100) -> dict[str, Any]: + """Read bounded API logs from this installation's exact Docker context/project.""" + if not 1 <= lines <= 1000: + raise PlatformError("Choose 1 through 1000 log lines.") + with _lock(directory): + state = _load(directory) + _check_engine(state) + if state.get("mode") != "docker": + raise PlatformError("Host mode prints API logs in its foreground terminal.") + from firefly_weave.sdk import platform_docker + + return {"ok": True, "logs": platform_docker.logs(state, lines)} diff --git a/src/firefly_weave/sdk/platform_docker.py b/src/firefly_weave/sdk/platform_docker.py new file mode 100644 index 00000000..db8c3ab0 --- /dev/null +++ b/src/firefly_weave/sdk/platform_docker.py @@ -0,0 +1,383 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Detached API transport for an already guarded, owned local installation.""" + +from __future__ import annotations + +import hashlib +import json +import re +import tempfile +from collections.abc import Callable +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit, urlunsplit +from uuid import uuid4 + +from firefly_weave.sdk import platform as local +from firefly_weave.sdk.deployment import read_file, real_path, strict_json + + +def _private_bytes(path: Path, data: bytes) -> None: + with tempfile.NamedTemporaryFile(dir=path.parent, prefix=".docker-", delete=False) as stream: + stream.write(data) + temporary = Path(stream.name) + temporary.replace(path) + + +def dependencies(state: dict[str, Any]) -> Path: + path = Path(state["directory"]) / "compose.persistence.json" + document = {"services": {name: {"restart": "unless-stopped"} for name in ("postgres", "keycloak", "keycloak-db")}} + expected = (json.dumps(document, sort_keys=True) + "\n").encode() + if path.exists(): + if read_file(path, 4096, private=True) != expected: + raise local.PlatformError("The saved Docker lifecycle configuration has changed.") + else: + local._write(path, document) + # Use the same canonical representation when checking subsequent commands. + _private_bytes(path, expected) + return path + + +def environment(state: dict[str, Any], execution: dict[str, str]) -> dict[str, str]: + runtime = local._env_file(Path(state["directory"]) / "runtime.env") + values = {"WEAVE_DOCS_ENABLED": "true", "WEAVE_DISPLAY_NAME": local._DISPLAY_NAME} + for key, role in (("WEAVE_DATABASE_URL", "weave_runtime_"), ("WEAVE_SCHEDULER_DATABASE_URL", "weave_scheduler_")): + parsed = urlsplit(runtime[key]) + if ( + parsed.scheme != "postgresql+asyncpg" + or parsed.hostname not in {"localhost", "127.0.0.1"} + or parsed.port != state["ports"]["postgres"] + or not (parsed.username or "").startswith(role) + or not parsed.password + or not parsed.path.startswith("/weave_b2_dev_") + or parsed.query + or parsed.fragment + ): + raise local.PlatformError("The saved runtime database is outside this installation.") + authority = parsed.netloc.rsplit("@", 1)[0] + values[key] = urlunsplit((parsed.scheme, authority + "@postgres:5432", parsed.path, "", "")) + providers = json.loads(runtime["WEAVE_OIDC_PROVIDERS"]) + issuer = f"http://localhost:{state['ports']['keycloak']}/realms/weave" + if ( + not isinstance(providers, list) + or len(providers) != 1 + or providers[0].get("provider_id") != local._PROVIDER_ID + or providers[0].get("issuer") != issuer + or providers[0].get("jwks_uri") != issuer + "/protocol/openid-connect/certs" + or providers[0].get("local_development") is not True + ): + raise local.PlatformError("The saved identity configuration is outside this installation.") + providers[0]["jwks_uri"] = "http://127.0.0.1:8080/realms/weave/protocol/openid-connect/certs" + values["WEAVE_OIDC_PROVIDERS"] = json.dumps(providers, separators=(",", ":")) + sign_in = local._client_sign_in(runtime) + if sign_in is not None: + values["WEAVE_CLIENT_SIGN_IN"] = sign_in + allowed = {"WEAVE_SECRET_ROOT", "WEAVE_SECRET_GRANTS", "WEAVE_NATIVE_IMAGE_DIGEST", "WEAVE_NATIVE_EXECUTORS"} + if set(execution) - allowed: + raise local.PlatformError("Unexpected container execution configuration.") + values.update(execution) + if "WEAVE_SECRET_ROOT" in values: + values["WEAVE_SECRET_ROOT"] = "/run/weave-secrets" + return values + + +def _image(state: dict[str, Any]) -> str: + value = state.get("docker_image") + if not isinstance(value, str) or re.fullmatch(r"sha256:[a-f0-9]{64}", value) is None: + raise local.PlatformError("The retained Docker image is missing; run platform up to build it.") + actual = local._run( + state, + "image-check", + ["docker", "--context", state["context"], "image", "inspect", value, "--format", "{{.Id}}"], + ) + if actual.decode().strip() != value: + raise local.PlatformError("The retained Docker image identity changed.") + return value + + +def _build(state: dict[str, Any], notice: Callable[[str], None]) -> None: + if state.get("docker_image") is not None: + _image(state) + return + directory = Path(state["directory"]) + release = strict_json(read_file(directory / "release/release.json", 65536)) + context = directory / "release/images" + if not release.get("complete"): + raise local.PlatformError("The retained release is incomplete.") + for name, digest in release["inputs"].items(): + if Path(name).name != name or hashlib.sha256(read_file(context / name, 64 * 1024 * 1024)).hexdigest() != digest: + raise local.PlatformError("The retained Docker build inputs changed.") + if strict_json(read_file(context / "release.json", 65536)) != release: + raise local.PlatformError("The Docker build receipt differs from the retained release.") + notice("Building the retained server image for this Docker platform") + receipt = directory / "api-image.id" + if receipt.exists(): + raise local.PlatformError("An earlier image build is incomplete; inspect api-image.id before recovery.") + local._run( + state, + "image-build", + [ + "docker", + "--context", + state["context"], + "build", + "--target", + "server", + "--iidfile", + str(receipt), + str(context), + ], + timeout=900, + ) + value = read_file(receipt, 128).decode().strip() + if re.fullmatch(r"sha256:[a-f0-9]{64}", value) is None: + raise local.PlatformError("Docker did not report an immutable image identity.") + state["docker_image"] = value + local._write(directory / "platform.json", state, replace=True) + _image(state) + + +def build_identity(state: dict[str, Any]) -> dict[str, Any]: + image = _image(state) + value = local._parse_build( + local._run( + state, + "image-build-identity", + [ + "docker", + "--context", + state["context"], + "run", + "--rm", + "--network", + "none", + image, + "python", + "-I", + "-c", + local._BUILD_PROBE, + ], + ) + ) + value["identity"] = image + return value + + +def _configuration(state: dict[str, Any], execution: dict[str, str]) -> Path: + directory = Path(state["directory"]) + values = environment(state, execution) + if any("\n" in v or "\r" in v for v in values.values()): + raise local.PlatformError("Container configuration contains unsupported line breaks.") + env_file = directory / "api-container.env" + _private_bytes(env_file, "".join(f"{k}={v}\n" for k, v in values.items()).encode()) + service: dict[str, Any] = { + "image": state["docker_image"], + "restart": "unless-stopped", + "network_mode": "service:keycloak", + "read_only": True, + "cap_drop": ["ALL"], + "security_opt": ["no-new-privileges:true"], + "tmpfs": ["/tmp:size=67108864,mode=1777"], + "env_file": [{"path": str(env_file), "format": "raw"}], + "depends_on": {"postgres": {"condition": "service_healthy"}, "keycloak": {"condition": "service_started"}}, + "healthcheck": { + "test": [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=2)", + ], + "interval": "2s", + "timeout": "3s", + "retries": 60, + }, + } + if "WEAVE_SECRET_ROOT" in values: + # Only selected provider values are mounted, never the installation or identity files. + store = directory / "container-secrets" + if store.exists(): + real_path(store) + else: + store.mkdir(mode=0o755) + index_path = directory / "container-secrets.json" + index = strict_json(read_file(index_path, 65536, private=True)) if index_path.exists() else {} + mounts = [] + for handle in local._secret_handles(directory): + value = read_file(directory / "secrets" / handle, local.SECRET_VALUE_LIMIT, private=True) + digest = hashlib.sha256(value).hexdigest() + previous = index.get(handle) + if previous is not None: + name = previous.get("file", "") + if not isinstance(name, str) or re.fullmatch(r"[a-f0-9]{32}", name) is None: + raise local.PlatformError("The container secret receipt is invalid.") + copied = read_file(store / name, local.SECRET_VALUE_LIMIT) + if hashlib.sha256(copied).hexdigest() != previous.get("sha256"): + raise local.PlatformError("A retained container secret changed outside platform commands.") + if previous is None or previous["sha256"] != digest: + # A new opaque mount source makes Compose refresh this API on rotation, without + # exposing a credential hash in container metadata or changing the live old mount. + previous = {"file": uuid4().hex, "sha256": digest} + destination = store / previous["file"] + _private_bytes(destination, value) + destination.chmod(0o444) + index[handle] = previous + destination = store / previous["file"] + mounts.append( + { + "type": "bind", + "source": str(destination), + "target": "/run/weave-secrets/" + handle, + "read_only": True, + } + ) + local._write(index_path, index, replace=index_path.exists()) + service["volumes"] = mounts + path = directory / "compose.api.json" + local._write(path, {"services": {"api": service}}, replace=path.exists()) + return path + + +def _command(state: dict[str, Any]) -> list[str]: + path = Path(state["directory"]) / "compose.api.json" + read_file(path, 65536, private=True) + return [*local._compose(state), "-f", str(path)] + + +def inspect(state: dict[str, Any]) -> dict[str, str]: + # Query by Compose's exact ownership labels; never act on a name selected by external input. + project = "weave-local-" + state["id"] + docker = ["docker", "--context", state["context"]] + ids = ( + local._run( + state, + "api-inventory", + [ + *docker, + "ps", + "--all", + "--quiet", + "--filter", + "label=com.docker.compose.project=" + project, + "--filter", + "label=com.docker.compose.service=api", + ], + ) + .decode() + .split() + ) + if not ids: + return {"state": "absent"} + if len(ids) != 1 or re.fullmatch(r"[a-f0-9]{12,64}", ids[0]) is None: + raise local.PlatformError("The owned API container inventory is ambiguous.") + records = strict_json(local._run(state, "api-inspect", [*docker, "inspect", ids[0]])) + record = records[0] + labels = record["Config"]["Labels"] + if ( + labels.get("com.docker.compose.project") != project + or labels.get("com.docker.compose.service") != "api" + or record["Image"] != state.get("docker_image") + ): + raise local.PlatformError("The owned API container identity differs; no container was changed.") + return { + "state": record["State"]["Status"], + "id": record["Id"], + "network_mode": record["HostConfig"]["NetworkMode"], + } + + +def start(state: dict[str, Any], notice: Callable[[str], None]) -> None: + _build(state, notice) + existing = inspect(state) + local._run( + state, + "dependencies-start", + [ + *local._compose(state), + "up", + "--detach", + "--no-recreate", + "--wait", + "--wait-timeout", + "180", + "postgres", + "keycloak", + ], + env=local._compose_env(state), + ) + local._wait_identity(state) + local._repair_login_client(state, notice) + execution = local._execution_environment(state, notice) + _configuration(state, execution) + local._run(state, "api-compose-check", [*_command(state), "config", "--quiet"], env=local._compose_env(state)) + recreate: list[str] = [] + if existing["state"] != "absent": + identity = ( + local._run( + state, + "identity-container", + [*local._compose(state), "ps", "--quiet", "keycloak"], + env=local._compose_env(state), + ) + .decode() + .strip() + ) + if re.fullmatch(r"[a-f0-9]{64}", identity) is None: + raise local.PlatformError("The owned Keycloak container inventory is incomplete.") + if existing["network_mode"] != "container:" + identity: + recreate = ["--force-recreate"] + notice("Starting the owned Docker API; it will keep running after this command exits") + local._run( + state, + "api-start", + [ + *_command(state), + "up", + "--detach", + "--no-deps", + "--no-build", + "--pull", + "never", + "--wait", + "--wait-timeout", + "180", + *recreate, + "api", + ], + env=local._compose_env(state), + ) + if not local._probe(local._summary(state)["api_url"] + "/health/ready"): + raise local.PlatformError( + "The Docker API is not ready. Inspect platform logs; retained services were not removed." + ) + + +def stop(state: dict[str, Any]) -> None: + container = inspect(state) + if container["state"] != "absent": + local._run( + state, "api-stop", ["docker", "--context", state["context"], "stop", "--time", "30", container["id"]] + ) + + +def logs(state: dict[str, Any], lines: int) -> str: + container = inspect(state) + if container["state"] == "absent": + raise local.PlatformError("The owned API container has not been created yet; inspect private setup logs.") + return local._run( + state, "api-logs", ["docker", "--context", state["context"], "logs", "--tail", str(lines), container["id"]] + ).decode(errors="replace") diff --git a/studio/tests/browser/real-platform.spec.ts b/studio/tests/browser/real-platform.spec.ts index 1f58fdfb..46a50508 100644 --- a/studio/tests/browser/real-platform.spec.ts +++ b/studio/tests/browser/real-platform.spec.ts @@ -119,7 +119,7 @@ function setup(): Setup { if (cached) return cached; const state = JSON.parse( readFileSync(join(platformDir, "platform.json"), "utf8"), - ) as { ports: Record }; + ) as { mode?: "host" | "docker"; ports: Record }; const identity = readFileSync(join(platformDir, "identity.env"), "utf8"); const secret = identity .split("\n") @@ -133,7 +133,7 @@ function setup(): Setup { ?.scope as Setup["environment"] | undefined; if (!environment) throw Error("The person has no environment grant"); cached = { - api: `http://127.0.0.1:${state.ports["api"]}`, + api: `http://127.0.0.1:${state.ports[state.mode === "docker" ? "container_api" : "api"]}`, keycloak: `http://localhost:${state.ports["keycloak"]}`, keycloakHost: `localhost:${state.ports["keycloak"]}`, adminSecret: secret, diff --git a/tests/unit/cli/test_platform_docker.py b/tests/unit/cli/test_platform_docker.py new file mode 100644 index 00000000..f2315fcb --- /dev/null +++ b/tests/unit/cli/test_platform_docker.py @@ -0,0 +1,446 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Detached local platform lifecycle and narrow container authority.""" + +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from firefly_weave import __version__ +from firefly_weave.cli.main import cli +from firefly_weave.sdk import platform + + +@pytest.fixture +def owned(tmp_path, monkeypatch): + directory = tmp_path / "platform" + directory.mkdir(mode=0o700) + state = { + "format": platform._FORMAT, + "id": "b" * 24, + "directory": str(directory), + "source": str(tmp_path), + "source_sha256": "fingerprint", + "version": __version__, + "stage": "ready", + "mode": "docker", + "context": "owned", + "endpoint": "unix:///owned.sock", + "engine": "owned-engine", + "ports": {"postgres": 55101, "keycloak": 55102, "api": 55103, "container_api": 55104}, + } + platform._write(directory / "platform.json", state) + issuer = "http://localhost:55102/realms/weave" + runtime = { + "WEAVE_DATABASE_URL": "postgresql+asyncpg://weave_runtime_test:runtime-secret@localhost:55101/weave_b2_dev_test", + "WEAVE_SCHEDULER_DATABASE_URL": "postgresql+asyncpg://weave_scheduler_test:scheduler-secret@localhost:55101/weave_b2_dev_test", + "WEAVE_MIGRATION_DATABASE_URL": "owner-must-not-leak", + "WEAVE_OIDC_PROVIDERS": json.dumps( + [ + { + "provider_id": "local-keycloak", + "issuer": issuer, + "jwks_uri": issuer + "/protocol/openid-connect/certs", + "local_development": True, + "audience": "weave-api", + "clients": {"weave-cli": "human"}, + } + ] + ), + } + import shlex + + for name, values in { + "runtime.env": runtime, + "identity.env": {"WEAVE_HOST_SECRET": "host-secret", "WEAVE_KC_ADMIN_SECRET": "admin-secret"}, + "postgres.env": {"WEAVE_POSTGRES_PASSWORD": "owner-secret"}, + }.items(): + p = directory / name + p.write_text("".join(f"{k}={shlex.quote(v)}\n" for k, v in values.items())) + p.chmod(0o600) + monkeypatch.setattr(platform, "_source", lambda p: (p, "fingerprint")) + monkeypatch.setattr(platform, "_docker", lambda c: ("unix:///owned.sock", "owned-engine")) + monkeypatch.setattr(platform, "_verify_runtime", lambda s: None) + return directory, state + + +def test_up_cli_is_one_step_and_does_not_print_password_in_generic_summary(monkeypatch): + calls = [] + monkeypatch.setattr( + platform, + "up", + lambda *a, **kw: calls.append((a, kw)) or {"ok": True, "mode": "docker", "api_url": "http://127.0.0.1:12345"}, + ) + result = CliRunner().invoke(cli, ["platform", "up", "--username", "developer", "--output", "json"]) + assert result.exit_code == 0, result.output + assert calls[0][1]["username"] == "developer" + assert json.loads(result.output)["mode"] == "docker" + + +def test_unknown_mode_is_rejected_before_docker(owned, monkeypatch): + directory, state = owned + state["mode"] = "remote" + platform._write(directory / "platform.json", state, replace=True) + monkeypatch.setattr(platform, "_check_engine", lambda s: pytest.fail("unvalidated mode reached Docker")) + with pytest.raises(platform.PlatformError, match="mode"): + platform.status(directory) + + +def test_docker_summary_uses_published_container_port(owned): + _, state = owned + assert platform._summary(state)["api_url"] == "http://127.0.0.1:55104" + assert platform._summary(state)["mode"] == "docker" + + +def test_container_environment_contains_only_scoped_runtime_authority(owned): + from firefly_weave.sdk import platform_docker + + _, state = owned + values = platform_docker.environment(state, {}) + assert "postgres:5432" in values["WEAVE_DATABASE_URL"] + assert "postgres:5432" in values["WEAVE_SCHEDULER_DATABASE_URL"] + providers = json.loads(values["WEAVE_OIDC_PROVIDERS"]) + assert providers[0]["issuer"] == "http://localhost:55102/realms/weave" + assert providers[0]["jwks_uri"] == "http://127.0.0.1:8080/realms/weave/protocol/openid-connect/certs" + assert ( + not {"WEAVE_MIGRATION_DATABASE_URL", "WEAVE_HOST_SECRET", "WEAVE_KC_ADMIN_SECRET", "WEAVE_POSTGRES_PASSWORD"} + & values.keys() + ) + assert "must-not-leak" not in json.dumps(values) + assert values["WEAVE_CLIENT_SIGN_IN"] == platform.local_client_sign_in() + + +@pytest.mark.parametrize("change", ["database", "jwks", "issuer"]) +def test_container_mapping_refuses_unowned_endpoints(owned, change): + from firefly_weave.sdk import platform_docker + + directory, state = owned + path = directory / "runtime.env" + value = path.read_text() + if change == "database": + value = value.replace("localhost:55101", "remote.example:55101") + elif change == "jwks": + value = value.replace("/protocol/openid-connect/certs", "/different") + else: + value = value.replace("localhost:55102", "localhost:55109") + path.write_text(value) + with pytest.raises(platform.PlatformError, match="runtime|identity|database"): + platform_docker.environment(state, {}) + + +def test_up_reuses_complete_installation_demo_and_saved_account(owned, monkeypatch): + from firefly_weave.sdk import platform_docker + + directory, state = owned + platform._write(directory / "up-user.json", {"stage": "ready", "username": "developer"}) + monkeypatch.setattr(platform, "setup", lambda *a, **k: pytest.fail("reprovisioned")) + monkeypatch.setattr(platform, "user", lambda *a, **k: pytest.fail("reset user")) + calls = [] + monkeypatch.setattr(platform_docker, "start", lambda s, n: calls.append("start")) + monkeypatch.setattr(platform, "demo", lambda d: {"existing": True, "receipt": {"status": "succeeded"}}) + result = platform.up(directory, Path("."), None, username="developer") + assert calls == ["start"] + assert result["account"] == { + "username": "developer", + "existing": True, + "roles": list(platform.DEFAULT_PERSON_ROLES), + } + assert "password" not in json.dumps(result) + + +def test_up_does_not_convert_legacy_host_or_resume_incomplete_setup(owned, monkeypatch): + directory, state = owned + monkeypatch.setattr(platform, "setup", lambda *a, **k: pytest.fail("reprovisioned")) + state.pop("mode") + platform._write(directory / "platform.json", state, replace=True) + with pytest.raises(platform.PlatformError, match="foreground|host"): + platform.up(directory, Path("."), None) + state.update(mode="docker", stage="database") + platform._write(directory / "platform.json", state, replace=True) + with pytest.raises(platform.PlatformError, match="incomplete"): + platform.up(directory, Path("."), None) + + +def test_docker_stop_orders_api_before_dependencies_without_removing_data(owned, monkeypatch): + from firefly_weave.sdk import platform_docker + + directory, state = owned + calls = [] + monkeypatch.setattr(platform_docker, "stop", lambda s: calls.append("api")) + monkeypatch.setattr(platform, "_run", lambda *a, **k: calls.append(a[2])) + assert platform.stop(directory)["data_retained"] + assert calls[0] == "api" + assert calls[1][-3:] == ["postgres", "keycloak", "keycloak-db"] + assert "down" not in calls[1] + + +def test_new_up_sets_mode_before_setup_and_creates_account_only_once(tmp_path, monkeypatch): + directory = tmp_path / "new" + state = { + "mode": "docker", + "context": "owned", + "directory": str(directory), + "stage": "ready", + "id": "c" * 24, + "ports": {"container_api": 55200}, + } + calls = [] + + def setup(*args, **kwargs): + assert kwargs["mode"] == "docker" + calls.append("setup") + directory.mkdir(mode=0o700) + + monkeypatch.setattr(platform, "setup", setup) + monkeypatch.setattr(platform, "_load", lambda p: state) + monkeypatch.setattr(platform, "start", lambda *a: calls.append("start")) + monkeypatch.setattr(platform, "demo", lambda *a: {"receipt": {"status": "succeeded"}}) + monkeypatch.setattr( + platform, "user", lambda *a: calls.append("user") or {"username": "developer", "password": "once"} + ) + result = platform.up(directory, tmp_path, "owned", username="developer") + assert result["account"]["password"] == "once" + assert "once" not in (directory / "up-user.json").read_text() + assert platform.up(directory, tmp_path, "owned", username="developer")["account"]["existing"] + assert calls == ["setup", "start", "user", "start"] + + +def test_uncertain_account_creation_blocks_replay(owned, monkeypatch): + directory, state = owned + platform._write(directory / "up-user.json", {"stage": "attempted", "username": "developer"}) + monkeypatch.setattr(platform, "start", lambda *a: None) + monkeypatch.setattr(platform, "demo", lambda *a: {"receipt": {"status": "succeeded"}}) + monkeypatch.setattr(platform, "user", lambda *a: pytest.fail("repeated unknown account operation")) + with pytest.raises(platform.PlatformError, match="incomplete"): + platform.up(directory, Path("."), None, username="developer") + + +def test_container_configuration_mounts_only_selected_values_and_literal_environment(owned, monkeypatch): + from firefly_weave.sdk import platform_docker + + directory, state = owned + state["docker_image"] = "sha256:" + "1" * 64 + store = directory / "secrets" + store.mkdir(mode=0o700) + (store / "api-key").write_bytes(b"secret$literal") + (store / "api-key").chmod(0o600) + execution = {"WEAVE_SECRET_ROOT": str(directory / "secrets"), "WEAVE_SECRET_GRANTS": "[]"} + config = json.loads(platform_docker._configuration(state, execution).read_text())["services"]["api"] + assert config["network_mode"] == "service:keycloak" and "ports" not in config + assert config["restart"] == "unless-stopped" and config["read_only"] + assert config["env_file"] == [{"path": str(directory / "api-container.env"), "format": "raw"}] + assert len(config["volumes"]) == 1 + mount = config["volumes"][0] + assert mount["read_only"] and mount["target"] == "/run/weave-secrets/api-key" + assert Path(mount["source"]).read_bytes() == b"secret$literal" + assert "secret$literal" not in json.dumps(config) + env = (directory / "api-container.env").read_text() + assert all(value not in env for value in ["owner-secret", "admin-secret", "host-secret", "must-not-leak"]) + assert (directory / "api-container.env").stat().st_mode & 0o777 == 0o600 + + +def test_tampered_dependency_override_is_not_used(owned): + from firefly_weave.sdk import platform_docker + + _, state = owned + path = platform_docker.dependencies(state) + assert all(v["restart"] == "unless-stopped" for v in json.loads(path.read_text())["services"].values()) + path.write_text('{"services":{"foreign":{"image":"bad"}}}') + with pytest.raises(platform.PlatformError, match="changed"): + platform_docker.dependencies(state) + + +@pytest.mark.parametrize("mismatch", ["image", "project", "service", "many"]) +def test_container_inventory_rejects_foreign_or_ambiguous_container(owned, monkeypatch, mismatch): + from firefly_weave.sdk import platform_docker + + _, state = owned + state["docker_image"] = "sha256:" + "1" * 64 + record = { + "Id": "a" * 64, + "Image": state["docker_image"], + "State": {"Status": "running"}, + "Config": { + "Labels": {"com.docker.compose.project": "weave-local-" + state["id"], "com.docker.compose.service": "api"} + }, + } + if mismatch == "image": + record["Image"] = "foreign" + if mismatch in {"project", "service"}: + record["Config"]["Labels"]["com.docker.compose." + mismatch] = "foreign" + + def run(s, stage, command, **kwargs): + if stage == "api-inventory": + return (("a" * 12 + "\n") * (2 if mismatch == "many" else 1)).encode() + assert stage == "api-inspect" + return json.dumps([record]).encode() + + monkeypatch.setattr(platform, "_run", run) + with pytest.raises(platform.PlatformError, match="identity|ambiguous"): + platform_docker.stop(state) + + +def test_docker_start_never_reprovisions_or_passes_owner_configuration(owned, monkeypatch): + from firefly_weave.sdk import platform_docker + + _, state = owned + state["docker_image"] = "sha256:" + "1" * 64 + monkeypatch.setattr(platform_docker, "_build", lambda *a: None) + monkeypatch.setattr(platform_docker, "inspect", lambda *a: {"state": "absent"}) + monkeypatch.setattr(platform, "_wait_identity", lambda *a: None) + monkeypatch.setattr(platform, "_repair_login_client", lambda *a: None) + monkeypatch.setattr(platform, "_execution_environment", lambda *a: {}) + monkeypatch.setattr(platform, "_probe", lambda *a: True) + calls = [] + monkeypatch.setattr(platform, "_run", lambda s, n, c, **kw: calls.append((n, c, kw)) or b"") + platform_docker.start(state, lambda m: None) + assert [x[0] for x in calls] == ["dependencies-start", "api-compose-check", "api-start"] + assert "--no-recreate" in calls[0][1] + assert all("setup-runtime.py" not in str(x) for x in calls) + assert "--detach" in calls[-1][1] and "--pull" in calls[-1][1] + assert calls[-1][1][-1] == "api" + + +def test_docker_native_execution_uses_image_attestation(owned, monkeypatch): + from uuid import uuid4 + + directory, state = owned + scope = {k: str(uuid4()) for k in ["tenant_id", "project_id", "environment_id"]} + image = "sha256:" + "1" * 64 + monkeypatch.setattr(platform, "_secret_handles", lambda d: []) + monkeypatch.setattr(platform, "_demo_receipt", lambda d: {"scope": scope}) + monkeypatch.setattr( + platform, + "_integrations", + lambda d: { + "stage": "ready", + "scope": scope, + "image_digest": image, + "principal_id": str(uuid4()), + "release_id": str(uuid4()), + "task_types": list(platform._HTTP_TASKS), + "capacity": 4, + }, + ) + monkeypatch.setattr(platform, "_runtime_build", lambda s: {"identity": image}) + env = platform._execution_environment(state, lambda m: None) + assert json.loads(env["WEAVE_NATIVE_EXECUTORS"])[0]["build"] == "image" + assert env["WEAVE_NATIVE_IMAGE_DIGEST"] == image + + +def test_secret_rotation_changes_mount_without_publishing_value_hash(owned): + from firefly_weave.sdk import platform_docker + + directory, state = owned + state["docker_image"] = "sha256:" + "1" * 64 + store = directory / "secrets" + store.mkdir(mode=0o700) + secret = store / "api-key" + secret.write_bytes(b"first") + secret.chmod(0o600) + execution = {"WEAVE_SECRET_ROOT": str(store), "WEAVE_SECRET_GRANTS": "[]"} + + def mount(): + return json.loads(platform_docker._configuration(state, execution).read_text())["services"]["api"]["volumes"][ + 0 + ]["source"] + + before = mount() + assert mount() == before + secret.write_bytes(b"second") + after = mount() + assert after != before + assert Path(before).read_bytes() == b"first" + assert Path(after).read_bytes() == b"second" + import hashlib + + assert hashlib.sha256(b"second").hexdigest() not in after + + +def test_keycloak_namespace_replacement_recreates_only_owned_api(owned, monkeypatch): + from firefly_weave.sdk import platform_docker + + _, state = owned + state["docker_image"] = "sha256:" + "1" * 64 + monkeypatch.setattr(platform_docker, "_build", lambda *a: None) + monkeypatch.setattr( + platform_docker, + "inspect", + lambda *a: {"state": "exited", "id": "a" * 64, "network_mode": "container:" + "b" * 64}, + ) + monkeypatch.setattr(platform, "_wait_identity", lambda *a: None) + monkeypatch.setattr(platform, "_repair_login_client", lambda *a: None) + monkeypatch.setattr(platform, "_execution_environment", lambda *a: {}) + monkeypatch.setattr(platform, "_probe", lambda *a: True) + calls = [] + + def run(s, n, c, **kw): + calls.append((n, c)) + return ("c" * 64).encode() if n == "identity-container" else b"" + + monkeypatch.setattr(platform, "_run", run) + platform_docker.start(state, lambda m: None) + command = next(c for n, c in calls if n == "api-start") + assert "--force-recreate" in command and "--no-deps" in command + assert command[-1] == "api" + assert "--force-recreate" not in calls[0][1] + + +def test_repeated_up_refuses_changed_roles_instead_of_claiming_new_access(owned, monkeypatch): + directory, _ = owned + platform._write(directory / "up-user.json", {"stage": "ready", "username": "developer", "roles": ["viewer"]}) + monkeypatch.setattr(platform, "start", lambda *a: None) + monkeypatch.setattr(platform, "demo", lambda *a: {"receipt": {"status": "succeeded"}}) + monkeypatch.setattr(platform, "user", lambda *a: pytest.fail("reset existing account")) + with pytest.raises(platform.PlatformError, match="roles"): + platform.up(directory, Path("."), None, username="developer", roles=["tenant_admin"]) + result = platform.up(directory, Path("."), None, username="developer") + assert result["account"]["roles"] == ["viewer"] + + +def test_status_does_not_claim_foreign_listener_as_owned_api(owned, monkeypatch): + from firefly_weave.sdk import platform_docker + + directory, _ = owned + monkeypatch.setattr(platform_docker, "inspect", lambda state: {"state": "absent"}) + monkeypatch.setattr(platform, "_probe", lambda *a: True) + result = platform.status(directory) + assert result["api_ready"] is False + + +def test_public_secret_rotation_and_removal_report_live_docker_snapshot(owned, monkeypatch): + from firefly_weave.sdk import platform_docker + + directory, state = owned + state["docker_image"] = "sha256:" + "1" * 64 + monkeypatch.setattr(platform, "_workspace", lambda *a: (state, {"tenant_id": "demo"})) + platform.secret_set(directory, "api-key", b"first") + execution = {"WEAVE_SECRET_ROOT": str(directory / "secrets"), "WEAVE_SECRET_GRANTS": "[]"} + configured = json.loads(platform_docker._configuration(state, execution).read_text()) + mounted = Path(configured["services"]["api"]["volumes"][0]["source"]) + replaced = platform.secret_set(directory, "api-key", b"second") + assert mounted.read_bytes() == b"first" + assert replaced["restart_required"] is True + assert "platform" in replaced["message"] and " start" in replaced["message"] + assert "next use" not in replaced["message"] and "Ctrl-C" not in replaced["message"] + removed = platform.secret_remove(directory, "api-key") + assert mounted.read_bytes() == b"first" + assert removed["restart_required"] is True + assert "running" in removed["message"] and " start" in removed["message"] + assert "Ctrl-C" not in removed["message"] diff --git a/tests/unit/test_macos_notarization.py b/tests/unit/test_macos_notarization.py new file mode 100644 index 00000000..1e3834fb --- /dev/null +++ b/tests/unit/test_macos_notarization.py @@ -0,0 +1,345 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Release signing must fail closed before secrets or notarization are used.""" + +import base64 +import runpy +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "desktop/scripts/notarize_macos.py" + + +def load(): + assert SCRIPT.is_file(), "The trusted macOS signing helper is not implemented" + return runpy.run_path(str(SCRIPT)) + + +def environment(): + return { + "GITHUB_EVENT_NAME": "workflow_dispatch", + "GITHUB_REPOSITORY": "fireflyframework/firefly-weave", + "GITHUB_REF": "refs/tags/v0.1.0a13", + "GITHUB_SHA": "a" * 40, + "APPLE_CERTIFICATE": base64.b64encode(b"test certificate").decode(), + "APPLE_CERTIFICATE_PASSWORD": "private-password", + "APPLE_SIGNING_IDENTITY": "Developer ID Application: Example (ABCDEFGHIJ)", + "APPLE_TEAM_ID": "ABCDEFGHIJ", + "APPLE_API_ISSUER": "00000000-0000-0000-0000-000000000001", + "APPLE_API_KEY": "0123456789", + "APPLE_API_PRIVATE_KEY": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + } + + +@pytest.mark.parametrize( + ("key", "value"), + [ + ("GITHUB_EVENT_NAME", "pull_request"), + ("GITHUB_EVENT_NAME", "pull_request_target"), + ("GITHUB_REPOSITORY", "untrusted/fork"), + ("GITHUB_REF", "refs/heads/main"), + ], +) +def test_untrusted_context_rejected_before_git_or_credentials(key, value): + module = load() + env = environment() + env[key] = value + with pytest.raises(RuntimeError, match="trusted release tag"): + module["verify_source"](ROOT, env, "0.1.0a13") + + +def test_tag_and_checked_out_commit_must_match(monkeypatch): + module = load() + responses = iter(["a" * 40, "b" * 40]) + monkeypatch.setattr(subprocess, "check_output", lambda *args, **kwargs: next(responses)) + with pytest.raises(RuntimeError, match="commit"): + module["verify_source"](ROOT, environment(), "0.1.0a13") + + +def test_partial_credentials_list_names_without_values(): + env = environment() + del env["APPLE_API_PRIVATE_KEY"] + with pytest.raises(RuntimeError) as exc: + load()["validate_credentials"](env) + assert "APPLE_API_PRIVATE_KEY" in str(exc.value) + assert "private-password" not in str(exc.value) + + +@pytest.mark.parametrize( + "identity", ["-", "Apple Development: Example (ABCDEFGHIJ)", "Developer ID Application: Example (WRONGTEAM1)"] +) +def test_wrong_certificate_kind_or_team_rejected(identity): + env = environment() + env["APPLE_SIGNING_IDENTITY"] = identity + with pytest.raises(RuntimeError, match="Developer ID Application"): + load()["validate_credentials"](env) + + +def test_complete_credentials_accepted(): + load()["validate_credentials"](environment()) + + +@pytest.mark.parametrize("change", ["authority", "team", "runtime", "timestamp"]) +def test_signed_metadata_requires_exact_identity_runtime_and_timestamp(change): + env = environment() + text = ( + f"Authority={env['APPLE_SIGNING_IDENTITY']}\nTeamIdentifier=ABCDEFGHIJ\n" + "CodeDirectory flags=0x10000(runtime)\nTimestamp=Oct 7, 2026\n" + ) + text = { + "authority": text.replace("Developer ID Application", "Apple Development"), + "team": text.replace("TeamIdentifier=ABCDEFGHIJ", "TeamIdentifier=WRONGTEAM1"), + "runtime": text.replace("(runtime)", "(adhoc)"), + "timestamp": text.replace("Timestamp=Oct 7, 2026", "Signed Time=Oct 7, 2026"), + }[change] + with pytest.raises(RuntimeError, match="signature"): + load()["verify_signature_details"](text, env["APPLE_SIGNING_IDENTITY"], env["APPLE_TEAM_ID"]) + + +def test_unsigned_or_pending_notary_response_never_accepted(): + module = load() + for status in ("Invalid", "In Progress", None): + with pytest.raises(RuntimeError, match="Accepted"): + module["accepted_submission"]({"id": "submission", "status": status}) + assert module["accepted_submission"]({"id": "submission", "status": "Accepted"}) == "submission" + + +def test_frozen_host_signing_includes_entitlements(monkeypatch, tmp_path): + module = runpy.run_path(str(ROOT / "desktop/scripts/build_sidecar.py")) + assert "signing_arguments" in module, "Frozen host signing must explicitly include entitlements" + args = module["signing_arguments"]("Developer ID Application: Example (ABCDEFGHIJ)", tmp_path) + assert args == [ + "--codesign-identity", + "Developer ID Application: Example (ABCDEFGHIJ)", + "--osx-entitlements-file", + str(tmp_path / "desktop/src-tauri/entitlements.plist"), + ] + assert module["signing_arguments"](None, tmp_path) == [] + + +def test_cleanup_runs_after_signing_failure(tmp_path, monkeypatch): + module = load() + env = environment() | {"RUNNER_TEMP": str(tmp_path)} + commands = [] + + def run(command, **kwargs): + commands.append(command) + if command[1:3] == ["list-keychains", "-d"] and "-s" not in command: + return subprocess.CompletedProcess( + command, 0, stdout='"/Users/runner/Library/Keychains/login.keychain-db"\n', stderr="" + ) + if command[1] == "import": + raise subprocess.CalledProcessError(1, command, stderr="private-password") + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + monkeypatch.setattr(subprocess, "run", run) + with pytest.raises(RuntimeError) as exc, module["signing_session"](env): + pytest.fail("Import failure must prevent signing") + assert "private-password" not in str(exc.value) + assert any(command[1] == "delete-keychain" for command in commands) + assert not list(tmp_path.glob("weave-signing-*")) + assert not (tmp_path / "weave-macos-signing-state.json").exists() + + +def test_workflow_never_exposes_credentials_to_pull_requests_or_ordinary_builds(): + import yaml + + workflow = ROOT / ".github/workflows/desktop-notarized.yml" + assert workflow.exists(), "A separately gated signing workflow is required" + data = yaml.safe_load(workflow.read_text()) + triggers = data.get("on", data.get(True)) + assert set(triggers) == {"workflow_dispatch"} + assert triggers["workflow_dispatch"]["inputs"]["notarize"]["default"] is False + job = data["jobs"]["macos"] + assert "github.repository == 'fireflyframework/firefly-weave'" in job["if"] + assert "startsWith(github.ref, 'refs/tags/v')" in job["if"] + assert "inputs.notarize == true" in job["if"] + assert job["environment"] == "macos-release-signing" + secret_steps = [step for step in job["steps"] if "secrets." in str(step)] + assert len(secret_steps) == 1 + assert secret_steps[0]["run"].endswith("notarize_macos.py --target ${{ matrix.target }}") + assert "secrets." not in (ROOT / ".github/workflows/desktop.yml").read_text() + cleanup = next(step for step in job["steps"] if "--cleanup" in step.get("run", "")) + assert cleanup["if"] == "always()" + + +def test_notary_ticket_failure_blocks_gatekeeper_and_installer_verification(tmp_path, monkeypatch): + module = load() + app = tmp_path / "Studio.app" + (app / "Contents/MacOS").mkdir(parents=True) + (app / "Contents/MacOS/host").write_bytes(b"test") + (app / "Contents/_CodeSignature").mkdir() + (app / "Contents/_CodeSignature/CodeResources").write_bytes(b"seal") + commands = [] + + def run(command, **kwargs): + commands.append(command) + if command[:3] == ["xcrun", "stapler", "validate"]: + raise subprocess.CalledProcessError(1, command) + output = "" + if command[:3] == ["codesign", "--display", "--verbose=4"]: + output = ( + "Authority=Developer ID Application: Example (ABCDEFGHIJ)\nTeamIdentifier=ABCDEFGHIJ\n" + "CodeDirectory flags=0x10000(runtime)\nTimestamp=Oct 7, 2026\n" + ) + return subprocess.CompletedProcess(command, 0, stdout=output, stderr="") + + monkeypatch.setattr(subprocess, "run", run) + with pytest.raises(RuntimeError, match="signing was not completed"): + module["verify_tickets"](app, tmp_path / "Studio.dmg", environment()["APPLE_SIGNING_IDENTITY"], "ABCDEFGHIJ") + assert not any(command[0] in {"spctl", "hdiutil"} for command in commands) + + +@pytest.mark.parametrize("interrupted", [False, True]) +def test_session_restores_search_list_and_removes_private_files(tmp_path, monkeypatch, interrupted): + module = load() + env = environment() | {"RUNNER_TEMP": str(tmp_path)} + previous = ["/Users/runner/Library/Keychains/login.keychain-db", "/Library/Keychains/System.keychain"] + commands = [] + + def run(command, **kwargs): + commands.append(command) + output = "" + if command[1] == "list-keychains" and "-s" not in command: + output = "\n".join(f'"{path}"' for path in previous) + if command[1] == "find-identity": + output = f'1) {"A" * 40} "{env["APPLE_SIGNING_IDENTITY"]}"\n 1 valid identities found' + return subprocess.CompletedProcess(command, 0, stdout=output, stderr="") + + monkeypatch.setattr(subprocess, "run", run) + + def invoke(): + with module["signing_session"](env) as (child, directory): + assert "APPLE_CERTIFICATE" not in child + assert "APPLE_CERTIFICATE_PASSWORD" not in child + assert "APPLE_API_PRIVATE_KEY" not in child + assert (directory / "AuthKey.p8").stat().st_mode & 0o777 == 0o600 + assert not (directory / "certificate.p12").exists() + if interrupted: + raise SystemExit("interrupted") + + if interrupted: + with pytest.raises(SystemExit): + invoke() + else: + invoke() + assert ["security", "list-keychains", "-d", "user", "-s", *previous] in commands + assert not any(command[1] == "default-keychain" for command in commands) + assert not list(tmp_path.iterdir()) + + +def test_cleanup_refuses_unowned_directory(tmp_path): + import json + + (tmp_path / "weave-macos-signing-state.json").write_text( + json.dumps({"directory": str(tmp_path.parent), "search_list": [], "keychain_created": True}) + ) + with pytest.raises(RuntimeError, match="outside the owned signing directory"): + load()["cleanup"](tmp_path) + + +@pytest.mark.parametrize("ticket_valid", [False, True]) +def test_artifacts_only_collected_after_ticket_gate_and_cleanup(tmp_path, monkeypatch, ticket_valid): + import json + import sys + from contextlib import contextmanager + + module = load() + function = module["build"] + scope = function.__globals__ + target = "aarch64-apple-darwin" + (tmp_path / "desktop/src-tauri").mkdir(parents=True) + (tmp_path / "desktop/src-tauri/tauri.conf.json").write_text(json.dumps({"version": "0.1.0-alpha.13"})) + (tmp_path / "pyproject.toml").write_text('[project]\nversion="0.1.0a13"\n') + bundle = tmp_path / "desktop/src-tauri/target" / target / "release/bundle" + (bundle / "macos/Firefly Weave Studio.app/Contents/MacOS").mkdir(parents=True) + (bundle / "macos/Firefly Weave Studio.app/Contents/MacOS/weave-studio-host").write_bytes(b"signed host") + (bundle / "dmg").mkdir() + (bundle / "dmg/Studio.dmg").write_bytes(b"signed and stapled installer") + (tmp_path / "desktop/work").mkdir() + (tmp_path / "desktop/work/sidecar-manifest.json").write_text(json.dumps({"sha256": "previous", "filename": "host"})) + output = tmp_path / "desktop/work/notarized-assets" + state = {"cleaned": False} + + @contextmanager + def session(env): + try: + yield env | {"APPLE_API_KEY_PATH": "private-key-file"}, tmp_path + finally: + assert not output.exists() + state["cleaned"] = True + + def tickets(*args): + if not ticket_valid: + raise RuntimeError("Ticket rejected") + + def run(command, **kwargs): + if "hash_installers.py" in str(command): + assert state["cleaned"] + (output / "SHA256SUMS").write_text("hashes") + return subprocess.CompletedProcess( + command, 0, stdout=json.dumps({"status": "Accepted", "id": "test-submission"}), stderr="" + ) + + monkeypatch.setitem(scope, "verify_source", lambda *args: None) + monkeypatch.setitem(scope, "signing_session", session) + monkeypatch.setitem(scope, "verify_tickets", tickets) + monkeypatch.setattr(subprocess, "run", run) + monkeypatch.setattr(sys, "platform", "darwin") + if ticket_valid: + function(tmp_path, target, environment()) + record = json.loads((output / f"weave-studio-{target}-build.json").read_text()) + assert record["notarized"] is True and record["unsigned"] is False + assert record["dmg_submission_id"] == "test-submission" + assert record["dmg_sha256"] == module["digest"](bundle / "dmg/Studio.dmg") + else: + with pytest.raises(RuntimeError, match="Ticket rejected"): + function(tmp_path, target, environment()) + assert not output.exists() + assert state["cleaned"] + + +def test_always_cleanup_can_retry_search_list_restoration_after_failure(tmp_path, monkeypatch): + import json + + module = load() + directory = tmp_path / "weave-signing-owned" + directory.mkdir() + (directory / "AuthKey.p8").write_text("private") + state = tmp_path / "weave-macos-signing-state.json" + state.write_text( + json.dumps({"directory": str(directory), "search_list": ["original.keychain-db"], "keychain_created": True}) + ) + attempts = [] + + def run(command, **kwargs): + attempts.append(command) + if len(attempts) == 1: + raise subprocess.CalledProcessError(1, command) + assert command[1] != "delete-keychain", "Private directory has already been removed" + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + monkeypatch.setattr(subprocess, "run", run) + with pytest.raises(RuntimeError): + module["cleanup"](tmp_path) + assert not directory.exists() + assert state.exists() + module["cleanup"](tmp_path) + assert not state.exists() diff --git a/tests/unit/test_macos_seal.py b/tests/unit/test_macos_seal.py index 5e380bf1..fde80a75 100644 --- a/tests/unit/test_macos_seal.py +++ b/tests/unit/test_macos_seal.py @@ -72,3 +72,36 @@ def run(command, **kwargs): runpy.run_path(str(script))["verify_dmg"](tmp_path / "Studio.dmg") assert calls[0][:4] == ["hdiutil", "attach", "-readonly", "-nobrowse"] assert calls[-1] == ["hdiutil", "detach", "/dev/test-owned"] + + +def test_installer_runs_additional_trust_check_on_mounted_app(tmp_path, monkeypatch): + import plistlib + + script = Path(__file__).resolve().parents[2] / "desktop/scripts/verify_macos_bundle.py" + app = tmp_path / "Studio.app" + (app / "Contents/MacOS").mkdir(parents=True) + (app / "Contents/_CodeSignature").mkdir() + (app / "Contents/_CodeSignature/CodeResources").write_bytes(b"seal") + calls = [] + + def run(command, **kwargs): + calls.append(command) + return subprocess.CompletedProcess( + command, + 0, + stdout=plistlib.dumps( + {"system-entities": [{"dev-entry": "/dev/test-owned", "mount-point": str(tmp_path)}]} + ), + ) + + monkeypatch.setattr(subprocess, "run", run) + checked = [] + + def reject_ticket(mounted): + checked.append(mounted) + raise RuntimeError("Ticket invalid") + + with pytest.raises(RuntimeError, match="Ticket invalid"): + runpy.run_path(str(script))["verify_dmg"](tmp_path / "Studio.dmg", trust_check=reject_ticket) + assert checked == [app] + assert calls[-1] == ["hdiutil", "detach", "/dev/test-owned"] diff --git a/uv.lock b/uv.lock index 6377d1e9..15aeec1a 100644 --- a/uv.lock +++ b/uv.lock @@ -706,7 +706,7 @@ wheels = [ [[package]] name = "firefly-weave" -version = "0.1.0a13" +version = "0.1.0a14" source = { editable = "." } dependencies = [ { name = "click" }, diff --git a/workers/agentic/pyproject.toml b/workers/agentic/pyproject.toml index 5587f7c3..79a70cdb 100644 --- a/workers/agentic/pyproject.toml +++ b/workers/agentic/pyproject.toml @@ -20,13 +20,13 @@ build-backend = "hatchling.build" [project] name = "weave-agentic-worker" -version = "0.1.5" +version = "0.1.6" license = "Apache-2.0" license-files = ["LICENSE", "NOTICE"] description = "Lease-bound Firefly Agentic model worker for Firefly Weave" requires-python = ">=3.13,<3.15" dependencies = [ - "firefly-weave==0.1.0a13", + "firefly-weave==0.1.0a14", "pyfly[oauth2-client] @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/download/v26.09.15/pyfly-26.9.15-py3-none-any.whl#sha256=c712b314cbaaaaec9aa7ec7f256fb8a31e6faf228e3bd52715356fd689c8943b", "fireflyframework-agentic @ https://github.com/fireflyframework/fireflyframework-agentic/releases/download/v26.09.0/fireflyframework_agentic-26.9.0-py3-none-any.whl#sha256=a6ecb82abd54c9a1d4fa71af46593b57228ed3336e41ea0f0e821724d9bf0224", "httpx>=0.28.1,<1", diff --git a/workers/agentic/uv.lock b/workers/agentic/uv.lock index 77c2fa15..40e6344e 100644 --- a/workers/agentic/uv.lock +++ b/workers/agentic/uv.lock @@ -676,7 +676,7 @@ wheels = [ [[package]] name = "firefly-weave" -version = "0.1.0a13" +version = "0.1.0a14" source = { directory = "../../" } dependencies = [ { name = "click" }, @@ -3163,7 +3163,7 @@ wheels = [ [[package]] name = "weave-agentic-worker" -version = "0.1.5" +version = "0.1.6" source = { editable = "." } dependencies = [ { name = "firefly-weave" }, diff --git a/workers/files/pyproject.toml b/workers/files/pyproject.toml index c21e44b5..9ae4c6ac 100644 --- a/workers/files/pyproject.toml +++ b/workers/files/pyproject.toml @@ -20,12 +20,12 @@ build-backend = "hatchling.build" [project] name = "weave-files-worker" -version = "0.1.5" +version = "0.1.6" license = "Apache-2.0" license-files = ["LICENSE", "NOTICE"] description = "Lease-bound FTP, SFTP and cloud-drive transfers for Firefly Weave" requires-python = ">=3.12,<3.15" -dependencies = ["firefly-weave[worker]==0.1.0a13", "httpx>=0.28.1,<1", "aioftp==0.28.0", "asyncssh==2.24.0"] +dependencies = ["firefly-weave[worker]==0.1.0a14", "httpx>=0.28.1,<1", "aioftp==0.28.0", "asyncssh==2.24.0"] [project.scripts] weave-files-worker = "weave_files_worker.main:run" diff --git a/workers/files/uv.lock b/workers/files/uv.lock index 0979abed..bd555b6a 100644 --- a/workers/files/uv.lock +++ b/workers/files/uv.lock @@ -230,7 +230,7 @@ wheels = [ [[package]] name = "firefly-weave" -version = "0.1.0a13" +version = "0.1.0a14" source = { directory = "../../" } dependencies = [ { name = "click" }, @@ -1295,7 +1295,7 @@ wheels = [ [[package]] name = "weave-files-worker" -version = "0.1.5" +version = "0.1.6" source = { editable = "." } dependencies = [ { name = "aioftp" },