27 Commits
Author SHA1 Message Date
FrameBridge release automation 3a539fb4e4 docs: align current release references with 0.3.4 2026-10-29 16:49:08 -04:00
FrameBridge release automation cdd980e1eb FrameBridge 0.3.4 corresponding-source release
Snapshot assembled only from the signed corresponding-source archive.
Source archive SHA-256: db91a270cdefc4be79056ed38a0b4a16747e1ab130592645eac40fa3809809df
2026-10-29 11:53:40 -04:00
FrameBridge b8c9b713d8 Document the published signed 0.3.3 release and canonical repository 2026-10-28 14:17:21 -04:00
Gerardo Betancourt a52625219e Set the official release homepage and maintainer contact 2026-10-28 12:34:22 -04:00
Gerardo Betancourt 7356393f53 Make source tests reproducible from a fresh checkout 2026-10-28 12:16:39 -04:00
Gerardo Betancourt 6fe1d76483 docs: describe the FrameBridge 0.3.3 network administration release 2026-10-28 12:15:02 -04:00
Gerardo Betancourt 689f79afec Keep release tests aligned with helper trust floor and single-admin guide 2026-10-28 12:14:50 -04:00
Gerardo Betancourt 81cf7b2141 Verify complete signed releases and publish through canonical Gitea 2026-10-28 12:14:09 -04:00
Gerardo Betancourt b0c3e71555 Resume active profiles safely after forward APT upgrades 2026-10-28 12:08:15 -04:00
Gerardo Betancourt b1ff87dd81 Prepare 0.3.3 and migrate recognized legacy console wrappers 2026-10-28 12:07:52 -04:00
Gerardo Betancourt c0a679d989 Align website and shared guide with three signed Linux channels 2026-10-28 12:07:03 -04:00
Gerardo Betancourt e98d0d70e6 Repair the official desktop console without replacing existing work 2026-10-28 11:56:27 -04:00
Gerardo Betancourt 7c9371b76a Format APT maintenance instructions 2026-10-28 11:54:59 -04:00
Gerardo Betancourt fb53fe2b70 Add signed APT channels for Debian, Ubuntu and Linux Mint 2026-10-28 11:52:35 -04:00
Gerardo Betancourt db7b472861 Prepare private.20261028.2 X11 text pilot 2026-10-28 09:12:58 -04:00
Gerardo Betancourt b283999f5a Fix guarded X11 text mapping without group switching 2026-10-28 09:07:05 -04:00
Gerardo Betancourt 7a27f7fd55 build: label private production pilot candidate 2026-10-27 23:55:55 -04:00
Gerardo Betancourt db5f85c922 Fix private production pilot recovery, screen transfer and MCP dispatch 2026-10-27 23:54:41 -04:00
FrameBridge contributors f6b14c5210 Verify owned TTY cleanup before reporting stop 2026-10-27 16:54:03 -04:00
FrameBridge contributors ba17412c60 Prepare version 0.3.2 from validated compatibility runtime 2026-10-27 15:46:50 -04:00
FrameBridge contributors b0340869a7 Keep HTTP MCP startup independent of ambient WebSocket packages 2026-10-27 15:30:25 -04:00
FrameBridge contributors e84362b999 Make private desktop tools opt-in and preserve host defaults 2026-10-27 15:10:11 -04:00
FrameBridge contributors 0ae1fccfba docs: add three-step and agent-assisted setup workflow 2026-10-27 15:05:05 -04:00
FrameBridge compatibility build 5682508644 Revoke scoped credential policies before package downgrade 2026-10-27 11:23:36 -04:00
FrameBridge compatibility build bf79a79342 Bound credential input reads and verify registry lock recovery 2026-10-27 11:10:28 -04:00
FrameBridge compatibility build 12a03a1f97 Protect credential transport from sudo I/O logging and report matched platforms 2026-10-27 11:06:05 -04:00
FrameBridge compatibility build a48a20c936 Add Debian, Ubuntu and Mint candidate profiles with scoped systemd 255 credentials 2026-10-27 10:49:56 -04:00
1040 changed files with 264660 additions and 926 deletions

No files matched your search

+4
View File
@@ -9,3 +9,7 @@ Work in a separate source checkout. Describe the problem, resulting behavior, af
Official branches and release artifacts are maintainer-controlled. Changes enter them through review; official packages are built from identified source revisions and signed through the release process. A pull request does not itself become an official binary. See [OFFICIAL-RELEASES.md](OFFICIAL-RELEASES.md). Official branches and release artifacts are maintainer-controlled. Changes enter them through review; official packages are built from identified source revisions and signed through the release process. A pull request does not itself become an official binary. See [OFFICIAL-RELEASES.md](OFFICIAL-RELEASES.md).
Preserve the historical RC7 installer and source evidence. New executable or payload changes require a separately identifiable build. Do not present a modified artifact as the unchanged certified RC7 release. Preserve the historical RC7 installer and source evidence. New executable or payload changes require a separately identifiable build. Do not present a modified artifact as the unchanged certified RC7 release.
## Run source tests
From a fresh clone, run `python3 tools/run_tests.py` as your normal Linux user. The runner creates the private `source/qa` fixture directory and a temporary test home, clears inherited FrameBridge profile variables, and tests the repository source. It requires the native tools used by the tests, including `tmux`, `openssl`, `dpkg` and `systemd` command-line utilities. Test fixtures are generated from tracked source; no previous build directory or installed profile is copied.
+2 -2
View File
@@ -2,9 +2,9 @@
The owner-approved first-party license is GNU GPL version 3 only (GPL-3.0-only), selected October 25, 2026. Apache-2.0 was an earlier first-party proposal and remains applicable to third-party components that use it. The complete GPL is in [LICENSE](LICENSE), with scope in [LICENSING.md](LICENSING.md). The owner-approved first-party license is GNU GPL version 3 only (GPL-3.0-only), selected October 25, 2026. Apache-2.0 was an earlier first-party proposal and remains applicable to third-party components that use it. The complete GPL is in [LICENSE](LICENSE), with scope in [LICENSING.md](LICENSING.md).
The current release target is FrameBridge 0.3.1, installer FrameBridge-0.3.1.deb. Historical RC7 and Community Preview artifacts retain their separate identities. FrameBridge 0.3.4 has separate Debian 13, Ubuntu 24.04 and Linux Mint 22.x amd64 release profiles. Signature and publication status belong to its accompanying release record. The [official download page](https://framebridge.betancourt.one/download/) and each artifact's release record identify its source and signatures. Historical 0.3.3, 0.3.1, RC7 and Community Preview artifacts retain their separate identities; their versioned filenames do not identify the current installer.
The pinned Python/Rust dependency notices and source delivery were reconciled in the retained native terms review. The 0.3.1 preparation additionally identifies the OpenAI tunnel-client as third-party Apache-2.0 material, retains its notices, and supplies exact h1-verified source archives and notices for its 47 embedded Go modules. Its own development-build source commit is not embedded and remains a provenance limit. The pinned Python/Rust dependency notices and source delivery were reconciled in the retained native terms review. The retained OpenAI tunnel-client is third-party Apache-2.0 material. Its notices and exact h1-verified source archives and notices for its 47 embedded Go modules remain included. Its own development-build source commit is not embedded and remains a provenance limit. The dependency-source assets retain their historical 0.3.1 filenames; this does not change the FrameBridge release version or assert an independent rebuild of those components.
Public delivery is complete only when the actual matching installer, FrameBridge source, dependency-source assets, supplements and notices are downloadable with verified hashes. Private collection does not establish public availability. The final release delivery record is authoritative for staged or public status; this file does not assert that the port is open or public downloads are live. Public delivery is complete only when the actual matching installer, FrameBridge source, dependency-source assets, supplements and notices are downloadable with verified hashes. Private collection does not establish public availability. The final release delivery record is authoritative for staged or public status; this file does not assert that the port is open or public downloads are live.
+2 -2
View File
@@ -6,7 +6,7 @@ The owner selected GNU General Public License version 3 only, SPDX identifier GP
The grant covers original FrameBridge code and project documentation that the rightsholders are entitled to license. Recipients may use, study, modify and redistribute that material under the GPL. Modified private copies and lawful independent distributions do not require submitting a pull request to this project. Original blog article text explicitly marked for that purpose uses CC-BY-SA-4.0 under [blog/LICENSING.md](blog/LICENSING.md); media and separately licensed components retain their own terms. The grant covers original FrameBridge code and project documentation that the rightsholders are entitled to license. Recipients may use, study, modify and redistribute that material under the GPL. Modified private copies and lawful independent distributions do not require submitting a pull request to this project. Original blog article text explicitly marked for that purpose uses CC-BY-SA-4.0 under [blog/LICENSING.md](blog/LICENSING.md); media and separately licensed components retain their own terms.
[licensing/SOURCE-LICENSE-SCOPE.json](licensing/SOURCE-LICENSE-SCOPE.json) records classification and provenance. File location is not proof of ownership. Preserve [COPYRIGHT.md](COPYRIGHT.md) and applicable notices. [licensing/SOURCE-LICENSE-SCOPE.json](licensing/SOURCE-LICENSE-SCOPE.json) records historical classification and provenance; the matching release SOURCE-ARCHIVE-MANIFEST.json identifies the complete current source set. File location is not proof of ownership. Preserve [COPYRIGHT.md](COPYRIGHT.md) and applicable notices.
## Third-party material ## Third-party material
@@ -18,7 +18,7 @@ The tunnel executable identifies its own module as a development build without a
## Source delivery ## Source delivery
Distribute the matching FrameBridge 0.3.1 source archive beside its installer, the immutable shared Python/Rust dependency-source asset, the new tunnel-client dependency-source supplement and retained notices. [Source availability](licensing/third-party/SOURCE-AVAILABILITY.md) identifies the files and hashes. Distribute the matching FrameBridge 0.3.4 source archive beside each of its three distribution-profile installers, the immutable shared Python/Rust dependency-source asset, the new tunnel-client dependency-source supplement and retained notices. [Source availability](licensing/third-party/SOURCE-AVAILABILITY.md) identifies the files and hashes.
The unchanged Certifi source arrangement retains its exact upstream source assets, original notice, full MPL-2.0 text and recipient access instructions. The historical Mozilla certdata revision remains unidentified. No independent native-wheel or tunnel-client rebuild is claimed. These are provenance and reproducibility limits, not claims that unavailable proof has been obtained. The unchanged Certifi source arrangement retains its exact upstream source assets, original notice, full MPL-2.0 text and recipient access instructions. The historical Mozilla certdata revision remains unidentified. No independent native-wheel or tunnel-client rebuild is claimed. These are provenance and reproducibility limits, not claims that unavailable proof has been obtained.
+1 -1
View File
@@ -4,7 +4,7 @@ FrameBridge is free software under the applicable licenses. Everyone retains the
## What official means ## What official means
An official FrameBridge release is built from an identified source revision accepted by the project maintainers. It has a version, source archive, installer, checksums and release signatures. The Debian APT repository publishes signed repository metadata. Official distribution starts at https://framebridge.betancourt.one/. An official FrameBridge release is built from an identified source revision accepted by the project maintainers. It has a version, source archive, installer, checksums and release signatures. The Debian APT repository publishes signed repository metadata. Official distribution starts at https://framebridge.betancourt.one/. The existing GERANAS Gitea repository is https://framebridge.betancourt.one/git/gerardo/framebridge. Each release record identifies the exact source revision, corresponding-source archive, three distribution package profiles, checksums and signatures. The signed APT repository uses independent trixie, noble and mint22 suites for the tested Debian 13, Ubuntu 24.04 and Linux Mint 22.x amd64 profiles. Release artifact signatures identify official downloads; individually signed Git tags are not required by this policy.
Maintainers control write access, reviewed branches, release tags and signing operations. Public visitors and agents do not receive release-signing or direct protected-branch write access. External contributors propose changes through review. Maintainers control write access, reviewed branches, release tags and signing operations. Public visitors and agents do not receive release-signing or direct protected-branch write access. External contributors propose changes through review.
+30 -30
View File
@@ -1,37 +1,37 @@
# FrameBridge 0.3.1 source archive # FrameBridge 0.3.4 corresponding source
This archive preserves the repository layout needed by the offline builder. This archive supplies the project source and the reviewed cp313 and cp312 vendor
From this directory, run python3 source/packaging/build_expanded.py to build inputs for Debian 13, Ubuntu 24.04 and Linux Mint 22.x on amd64. Keep its matching
FrameBridge-0.3.1.deb. The builder writes source/build and source/release; SOURCE-ARCHIVE-MANIFEST.json sidecar beside the archive. Signatures, publication
it does not install or start FrameBridge. Debian's dpkg-deb and the documented and actual OS lifecycle acceptance are recorded separately; assembly alone does
build prerequisites must already be available. not certify them. Historical signed assets retain their immutable identities.
Original FrameBridge material is GNU GPL version 3 only. Read LICENSE, The offline builder needs Python 3, dpkg/dpkg-deb and the documented host build
LICENSING.md, COPYRIGHT.md, LICENSE-STATUS.md and OFFICIAL-RELEASES.md for prerequisites. It never fetches dependencies, installs packages or starts services.
scope, attribution, third-party terms and the official-release trust model. Extract the archive beside its matching sidecar and run from the extracted root:
The project source asset is accompanied by these separate dependency assets: python3 source/packaging/build_expanded.py --target debian13-amd64-py313 --public-release --source-manifest ../SOURCE-ARCHIVE-MANIFEST.json
python3 source/packaging/build_expanded.py --target ubuntu24.04-amd64-py312 --vendor-dir source/target-vendors/cp312 --public-release --source-manifest ../SOURCE-ARCHIVE-MANIFEST.json
python3 source/packaging/build_expanded.py --target mint22-amd64-py312 --vendor-dir source/target-vendors/cp312 --public-release --source-manifest ../SOURCE-ARCHIVE-MANIFEST.json
Public-release mode verifies the archive hash, every current project source file
and the selected reviewed vendor identity before assembling a package. It does
not assert an independent rebuild of third-party native wheels or tunnel-client.
--check-vendor validates inputs without staging a package.
Original FrameBridge material is GNU GPL version 3 only. Third-party material
retains its applicable terms. Read LICENSE, LICENSING.md, COPYRIGHT.md and the
source-delivery records. Distribute these separate, unchanged dependency assets
with the release; their historical names do not identify the installer version:
- FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz - FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz
82,606,998 bytes Bytes: 82606998
SHA-256 64540a85bd65eac3f394d8c0a967cacbc00e112676d57d644dcd1bb691c1926a SHA-256: 64540a85bd65eac3f394d8c0a967cacbc00e112676d57d644dcd1bb691c1926a
- FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz - FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz
26,941,745 bytes Bytes: 26941745
SHA-256 6445d4f0e773aba833fe5c610fbae54102b92de2ce636438f769f21e890c2bd9 SHA-256: 6445d4f0e773aba833fe5c610fbae54102b92de2ce636438f769f21e890c2bd9
The Preview1 filename deliberately preserves the identity of an immutable The exact own-source revision of the retained tunnel executable is not asserted.
shared asset. Retain licensing/third-party/R2-SUPPLEMENTS.json and its referenced Retain the R2 source-notice supplements, both dependency indices and original
source notices with the dependency sources. The source indices and notices. Private profiles, credentials, QA logs, build outputs, Git history and
SOURCE-AVAILABILITY.md there record scope and provenance. Do not substitute unpublished blog drafts are excluded from the archive allowlist.
unrelated archives. The retained tunnel executable is not claimed to have been
independently rebuilt from an identified own-source revision.
The blog directory contains licensing policies and the post template.
Editorial article drafts are omitted because they are not source build inputs.
Only source/evidence/BASELINE.json is included from technical evidence. Runtime
profiles, private QA logs, generated build/release files and Git history are
outside the source archive allowlist.
A completed archive or builder run is not a declaration of public readiness.
Release checksums, signatures and publication status are recorded separately.
+79 -58
View File
@@ -1,101 +1,122 @@
# FrameBridge # FrameBridge
**Bring your ChatGPT conversations to Linux computers you control.** **Administer your Linux network from a connected conversation.**
FrameBridge connects a hosted ChatGPT conversation to tools running on your own computers. Run a task, return to a persistent Bash terminal, inspect the real desktop, and select an enrolled node. Use a central gateway and enrolled nodes to administer Linux computers you control. Run commands and return to persistent Bash terminals; an existing X11 desktop adds optional graphical tools. ChatGPT remains hosted; FrameBridge runs the tools on your Linux computers.
**Community Release 0.3.1** · **Debian 13 amd64** · **Python 3.13** > **FrameBridge 0.3.4.** Release builds cover Debian 13, Ubuntu 24.04 and Linux Mint 22.x on amd64. Choose the exact distribution profile on the [official download page](https://framebridge.betancourt.one/download/). Verify the matching release record, package signatures and signed APT metadata before installation.
The installer and control console are text interfaces in a Linux TTY or desktop terminal. FrameBridge detects a usable graphical X11 session automatically and adds graphical CUA when available. Text mode provides FBEXEC, persistent terminals and CUA-TTY. Adaptive mode does not silently create a virtual desktop. This release adds `fb health` / `fb --json health`, confirmed destination identity in Fleet results, native persistent startup improvements and reduced disk writes while idle. The source archive, release packages and external acceptance record identify the exact released code. See the [0.3.4 release notes](docs/RELEASE-NOTES-0.3.4.md).
ChatGPT remains hosted. The tools execute on your own machines. Your own ChatGPT account and manual browser approval are required; a local account label does not authenticate you. No phone pairing is required. ## Start here
## Install and connect Start on the Linux computer you want to connect, with a person at its terminal or an assistant already given shell access there. Your ChatGPT account and manual browser approval complete the connection. No phone pairing is required.
Verify the release checksums and signatures using the independently trusted project key before installing. See [Official releases](OFFICIAL-RELEASES.md) for the trust model and the project site for the signed release record. | Step | What you do | What to check |
| --- | --- | --- |
| **1. Install** | Choose the package matching your distribution and Python version, verify its release record, then install it with APT. | The package manager resolves dependencies for that computer. |
| **2. Connect** | Run `fb connect` in your normal terminal and enter the requested connection details there. Complete browser sign-in and approval yourself. | The command reports the real connection result and returns to the prompt. |
| **3. Verify** | Run `fb status`, `fb doctor`, then `fb console`. In ChatGPT, ask the connected FrameBridge help tool for a small terminal task. | Confirm the selected machine and the result of the task. |
On Debian 13 amd64, with the downloaded installer in the current directory: ### 1. Install the matching package
The release has three native Python builds. Choose the package matching the distribution and system Python.
| Distribution | Architecture | System Python | Release package profile |
| --- | --- | --- | --- |
| Debian 13 | amd64 / x86_64 | 3.13 | `debian13-amd64-py313` |
| Ubuntu 24.04 | amd64 / x86_64 | 3.12 | `ubuntu24.04-amd64-py312` |
| Linux Mint 22.x | amd64 / x86_64 | 3.12 | `mint22-amd64-py312` |
Version-specific signed APT update and managed-service lifecycle results belong to the accompanying release acceptance record. Ubuntu 26.04, LMDE 7, other distributions, older releases and other CPU architectures are outside the validated scope of this release. Earlier compatibility experiments are recorded in the [compatibility history](docs/LINUX-COMPATIBILITY-CANDIDATE.md).
Verify the signing-key fingerprint, package checksum and detached signature using the instructions on the [official download page](https://framebridge.betancourt.one/download/). Historical release records remain in [Official releases](OFFICIAL-RELEASES.md). A checksum identifies a file; the release signature authenticates it with the independently trusted project key.
For example, with the **Mint** 0.3.4 package downloaded into the current directory:
~~~bash ~~~bash
sudo apt install ./FrameBridge-0.3.1.deb sudo apt install ./FrameBridge-0.3.4-mint22-amd64-py312.deb
fb connect
fb status
~~~ ~~~
Normal first connection needs no separate setup or start command. The terminal asks for an optional account label, a hidden tunnel ID and a hidden MCP server access key. This access key is not an OpenAI API key. Complete any browser sign-in and approval separately. Use the corresponding profile filename on Debian or Ubuntu. Review APT's proposed changes for the actual computer. Installation places the software on disk; it does not establish a ChatGPT connection or replace the system Python.
Normal connection prepares managed Linux startup, stores the saved credential encrypted on this machine, reports progress and returns to the prompt. It does not force a reboot. Repeating the command resumes valid saved settings; explicit replacement is available through fb connect --replace. ### 2. Connect from your terminal
## One command interface for people and agents ~~~bash
fb connect
~~~
The executable names fb and framebridge run the same program. First connection needs no separate setup or start command. Have your tunnel ID and MCP server access key ready from the manual connection setup described in `fb help`. Enter them in the hidden local prompts and complete browser approval yourself. The key is not an OpenAI API key; the optional account label does not authenticate you.
| Command | Purpose | The managed connection saves an encrypted credential locally, prepares startup, reports progress and returns to the prompt without requiring a reboot. Repeating `fb connect` resumes valid saved settings. Read the guide before replacing them with `fb connect --replace`.
| --- | --- |
| fb connect | Establish or resume the managed ChatGPT connection. |
| fb disconnect | Pause that connection until the next explicit connect, including after restart. Local tools and saved material remain. |
| fb status | Observe actual tools, connection and boot policy together. |
| fb status connection | Observe just the connection, including its intended state. |
| fb doctor | Diagnose prerequisites and problems; it does not repair or connect. |
| fb console | Open or reattach the text Bash/FBTERM work view. |
| fb help | Read the versioned English guide. |
| fb setup | Optional advanced role/profile configuration. |
Read fb help commands for specialized service, boot, node, framebuffer-access and terminal-session controls. Legacy spellings are described separately in fb help compatibility. ### 3. Verify and use it
For an agent or script: ~~~bash
fb status
fb doctor
fb console
~~~
Start with a harmless terminal task, such as reporting the hostname and current directory. Status distinguishes local tools, connection and startup policy. Doctor diagnoses prerequisites; it does not repair or connect.
The console works in a Linux TTY or desktop terminal. A local X11 session adds graphical CUA. Terminal text, real screen captures and explicitly selected virtual displays are identified separately. A VM test does not prove the owner's physical desktop or Wayland.
### Optional virtual desktop
Text mode and an existing X11 desktop work without a virtual desktop. For an explicitly selected virtual display, install its optional tools yourself:
~~~bash
sudo apt install xvfb openbox xauth
~~~
Openbox can register itself as the generic window manager when installed. Review that desktop choice when opting in. Normal FrameBridge installation does not request Xvfb or Openbox; `xauth` remains a shared X11 dependency. `fb doctor` reports virtual-display availability separately. Read `fb help commands` for the explicit virtual mode.
## Use it with an assistant
Give a coding assistant that already has shell access the [FrameBridge agent-start workflow](docs/FRAMEBRIDGE-AGENT-START.md). The same CLI is available for people and scripts:
~~~bash ~~~bash
fb --json status fb --json status
fb help commands --json fb help commands --json
~~~ ~~~
Explicit JSON output is versioned and does not request interactive input. When owner authentication is needed, it reports INTERACTION_REQUIRED with an action for a normal terminal. The MCP server also invites an agent to read its registered help tool. This supplies usage context; client compliance must not be assumed from the invitation alone. JSON output is versioned and never prompts. `INTERACTION_REQUIRED` gives an action for the owner's normal terminal. The agent workflow helps an existing shell-based assistant install and diagnose FrameBridge; it does not claim every AI client is a tested replacement for ChatGPT.
## Services, terminals and desktops For remote work, identify the receiver and execution target separately. `LOCAL` means the current FrameBridge receiver, not a fixed ASUS destination or the computer displaying the conversation. Read the enrolled-node inventory and use a confirmed alias or ID when selecting another computer. Legacy connectors can have different defaults; verify routing before replacing one with another.
Service execution, connection intent, boot policy and saved material are separate: `fb` and `framebridge` are names for the same program. Read `fb help` for the English guide and `fb help compatibility` for legacy commands.
- fb service start and fb service stop control the current stack. The first start of a configured node can also prepare its managed startup; that initial provisioning is reported explicitly. ## Pause, stop or remove
- fb service boot enable and fb service boot disable control future startup without stopping current work or forgetting a saved credential.
- fb service credentials forget --confirm is a separate destructive operation: it removes the managed connection setup and saved material while retaining the profile and user persistence.
Persistent Bash sessions retain shell state between calls while the session and service remain alive. They do not preserve running processes across an operating-system reboot. Changing a session's sudo mode recreates Bash and loses its internal shell state; consult the guide before doing so. | Intended result | Command | What remains |
| --- | --- | --- |
| Pause the ChatGPT connection until the next explicit connect, including after restart | `fb disconnect` | Local tools and saved material. |
| Stop the current managed stack | `fb service stop` | Future startup policy and saved material. |
| Disable future managed startup | `fb service boot disable` | Current execution and saved material. |
| Remove the package without removing its dependencies automatically | `sudo dpkg --remove framebridge-original-expanded` | User-profile data can remain. |
CLI terminal observation is text. Real CUA captures are separate, authentic screen evidence. Private virtual displays, when explicitly selected, are identified as virtual. Removal stops package-owned managed processes and revokes administrative helper grants. Reinstalling requires an interactive `fb connect` to reprovision what is needed. Removal/purge may retain profiles; forgetting saved connection material is a separate action in `fb help commands`.
Independent FBEXEC tasks have been observed to work on one computer when their targets and changes do not overlap. This is not an isolation guarantee. Use one controller at a time for a shared graphical desktop. These controls undo specific operations, not a whole computer snapshot. Bash state persists between calls while the session lives, not across reboots. Changing its sudo mode recreates Bash and loses its shell state.
## Source, licenses and official releases ## Source, licensing and contributions
Original FrameBridge material is licensed under **GNU GPL version 3 only (GPL-3.0-only)**. Third-party material retains its own notices and license terms. Read [LICENSING.md](LICENSING.md), [COPYRIGHT.md](COPYRIGHT.md) and [LICENSE-STATUS.md](LICENSE-STATUS.md). Original FrameBridge material is **GNU GPL version 3 only (`GPL-3.0-only`)**. Third-party material keeps its own licenses and notices. Read [LICENSING.md](LICENSING.md), [COPYRIGHT.md](COPYRIGHT.md) and [LICENSE-STATUS.md](LICENSE-STATUS.md).
The distribution source set contains: Versioned source archives include the FrameBridge implementation, builders, tests and legal records. The shared Python/Rust dependency source asset and the tunnel client's dependency sources retain their reviewed historical 0.3.1 filenames. Exact sizes, hashes, scope and upstream provenance are recorded in `licensing/third-party`. The retained tunnel executable is not presented as independently rebuilt from an identified upstream source revision. Building FrameBridge does not claim an independent rebuild of every embedded native component.
- FrameBridge-0.3.1-source.tar.gz — FrameBridge implementation, builder, tests and legal records. Use `python3 source/packaging/build_expanded.py --help` for package build profiles and `python3 source/packaging/archive_sources.py --help` for source archiving. Building does not install, start, sign or publish the product.
- FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz — the immutable shared Python/Rust dependency source asset; its historical name is retained deliberately.
- FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz — retained source archives and notices for the tunnel executable's Go dependencies.
Exact sizes, hashes, scope and upstream provenance are recorded in licensing/third-party. The retained tunnel executable is not presented as independently rebuilt from an identified upstream source revision. Read [CONTRIBUTING.md](CONTRIBUTING.md) to propose a change and [SECURITY.md](SECURITY.md) for security reports. Use [project issues](https://framebridge.betancourt.one/git/gerardo/framebridge/issues) for non-sensitive bug reports and installation questions. Useful compatibility reports name the exact distribution, version, Python version, package profile and tests actually performed. Independent copies retain their applicable software freedoms; they must not be presented as maintainer-approved releases.
~~~bash The [claims and evidence boundaries](docs/CLAIMS-AND-LIMITS.md) distinguish package installation, connection and operation results. The [operations roadmap](docs/OPERATIONS-ROADMAP.md) describes proposed follow-up work and acceptance checks; it is not a list of features already delivered.
python3 source/packaging/build_expanded.py
python3 source/packaging/archive_sources.py
~~~
These commands assemble the Debian package and project source archive; they do not install or start FrameBridge. Build prerequisites must already be available. Rebuilding the project package does not claim an independent rebuild of every embedded native component.
Release signatures and publication readiness are recorded separately from the builder. A successful build alone is not proof that a release is signed or publicly available.
Everyone retains the applicable freedoms to modify and redistribute lawful copies. Contributing through the project Git is welcome; it is not an additional license condition. Maintainers review what enters the official repository and sign official releases. Independent variants must not be represented as maintainer-approved releases.
## Community and credits
Project home: https://framebridge.betancourt.one/ Project home: https://framebridge.betancourt.one/
The public Git repository is `https://framebridge.betancourt.one/git/gerardo/framebridge.git`. For the exact 0.3.4 release, use the signed `v0.3.4` tag or its signed corresponding-source archive from the download page. The repositories, package downloads and source archive are published; normal owner authentication and connection setup are still required on a newly installed computer.
Programming: **ChatGPT**. Programming: **ChatGPT**.
Original concept and project coordination: **Gerardo Betancourt**. Original concept and project coordination: **Gerardo Betancourt**.
FrameBridge is an independent community project. No endorsement by OpenAI is claimed. FrameBridge is an independent community project; no OpenAI endorsement is claimed. Demonstration animations are conceptual illustrations, not recordings of tool execution. Use one controller at a time for a shared graphical desktop. Independent FBEXEC tasks observed on different work targets are not an isolation guarantee.
Read [CONTRIBUTING.md](CONTRIBUTING.md) before proposing changes and [SECURITY.md](SECURITY.md) for the security process. The English Debian guide is available through fb help and the included documentation. Demonstration animations are conceptual illustrations, not recordings of tool execution.
+18
View File
@@ -1,5 +1,23 @@
# FrameBridge release notes # FrameBridge release notes
## 0.3.4
Native persistent startup improvements, lower idle disk writes, local health reporting and confirmed Fleet destinations. The release has three amd64 distribution profiles and matching source with both Python ABIs. See [the complete 0.3.4 notes](docs/RELEASE-NOTES-0.3.4.md); signatures and actual lifecycle results are recorded with the release artifacts.
## Unreleased — private production pilot fixes
Private pilot validation remains in progress; these changes are not a public release.
- Recover from classified transient broker failures with capped backoff and fresh signed nonces. Result acknowledgement retries retain the computed result and never reexecute a command. TLS, authentication and malformed-response failures remain terminal.
- Dispatch blocking MCP operations in bounded workers, preserve synchronous internal callbacks, and protect shared initialization. Concurrent generated local job IDs are distinct.
- Negotiate larger original-PNG chunks per capture while retaining legacy receivers. Apply a cooperative transfer budget and require fresh observation after uncertain input replies; performed claims require explicit matching backend confirmation.
- Bind local real-terminal capture transport to the current node identity.
- Keep privileged-helper downgrade revocation tied to the fixed 0.3.2 compatibility floor, allowing private candidates to return to 0.3.2 without revoking grants.
Validation: 721 integrated source tests passed with zero failures, errors or skips. Frozen 0.3.2 source and signed Debian artifact remained unchanged. Focused tests cover real vendored MCP dispatch, retry/no-replay behavior, frozen screen-receiver compatibility and isolated maintainer-script semantics.
Known limits: the cooperative capture budget can be exceeded by an already-running lower transport call. A broker restart can reject an unknown in-flight result; a lost poll reply can orphan a request. These cases do not reconstruct or replay commands. Live fleet acceptance remains separate from source-test success.
## 0.3.1 — Community Release ## 0.3.1 — Community Release
Installer: FrameBridge-0.3.1.deb Installer: FrameBridge-0.3.1.deb
+451
View File
@@ -0,0 +1,451 @@
{
"omitted_vendor_abis": [
"cp314"
],
"release_profiles": [
"debian13-amd64-py313",
"ubuntu24.04-amd64-py312",
"mint22-amd64-py312"
],
"schema": 1,
"validation_status": "release_source",
"vendors": {
"cp312": {
"native_extensions": [
"_cffi_backend.cpython-312-x86_64-linux-gnu.so",
"cryptography/hazmat/bindings/_rust.abi3.so",
"pydantic_core/_pydantic_core.cpython-312-x86_64-linux-gnu.so",
"rpds/rpds.cpython-312-x86_64-linux-gnu.so"
],
"packages": [
{
"name": "annotated-types",
"version": "0.8.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "anyio",
"version": "4.15.1",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "attrs",
"version": "26.1.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "certifi",
"version": "2026.7.22",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "cffi",
"version": "2.1.1",
"wheel_tags": [
"cp312-cp312-manylinux_2_17_x86_64",
"cp312-cp312-manylinux2014_x86_64"
]
},
{
"name": "click",
"version": "8.5.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "cryptography",
"version": "50.0.1",
"wheel_tags": [
"cp311-abi3-manylinux_2_34_x86_64"
]
},
{
"name": "h11",
"version": "0.16.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "httpcore",
"version": "1.0.9",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "httpx",
"version": "0.28.1",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "httpx-sse",
"version": "0.4.3",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "idna",
"version": "3.20",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "jsonschema",
"version": "4.26.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "jsonschema-specifications",
"version": "2025.9.1",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "mcp",
"version": "1.26.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "pycparser",
"version": "3.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "pydantic",
"version": "2.13.5",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "pydantic-core",
"version": "2.46.5",
"wheel_tags": [
"cp312-cp312-manylinux_2_17_x86_64",
"cp312-cp312-manylinux2014_x86_64"
]
},
{
"name": "pydantic-settings",
"version": "2.15.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "pyjwt",
"version": "2.14.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "python-dotenv",
"version": "1.2.3",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "python-multipart",
"version": "0.0.32",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "referencing",
"version": "0.37.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "rpds-py",
"version": "2026.6.3",
"wheel_tags": [
"cp312-cp312-manylinux_2_17_x86_64",
"cp312-cp312-manylinux2014_x86_64"
]
},
{
"name": "sse-starlette",
"version": "3.4.11",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "starlette",
"version": "1.7.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "typing-extensions",
"version": "4.16.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "typing-inspection",
"version": "0.4.4",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "uvicorn",
"version": "0.53.0",
"wheel_tags": [
"py3-none-any"
]
}
],
"path": "source/target-vendors/cp312",
"vendor_sha256": "f4ea1dfb4f71b11eb1a9963bfe55dc6f94e6b10e927f77a7bd9b62433a29ddf1"
},
"cp313": {
"native_extensions": [
"_cffi_backend.cpython-313-x86_64-linux-gnu.so",
"cryptography/hazmat/bindings/_rust.abi3.so",
"pydantic_core/_pydantic_core.cpython-313-x86_64-linux-gnu.so",
"rpds/rpds.cpython-313-x86_64-linux-gnu.so"
],
"packages": [
{
"name": "annotated-types",
"version": "0.8.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "anyio",
"version": "4.15.1",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "attrs",
"version": "26.1.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "certifi",
"version": "2026.7.22",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "cffi",
"version": "2.1.1",
"wheel_tags": [
"cp313-cp313-manylinux_2_17_x86_64",
"cp313-cp313-manylinux2014_x86_64"
]
},
{
"name": "click",
"version": "8.5.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "cryptography",
"version": "50.0.1",
"wheel_tags": [
"cp311-abi3-manylinux_2_34_x86_64"
]
},
{
"name": "h11",
"version": "0.16.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "httpcore",
"version": "1.0.9",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "httpx",
"version": "0.28.1",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "httpx-sse",
"version": "0.4.3",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "idna",
"version": "3.20",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "jsonschema",
"version": "4.26.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "jsonschema-specifications",
"version": "2025.9.1",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "mcp",
"version": "1.26.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "pycparser",
"version": "3.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "pydantic",
"version": "2.13.5",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "pydantic-core",
"version": "2.46.5",
"wheel_tags": [
"cp313-cp313-manylinux_2_17_x86_64",
"cp313-cp313-manylinux2014_x86_64"
]
},
{
"name": "pydantic-settings",
"version": "2.15.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "pyjwt",
"version": "2.14.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "python-dotenv",
"version": "1.2.3",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "python-multipart",
"version": "0.0.32",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "referencing",
"version": "0.37.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "rpds-py",
"version": "2026.6.3",
"wheel_tags": [
"cp313-cp313-manylinux_2_17_x86_64",
"cp313-cp313-manylinux2014_x86_64"
]
},
{
"name": "sse-starlette",
"version": "3.4.11",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "starlette",
"version": "1.7.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "typing-extensions",
"version": "4.16.0",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "typing-inspection",
"version": "0.4.4",
"wheel_tags": [
"py3-none-any"
]
},
{
"name": "uvicorn",
"version": "0.53.0",
"wheel_tags": [
"py3-none-any"
]
}
],
"path": "source/sources/original-geranas/FrameBridge-Fleet-Hub/vendor",
"vendor_sha256": "80e2917f19003e95f74a6250ee08043cb90681100faf510c0e034843740c0950"
}
},
"version": "0.3.4"
}
+26 -13
View File
@@ -1,23 +1,36 @@
# Claims and evidence boundaries # Claims and evidence boundaries
The current version is `0.3.1~community.20261026.2`, a separate candidate from RC7 and Preview 1. The latest signed release is **FrameBridge 0.3.4**, with separate amd64 packages for Debian 13, Ubuntu 24.04 and Linux Mint 22.x. The [official download page](https://framebridge.betancourt.one/download/) identifies the actual artifacts, signatures and corresponding source. A later default-branch documentation change does not rewrite the signed tag or an installed package.
## Current release scope
| Statement | Evidence and boundary | | Statement | Evidence and boundary |
| --- | --- | | --- | --- |
| Preview 2 unit suite | 376 tests passed in 22.351 seconds; focused peer regression also passed. | | Distribution profiles | Debian 13 / CPython 3.13, Ubuntu 24.04 / CPython 3.12 and Mint 22.x / CPython 3.12, all amd64. Ubuntu 26.04, LMDE 7, other architectures and other distributions are outside this release's validated scope. |
| Preview 2 package and VM | Pending at documentation freeze; no new VM acceptance or artifact hash is invented here. | | Signed APT updates | Actual update and managed-service lifecycle checks passed on Debian 13, Ubuntu 24.04 and Mint 22.3 VMs. This is not acceptance of every physical installation or every future package. |
| Preview 1 | Historical record: 368 tests and a reproducible package build. | | Release identity | The signed tag and release record identify released source and bytes. A source checkout, successful build or checksum alone does not establish an official signed release. |
| RC7 | Historical record: 361 tests, reproducible package, Debian 13 VM lifecycle and real ChatGPT/console acceptance. | | Source tests | Run `python3 tools/run_tests.py` from a source checkout. A test result applies to the source revision and environment actually tested; historical test counts are not current results. |
| ChatGPT connection | Manual owner account consent; the model remains hosted while tools run on selected owner-controlled computers. | | Installed documentation | Read the guide matching the installed version. A newer repository README cannot update an older installed manual. Report contradictory release labels with the package version and file path. |
| Persistent console | Session continuity is not a promise that every process survives reboot. |
| Independent FBEXEC tasks | Repeated maintainer experience with non-overlapping work; no certified isolation or simultaneous execution of every command. | ## Operation results
| Graphical control | One controller per shared desktop is a recommendation, not an automatic lock. |
| Statement | Evidence and boundary |
| --- | --- |
| ChatGPT connection | Manual owner account consent; the model remains hosted while tools run on selected owner-controlled computers. Installing the package or assigning an account label does not prove connection. |
| Receiver and target | `LOCAL` denotes the current receiver. Another computer must be selected using a confirmed enrolled alias or ID. A legacy endpoint's default destination is not a routing guarantee for a replacement endpoint. |
| Query and task success | A successful status query or outer tool wrapper does not prove readiness or successful inner execution. Read the target, exit code and operation result, then verify the intended effect. |
| Persistent console | Bash continuity lasts while the session survives; it does not promise survival across reboot. Preserve the existing console and unrelated work unless changing them is authorized. |
| Independent FBEXEC tasks | Repeated maintainer experience with non-overlapping work; no certified isolation or guarantee that every command can run concurrently. |
| Graphical control | Requires a compatible active X11 session and observed capture/input success. Native Wayland control is not certified. One controller per shared desktop is an operating recommendation, not an automatic lock. |
| Real images and demonstrations | Use complete authentic captures. Identify virtual displays and terminal text separately. Conceptual animation is illustration, not evidence of execution. |
| sudo | Actual privileges depend on the Unix account and normal authentication; an authorization flag does not grant root. | | sudo | Actual privileges depend on the Unix account and normal authentication; an authorization flag does not grant root. |
| Real images and demo | Use complete authentic captures. A fresh Preview 2 real recording is pending; conceptual animation is illustration. | | Recovery | Pause, stop, boot policy, revocation and removal have different effects. A package downgrade can revoke administrative helper grants; retaining a previous package is not proof of automatic rollback. |
| Dependency terms/source | Scoped technical terms review and exact source availability recorded with delivery conditions; component terms remain in force. | | Dependency terms/source | Scoped technical terms review and exact source availability are recorded with delivery conditions; component terms and provenance limits remain in force. |
Earlier RC7 and Community Preview results remain historical records. They must not be substituted for the build, signature, installation or acceptance evidence of a later artifact. The [operations roadmap](OPERATIONS-ROADMAP.md) distinguishes proposed work from delivered capabilities.
## Retained control components ## Retained control components
The retained development broker defaults to its existing 127.0.0.1 peer only. Repeatable --control-peer IP_LITERAL arguments add explicit peers. Invalid entries fail before Store creation/binding; checks use the real client address, not proxy headers. Retained RPC UID, host, client/socket and administrator identity guards remain unchanged. Normal Expanded handling uses the generic terminal entrypoint; FRAMEBRIDGE_ENABLE_GERAMEG_CUA_TTY=0 is not an effective guard. The retained development broker defaults to its existing 127.0.0.1 peer only. Repeatable `--control-peer IP_LITERAL` arguments add explicit peers. Invalid entries fail before Store creation/binding; checks use the real client address, not proxy headers. Retained RPC UID, host, client/socket and administrator identity guards remain unchanged. Normal Expanded handling uses the generic terminal entrypoint; `FRAMEBRIDGE_ENABLE_GERAMEG_CUA_TTY=0` is not an effective guard.
The classic lab-target map is empty, while the compatibility route remains available for explicit integrator configuration. Do not describe retained code as entirely inactive. Further executable edits require identifiable source and candidate-specific validation. The classic lab-target map is empty, while the compatibility route remains available for explicit integrator configuration. Do not describe retained code as entirely inactive. Executable changes require an identifiable source revision and validation of the changed behavior.
+21 -8
View File
@@ -1,6 +1,6 @@
# FrameBridge — command guide # FrameBridge — command guide
Version: 0.3.1 | Guide schema: 1 Version: 0.3.4 | Guide schema: 1
This reference is generated from the shared CLI/MCP guide. Use the matching installed version. This reference is generated from the shared CLI/MCP guide. Use the matching installed version.
@@ -23,7 +23,7 @@ This reference is generated from the shared CLI/MCP guide. Use the matching inst
### One interface for people and agents ### One interface for people and agents
FrameBridge provides tools and a shared command-line interface for human operators and authorized agents. fb is the short name for framebridge; both run the same program. The tools run on your Linux computers. The ChatGPT model remains hosted. Read this guide for the installed version before operating; confirm the receiver, target and available capabilities. Use the installed CLI and tools for normal work. Edit software only when the owner explicitly requests development, using a separate source clone and reviewable changes rather than rewriting the installed program. FrameBridge provides network administration tools and a shared command-line interface for a home Linux network with one human administrator and authorized agents. Desktop access is an auxiliary tool. fb is the short name for framebridge; both run the same program. The tools run on your Linux computers. The ChatGPT model remains hosted. Read this guide for the installed version before operating; confirm the receiver, target and available capabilities. Use the installed CLI and tools for normal work. Edit software only when the owner explicitly requests development, using a separate source clone and reviewable changes rather than rewriting the installed program.
~~~sh ~~~sh
fb help fb help
@@ -33,16 +33,15 @@ fb --json status
### Install and connect ### Install and connect
On a supported Debian system, install the downloaded package with the package manager. Normal first connection does not require a separate setup, doctor or start sequence. Run connect in a normal interactive terminal. Advanced setup is optional for selecting central/node roles and public settings. Install the package built for your exact Debian 13, Ubuntu 24.04 or Linux Mint 22.x amd64 profile. These are the three release profiles; other distributions and architectures are outside this release. Keep the distribution system Python: 3.13 on Debian 13, 3.12 on Ubuntu 24.04 and Mint 22.x. Use the matching signed APT suite: trixie, noble or mint22. Normal first connection does not require a separate setup, doctor or start sequence. Run connect in a normal interactive terminal; advanced setup is optional for central/node roles and public settings.
~~~sh ~~~sh
sudo apt install ./FrameBridge-0.3.1.deb
fb connect fb connect
~~~ ~~~
### Updating and removal ### Updating and removal
Install an official update through the normal package manager. Normal package removal stops package-owned managed processes and retains the profile and saved encrypted connection material for deliberate reuse; it is not a credential-forgetting operation. Use the separately documented explicit forget action first if that is your intended outcome. Do not assume purge erases user-profile data. Install an official update through the normal package manager when that update has been published. Normal removal stops package-owned managed processes and can retain the user profile and encrypted connection material; it is not a credential-forgetting operation. On systemd 255, package removal also revokes the administrative boot-credential grant. After reinstalling, run fb connect interactively to provision that grant again; retained files alone do not restore startup. Use the explicit credentials forget action first when that is your intended outcome. Do not assume purge erases user-profile data. An explicit downgrade also revokes administrative helper grants before the older package files replace the helper. Helper-backed profiles require a new interactive connect with a compatible package before managed startup can be restored.
~~~sh ~~~sh
sudo dpkg --remove framebridge-original-expanded sudo dpkg --remove framebridge-original-expanded
@@ -80,6 +79,7 @@ fb status runtime
fb status boot fb status boot
fb status console fb status console
fb doctor fb doctor
fb health
fb setup fb setup
fb console fb console
fb help fb help
@@ -231,7 +231,7 @@ fb nodes status
### Targets ### Targets
LOCAL means the actual receiving machine. Use enrolled, confirmed aliases or IDs and inspect the selected target before work. Do not guess SSH identities or use historical lab hostnames as defaults. CLI tty --node can use the authenticated node transport. The interactive console command is local; run it at the destination or use an explicitly configured SSH destination. LOCAL means the actual receiving machine. Use enrolled, confirmed aliases or IDs and inspect the selected target before work. Fleet results include requested_target, resolved_target, node_id, hostname and transport from the confirmed descriptor. The compatibility target field can remain LOCAL; resolved_target names the actual local computer. Unknown targets never fall back to LOCAL. Do not guess SSH identities or use historical lab hostnames as defaults. CLI tty --node can use the authenticated node transport. The interactive console command is local; run it at the destination or use an explicitly configured SSH destination.
### Transport trust ### Transport trust
@@ -303,13 +303,17 @@ A CLI tty transcript describes a terminal and explicitly does not claim authenti
### Automatic graphical discovery ### Automatic graphical discovery
The installer and control console stay text interfaces in both TTY and a desktop terminal. Compatible graphical CUA is discovered automatically; availability depends on the actual display/session and permissions. A loaded module is not proof that real pixel capture or input succeeded. New profiles use adaptive auto mode: without a verified active X11 session the runtime provides FBEXEC, FBTERM and CUA-TTY in text mode, without requiring a graphical login or automatically creating a private desktop. Explicit virtual-desktop settings remain advanced options and are labelled separately from physical-screen evidence. The installer and control console stay text interfaces in both TTY and a desktop terminal. Compatible graphical CUA is discovered automatically; availability depends on the actual display/session and permissions. A loaded module is not proof that real pixel capture or input succeeded. New profiles use adaptive auto mode: without a verified active X11 session the runtime provides FBEXEC, FBTERM and CUA-TTY in text mode, without requiring a graphical login or automatically creating a private desktop. Explicit virtual-desktop settings remain advanced options and are labelled separately from physical-screen evidence. Xvfb and Openbox are optional package suggestions and are not installed for the normal connection. Only when you choose a private virtual desktop, install its tools explicitly with sudo apt install xvfb openbox xauth and rerun fb doctor. Installing Openbox can register it as the generic window-manager alternative; use your distribution settings to retain your preferred manager when needed. Physical-screen capture and input currently require the supported active X11 session. Native Wayland control is not validated; showing an XWayland window does not establish full-desktop capture or input.
~~~sh ~~~sh
fb status fb status
fb doctor fb doctor
~~~ ~~~
### Guarded X11 text compatibility
Graphical TYPE in KEYS mode uses the guarded native X11 backend on x86-64 LP64 systems with libxdo 3.20160805.1. This private candidate accepts active keyboard group 0 only, with no held or latched modifiers and only conventional Caps Lock and Num Lock locked bits. The whole string and generated key actions are checked before any input. Unsupported groups, characters, actions or keyboard states fail closed. The backend does not change the keyboard group or map and does not use the clipboard. Display session, fixed target window, geometry and keyboard context are checked while sending. If input may have started and an error follows, inspect the target and do not automatically replay the text.
### Frame-buffer read access ### Frame-buffer read access
screen-access is a narrow, explicit administrative permission for reading the real frame buffer. It does not by itself enable graphical CUA, keyboard/mouse control or arbitrary VT switching. Persistent read access can remain effective after logout or a VT switch; review the command's displayed scope and use normal local administrator authentication. screen-access is a narrow, explicit administrative permission for reading the real frame buffer. It does not by itself enable graphical CUA, keyboard/mouse control or arbitrary VT switching. Persistent read access can remain effective after logout or a VT switch; review the command's displayed scope and use normal local administrator authentication.
@@ -340,12 +344,21 @@ fb status boot
### Doctor ### Doctor
doctor diagnoses prerequisites and reports findings with suggested next steps. It is not an implicit repair, and it is not a substitute for checking actual live connection state. Inspect the diagnosis and relevant private application logs before considering configuration changes. doctor diagnoses prerequisites and reports findings with suggested next steps. It is not an implicit repair, and it is not a substitute for checking actual live connection state. Inspect the diagnosis and relevant private application logs before considering configuration changes. Eligibility checks the exact OS release, amd64 architecture, package profile and CPython ABI before starting native components. The published 0.3.3 scope is Debian 13 with cp313 and Ubuntu 24.04/Mint 22.x with cp312. LMDE 7 and Ubuntu 26.04 builder experiments remain outside that validated scope. A matching profile or a successful development build does not establish a tested installation; never repair an ABI mismatch by replacing system Python or weakening package dependencies.
~~~sh ~~~sh
fb doctor fb doctor
~~~ ~~~
### Local health summary
health combines this local profile's existing status and prerequisite observations into an allowlisted report. It identifies LOCAL as this computer and reports the profile, role, connection intent, local tools, boot policy and desktop availability. It never starts, repairs, connects, changes boot policy or creates an absent profile. It does not scan the fleet or probe terminal commands, screen capture or input. States are healthy, paused, degraded, unavailable and unconfigured. An intentional connection pause can be healthy; missing information cannot. JSON result.ok describes observation completeness; result.health.healthy describes health. Health exits 0 for a complete healthy or intentionally paused observation and 1 for degraded, unavailable or unconfigured state. The CLI version is not proof of the running version. Detailed troubleshooting remains local in status and doctor.
~~~sh
fb health
fb --json health
~~~
### Logs and progress ### Logs and progress
Normal connection presents concise progress and a final result, then returns to the prompt. Detailed diagnostics belong in protected application logs. Share only the minimum relevant redacted result; never expose credentials, private keys, browser session data or unrelated personal paths. Avoid rerunning setup, enrollment or broad lifecycle tests merely because a view is confusing. Normal connection presents concise progress and a final result, then returns to the prompt. Detailed diagnostics belong in protected application logs. Share only the minimum relevant redacted result; never expose credentials, private keys, browser session data or unrelated personal paths. Avoid rerunning setup, enrollment or broad lifecycle tests merely because a view is confusing.
+15
View File
@@ -0,0 +1,15 @@
# Desktop console upgrade
The desktop console and its authenticated tty1 view share the existing local Bash and FBTERM activity dashboard. A desktop attachment preserves its Bash process, other work windows and login. The dashboard is read-only; the Bash pane is the command prompt.
Older private installations used a user-level desktop launcher. That launcher could hide the package launcher and reject a valid console after other work windows were added. Version 0.3.3 targets the existing named `Console` window, while preserving the other windows.
During an upgrade, a running profile can migrate the exact recognized old launcher to the packaged CLI. Custom launchers and changed legacy components are preserved. The exact recognized legacy wrapper is also redirected to the packaged CLI, so desktop or panel copies that invoke it keep working. The old desktop entry and wrapper are backed up privately in the profile; the console socket/session binding is recorded there. No login or boot policy changes.
For an explicit owner repair, use the real configured profile:
```sh
fb --state-dir /absolute/path/to/profile --json console desktop migrate-legacy
```
Then open **FrameBridge Console** from the desktop. The command does not open a generic replacement terminal, recreate the Bash session or delete additional work windows.
+112
View File
@@ -0,0 +1,112 @@
# Start FrameBridge with a coding assistant
This is a setup and verification workflow for an assistant that **already has authorized shell access to the target Linux computer**. A person can run the same commands by hand. For first setup, the owner uses that computer's normal terminal and completes the ChatGPT browser approval.
The currently tested connection is the manual ChatGPT workflow. A coding assistant can help prepare it through the CLI; this document does not claim drop-in integration with every AI client.
Latest signed release: **0.3.3**, published for Debian 13, Ubuntu 24.04 and Linux Mint 22.x on amd64. Use the exact profile, signatures and source record from the [official download page](https://framebridge.betancourt.one/download/). A later source checkout can contain unreleased changes; it is not evidence that a new package has been built, signed or published. Historical compatibility-candidate checks remain separate from validation of the actual release artifacts.
## Before handing over
Give the assistant the computer you want to work on, the intended task, and the local path of the reviewed matching package. Use the [README's package matrix](../README.md#1-install-the-matching-package). If this is a pilot beside an existing installation, identify a new isolated `--state-dir` and the existing services that must remain running.
Name both the computer receiving the assistant's tool call and the intended execution target. `LOCAL` means the current FrameBridge receiver, not a fixed computer called ASUS or the computer displaying the chat. Discover enrolled nodes and use their confirmed aliases or IDs. A legacy connector can have a different default target; changing endpoints does not preserve that default automatically.
Do not put passwords, tunnel IDs, server access keys or browser codes in the prompt. The owner will enter them in the local hidden prompts. An MCP server access key is not an OpenAI API key.
## Copy this into your assistant
~~~text
Help me install and verify FrameBridge on this Linux computer.
Target computer: [the computer to which you already have authorized shell access]
Reviewed package: [absolute local path to the matching FrameBridge .deb]
Scope: [new installation, or the specifically authorized pilot/profile]
Receiver and execution target: [identify both; do not assume LOCAL means ASUS]
First establish where you are running. Inspect the distribution/version,
architecture and system Python. Match them to the package's release profile.
Read the project's README and the installed versioned help where available.
Reuse FrameBridge's existing CLI and package; do not invent a replacement
transport or rewrite the application to complete setup.
Confirm the receiver and selected target before the first remote operation.
Use an enrolled alias or ID for another computer and inspect the effective
target in the response. Do not fall back to a different computer on error.
Use my existing task authorization. Complete the authorized preparatory work
before asking for information or a decision that is actually missing.
Before package installation:
- Check the exact reviewed file's identity, version and architecture. Verify
an official release through its signature/trusted key. An unsigned candidate
must remain labelled a candidate; a checksum is not a release signature.
- Inspect the actual package-manager plan. Record package versions and relevant
desktop selections before changes. Explain an unexpected upgrade/removal or
desktop change rather than approving a different plan silently.
- For a pilot, keep the existing connection and service processes available.
Use the agreed isolated state directory; do not guess an existing profile.
Treat optional virtual-display prerequisites as optional: install them only
when a virtual desktop is part of my task.
Install the matching local .deb through APT in the owner's normal terminal.
If OS authentication is required, show the exact action and target terminal
once and let me type there. Keep that terminal available until post-install
verification and any required correction have finished. Never ask me to paste
credentials into chat, command arguments, environment variables or a log.
Do not weaken a service's restrictions or grant permanent passwordless root
as a workaround for a restricted execution channel.
Run fb connect interactively in that same normal terminal when connection is
part of the authorized scope. I will supply the hidden connection details and
complete the browser sign-in/approval. Installing a package or setting an
account label is not evidence that ChatGPT is connected.
For programmatic observation, use fb --json status and fb help commands --json.
JSON mode never prompts. If it returns INTERACTION_REQUIRED, use the stated
normal-terminal action; do not send a secret to a noninteractive tool.
Verify installed files, the selected release profile, fb status and fb doctor.
When connected, read FrameBridge's registered help tool before choosing a
small authorized terminal task. Prove a persistent Bash session by keeping
its session ID and checking a variable/working directory in later calls.
Close only the test session and test service you created.
If desktop access is in scope, first identify the actual X11 session and use
a new test window. A virtual display must be described as virtual. Terminal
text is not a screen capture, and a VM's desktop is not the owner's physical
display. Do not claim a screenshot or successful input unless it was observed.
Coordinate one controller at a time for a shared desktop. Preserve existing
console windows and their work unless changing them is part of the task.
Check actual command exit codes and operation results. An outer tool wrapper's
success flag is not proof that its inner command succeeded. Distinguish local
tool readiness, real connection state, boot policy and saved material.
Finish by comparing the relevant before/after package and desktop state,
confirming original service processes are still running where required, and
giving me a short result: exact version/profile, tests passed, what remains
unproved, and the commands to pause or remove this installation.
~~~
## The same short route without an assistant
After verifying and installing the matching `.deb`:
~~~bash
fb connect
fb status
fb doctor
fb console
~~~
Normal first connection needs no separate `fb setup` or `fb service start`. Advanced roles, enrollment, startup policy and credential management are documented in `fb help commands`.
## Keep undo specific
- `fb disconnect` pauses the connection until the next explicit connect, including after reboot; local tools and saved material remain.
- `fb service stop` stops current managed services without changing future startup policy.
- `fb service boot disable` changes future startup policy without stopping the current stack or forgetting its saved credential.
- `sudo dpkg --remove framebridge-original-expanded` removes the package without automatically removing dependencies. User profiles may remain, and administrative helper grants are revoked. An interactive reconnect can be needed after reinstalling.
Forgetting credentials is a separate destructive operation in the installed guide. Do not use it as routine cleanup or present package removal as a whole-machine restore. For a pilot, retain the original inventory and the exact profile path so the owner can distinguish the test installation from other work.
+80
View File
@@ -0,0 +1,80 @@
# Debian, Ubuntu and Linux Mint compatibility candidate
This development candidate is based on signed 0.3.1 source commit
b23819fff1500cd43bb7c44dc9d866e3fb701672.
Candidate version: 0.3.2~compat.20261027.1.
## Scope
Only Debian, Ubuntu and Linux Mint are in this candidate's support plan.
Initial architecture: amd64/x86_64.
| Profile | Native interpreter ABI |
| --- | --- |
| Debian 13 | cp313 |
| Ubuntu 24.04 | cp312 |
| Mint 22.x (Ubuntu Noble base) | cp312 |
| Ubuntu 26.04 | cp314 |
| LMDE 7 (Debian Trixie base) | cp313 |
A build profile is not installation evidence. Results must distinguish static
and unit checks, interpreter imports, disposable OS installation, and a pilot
on a real machine. Mint and LMDE are separate targets.
## Changes and boundaries
Platform preflight uses explicit profiles and an installed manifest tied to the
Python ABI and native modules. Build checks reject wrong native ABI, CPU,
dependency versions and unsupported libc requirements.
Python 3.12/3.14 vendors preserve the pinned dependency versions. Only three
CPython-specific native distributions are replaced with matching official wheels.
Record their URLs and SHA-256 digests. The abi3 cryptography build is retained
when its requirements are satisfied.
The system Python stays intact. Standalone interpreters in the isolated build
workspace are build/test tools, not payload runtimes.
Protected user-service credentials retain the systemd >=256 route. Systemd 255
support requires a separately installed, narrowly scoped helper and explicit
owner provisioning. Missing backend must fail closed. Read its documentation
and tests before installing or provisioning it.
Physical desktop capture/control remains X11 based. A functioning CLI, virtual
display or Xwayland window does not establish physical Wayland desktop support.
ARM, Alpine and other distribution families are outside this candidate scope.
## Build and evidence
From the source root, use an explicit target and ABI-matched vendor:
python3 source/packaging/build_expanded.py --target mint22-amd64-py312 --vendor-dir /absolute/path/to/vendor-cp312 --version '0.3.2~compat.20261027.1'
Use the Debian/Ubuntu/LMDE target IDs documented by the builder. Output files and
manifests are separated by target. Candidate files need their own checksums and
must not reuse the signed 0.3.1 download path or signatures.
Native wheel provenance and matching source must accompany the candidate.
Existing license/source notices refer to the same upstream versions; preserve
the new wheel identities with the new build evidence.
## Pilot on existing machines
Order: disposable Debian and Ubuntu 24.04 VMs, GERAMEG (Debian 13), then ASUS
(Mint 22.3). Exclude GERANAS from this initial pilot: it is the central router
and its 160 GB RAID contains a degraded member.
The original services observed today run outside this published package.
Publishing the 0.3.1 website did not install its .deb on these hosts. GERAMEG
also has older Alpha packages. Check package ownership, file paths, maintainer
scripts, dependency transactions, ports and state before installation.
A production pilot needs separate owner authorization after concrete results:
- Review exact candidate checksum and dependency simulation.
- Confirm a recoverable backup for every existing path it will change.
- Retain an independent management route and the original service.
- Install before provisioning a new connection or replacing an existing role.
- Explain and authorize permission, udev, sudoers or service changes.
- Prove rollback/removal in a disposable machine, including credential cleanup
and preservation of unrelated owner files.
+29
View File
@@ -0,0 +1,29 @@
# Small Linux fleet operations: development scope
This document records proposed follow-up work after the signed 0.3.3 release. It is not a feature-availability statement, a release announcement or a promise of unattended administration. The initial scope remains owner-controlled Debian 13, Ubuntu 24.04 and Mint 22.x amd64 computers administered by one responsible person.
The `0.3.4` release source implements an initial **local-only** `health` summary and additive confirmed destination identity in Fleet results. It also consolidates the installed documentation, permits secure administrator-owned ancestors of the user's service directory and reduces durable idle status writes. These changes do not complete the wider acceptance items below. See [release notes](RELEASE-NOTES-0.3.4.md).
## Acceptance order
| Priority | Intended improvement | Acceptance evidence required |
| --- | --- | --- |
| 1 | Coherent installation and diagnosis | The package, CLI, MCP guide and website agree on version and supported profiles. An administrator outside the project completes installation, connection and a first harmless query using the documentation; record time and assistance needed. |
| 2 | Unambiguous execution destination | Show receiver and effective target separately. An explicit target never falls back to another machine. Cover unknown and duplicate names and differences from legacy defaults. |
| 3 | Unified read-only health | Reuse existing status, doctor and inventory tools. Report observation time, source and unavailable/error states. Distinguish disk capacity from free space, package presence from readiness and screenshot availability from input control. |
| 4 | Five versioned procedures | Begin with general diagnosis, a failed service, DNS diagnosis, APT diagnosis and a FrameBridge update. Record prerequisites, exact procedure version, proposed change, applicable authorization, post-check and a recovery route where supported. Read-only checks must not repair implicitly. |
| 5 | Jobs and technical history | A later conversation can identify the target, operation, result and remaining work without reconstructing the entire chat. Test disconnect/reconnect behavior and duplicate submissions. Export useful records without secrets. |
| 6 | Small external pilot | Use a separate installation per customer initially. Record installation effort, incidents, completed tasks, support time and willingness to renew before extending the platform. |
## Preserve the current operating model
- Keep `fb` / `framebridge` as the shared interface for the administrator and authorized agents.
- Retain the indicative console and existing work. A health view complements it.
- Use terminal and command tools for routine administration, with graphical CUA available for tasks that need a real desktop. Describe actual session and display limits.
- Keep normal OS/browser authentication in the owner's normal interface. Reuse task authorization; do not add repetitive approvals to harmless observations.
- Retain explicit connection intent and boot policy. An update must not start a profile that was intentionally stopped.
- Treat transport as a component. Another MCP client or platform is supported only after a documented interoperability test.
## Deferred expansion
Shared multi-customer hosting, broad Windows/macOS/DSM support, native Wayland control, a new tunnel/VPN and an autonomous always-on technician are separate projects. They need their own requirements and acceptance evidence. The initial commercial experiment is a scoped installation and maintenance service, not a claim that FrameBridge already supplies a full enterprise RMM platform.
+34
View File
@@ -0,0 +1,34 @@
# Historical FrameBridge 0.3.4 development notes
This record describes the earlier private I/O candidate. The final 0.3.4 source and current scope are documented in [the release notes](RELEASE-NOTES-0.3.4.md).
Version: `0.3.4~dev.20261029.3`.
This is a development candidate following the signed 0.3.3 release. It does not replace the signed tag, public downloads or stable APT channels. Build success is not installation, reboot, independent-user or multi-customer acceptance.
This I/O candidate is based on `0.3.4~dev.20261029.1`. The separate `.2` node-initialization experiment is not included.
## Changes
- `fb health` and `fb --json health` combine existing local status and prerequisite observations. The report names the receiving computer and profile, separates query success from health and connection intent, and leaves unknown values unknown. It does not create an absent profile, start a service, repair configuration or probe terminal/desktop control.
- Health exits 0 for a complete healthy or intentionally paused observation and 1 for degraded, unavailable or unconfigured state. JSON `result.ok` describes observation completeness; `result.health.healthy` describes health. A requested pause is not healthy when the observed tunnel still runs.
- Fleet read, EXEC, screen and CUA results include `requested_target`, `resolved_target`, `node_id`, `hostname` and `transport` from the confirmed route. Existing `target` semantics remain compatible. Upstream result fields cannot replace the confirmed identity. Errors after resolution retain that identity; unresolved targets never acquire an invented local identity.
- Installed documentation is assembled from the reviewed current source, including the corrected signed 0.3.3 publication record, support scope, first-connection instructions and canonical homepage. Shared CLI/MCP/manpage references are regenerated for this development version.
- Managed connection setup accepts administrator-owned `.config` and `.config/systemd` ancestors when they are directories without group/other write permission. The final `systemd/user` directory must still belong to the connecting user. Symbolic links, other-account ownership and unsafe permissions are rejected. Setup does not change existing directory ownership or permissions.
- Managed-worker state is persisted on transitions instead of repeatedly syncing the same state while idle. Connection liveness heartbeats use the account's validated `/run/user/<uid>` runtime directory when available; profiles without it retain a bounded durable fallback. Status still requires a fresh heartbeat bound to the same worker identity. Existing permissions and credentials are unchanged. These changes do not claim that all application or system disk activity stops.
## Native startup and the GERANAS deployment gap
Normal `fb connect` already installs the managed user service, protects the connection credential, enables startup without an interactive login and records connection intent. Installation of a package without connection setup cannot establish a new authenticated tunnel. APT preserves a manual profile's existing policy rather than silently enabling it.
The affected GERANAS deployment had been left as a manually started central profile without the native managed connection setup. External recovery services did not complete that setup and did not share the CLI's lifecycle controls. This candidate fixes the service-account directory compatibility issue needed for native setup; it does not add another external startup mechanism or fabricate managed installation records.
Source tests and package builds do not prove reconnection after a reboot. Acceptance must separately record a real changed boot ID, native service startup, connection availability and execution through the intended route. No reboot acceptance is claimed by these release notes.
## Scope
The three build profiles remain Debian 13 amd64/cp313, Ubuntu 24.04 amd64/cp312 and Linux Mint 22.x amd64/cp312. The new health command observes one selected **local profile**, not the fleet. Invoke it on the intended host and pass `--state-dir` when using a nondefault profile. Runtime readiness and capability availability are not fresh proofs that a command, screenshot or input operation succeeded.
The existing indicative console, connection intent, boot policy, user profiles, privileged-helper lifecycle and authentication workflow are preserved. Normal browser and operating-system authentication stay in the owner's normal interface. No new enrollment, transport, GUI or recurring approval mechanism is introduced.
The next product acceptance items are documented in [the operations roadmap](OPERATIONS-ROADMAP.md). Versioned repair workflows, a multi-host health dashboard, customer isolation and other MCP clients remain proposed work until separately implemented and tested.
+29
View File
@@ -0,0 +1,29 @@
# FrameBridge 0.3.4
Release profiles: Debian 13 amd64/Python 3.13, Ubuntu 24.04 amd64/Python 3.12 and Linux Mint 22.x amd64/Python 3.12.
## Network administration and observation
- `fb health` and `fb --json health` summarize the selected local profile without starting, repairing or connecting it. Query completeness, health, connection intent and unknown observations remain distinct.
- Fleet command, terminal, screen and CUA results identify the confirmed receiving computer, node and transport. Result fields cannot replace the destination identity established by routing.
- Transient broker failures recover with bounded backoff and fresh signed nonces. Acknowledgement retries retain the computed result and do not reexecute commands. Authentication, TLS and malformed-response failures remain terminal.
## Persistent startup and idle writes
- Native managed startup accepts secure administrator-owned service-directory ancestors without changing their ownership or permissions. The final user-service directory remains private to its account.
- Node startup avoids redundant privileged setup when the required account settings already exist. Existing credential grants still follow the privileged cleanup lifecycle.
- The managed supervisor persists worker-state transitions instead of repeatedly syncing identical state. Connection heartbeats use the validated account runtime directory when available, with the same process identity and 15-second freshness checks. A bounded durable fallback remains for environments without a user runtime directory.
- APT upgrades preserve profiles and existing startup policy. Normal connection setup provisions the native managed service; installing a package alone cannot establish an authenticated ChatGPT connection.
## Console, compatibility and delivery
- The indicative console and persistent terminal remain available. Real-screen capture, terminal transcripts and explicitly selected virtual displays are identified separately.
- The same English command guide serves the CLI, MCP help, installed manual and tutorial. Other distributions, Wayland and CPU architectures are outside this release's validated scope.
- One corresponding-source archive includes the reviewed cp313 and cp312 vendor inputs for all three packages. Public-release assembly requires its matching manifest and checks the archive, current source and selected vendor hashes.
- Original material remains GPL-3.0-only. Preserve both immutable dependency-source assets, third-party notices and stated provenance limits. No new independent native-wheel or tunnel-client rebuild is claimed.
## Validation and release identity
Artifact hashes, signatures, signed APT metadata and actual distribution lifecycle results belong to the accompanying release record. Build assembly does not assert that those external checks passed or that a download has been published. Retain the source archive and its sidecar manifest together when rebuilding official release profiles.
Programming: ChatGPT. Original concept and project coordination: Gerardo Betancourt.
+2 -2
View File
@@ -1,4 +1,4 @@
# Third-party terms and source delivery — FrameBridge 0.3.1 # Third-party terms and source delivery — FrameBridge 0.3.4
This scoped release record combines the retained Python/Rust review with the 0.3.1 OpenAI tunnel-client notice supplement. It does not relabel incorporated components as FrameBridge-owned GPL material. This scoped release record combines the retained Python/Rust review with the 0.3.1 OpenAI tunnel-client notice supplement. It does not relabel incorporated components as FrameBridge-owned GPL material.
@@ -20,7 +20,7 @@ The executable's own module is marked (devel) and has no embedded VCS revision.
## Delivery conditions and limits ## Delivery conditions and limits
Provide FrameBridge-0.3.1.deb, its exact corresponding FrameBridge source archive, both dependency-source assets, R2 supplement and retained notices through actual working download paths. Verify file hashes at staging and public delivery. Private collection or a future URL is not public availability. Provide all three FrameBridge 0.3.4 distribution-profile packages, their exact corresponding FrameBridge-0.3.4-source.tar.gz and SOURCE-ARCHIVE-MANIFEST.json, both dependency-source assets, R2 supplement and retained notices through actual working download paths. Verify file hashes at staging and public delivery. Private collection or a future URL is not public availability.
The unchanged Certifi arrangement retains its exact source assets, original notice, full MPL-2.0 text and recipient access instructions. The original historical Mozilla certdata revision remains unidentified. No independent native-wheel rebuild is claimed. These provenance/reproducibility limits remain explicit. The unchanged Certifi arrangement retains its exact source assets, original notice, full MPL-2.0 text and recipient access instructions. The original historical Mozilla certdata revision remains unidentified. No independent native-wheel rebuild is claimed. These provenance/reproducibility limits remain explicit.
+25
View File
@@ -0,0 +1,25 @@
# Private X11 text candidate 0.3.2+private.20261028.2
This candidate is private and uninstalled. It does not change the frozen 0.3.2 source or the six installed private.20261028.1 packages. No signing, publication or public activation is part of this preparation.
The X11 KEYS backend chooses keycodes only from validated keyboard group 0 and sends direct XTEST events. It never invokes the libxdo group-switching sender, changes a keyboard map/group, or reads or writes the clipboard. The captured original source remains byte-for-byte unchanged. The existing action authorization, target-window and geometry checks still run before the project-owned primitive hook. Every permission batch repeats the display guard; the child rechecks the fixed context after permission and before events. A bounded isolated child prevents a stalled Xlib roundtrip from indefinitely blocking the native host.
Supported native context is x86-64 LP64, libxdo version 3.20160805.1, active/locked keyboard group 0, no held/latched modifiers or group latch, and conventional Caps/Num locked bits only. All characters, action table indices and generated key behaviors are checked before the first input batch. Modifier actions must be exact SetMods for their physical modifier bit; ClearLocks is accepted only if that bit is not locked. Other groups, native ABIs, remapped actions and unrepresentable text are refused before events. No legacy text-sender fallback is permitted.
An attempted input batch remains conservatively marked as possible input after a failure or timeout; automatic replay is forbidden. Cleanup attempts to release all attempted keys. A stalled or terminated X server can prevent confirmation of cleanup, so the result must remain uncertain.
## Evidence and acceptance scope
- Fix source commit: b283999f5aef615b2acee7754e438cdd01cc75bd.
- Approved helper SHA-256: d0c8a1f56a6a8e3d16a7d676bcc0a858318cbe590ea825f76bceb6a1d7045470.
- Approved native host SHA-256: 9a2f2af559793aa93c0623d307f6383494fb0b4863db2c425f73ed67ed7ae65f.
- The fix worktree passed all 800 tests; remote receipt gerameg-mcp-20261028-090452-f2ada16c, stdout SHA-256 b073bf80ad84bb1aeb6105fd8d8796106c231f20c0ce060ffc66d320d4e62e42.
- ASUS read-only preflight for a 169-character command-shaped ASCII probe passed with group 0, no held modifiers, Num Lock mask 16 and slash keycode 61; event emission was disabled. Receipt asus-mcp-20261028-085936-a8426ef7, stdout SHA-256 235d7cc42b8313fccdf49c604530c47e6b14c4505ba4f3d324d0b0e98fc1aca1.
- The root GUI controller then tested the reviewed standalone development helper in its own foreground cat receiver: 169 characters exactly matched, 169 permission batches, 5.704268 seconds, with keyboard map/group/held/Num Lock state unchanged. Production EXEC receipt 090055-db27c436d046 and receiver receipt 090127-6ceba9d8e540.
- That live result validates the development helper, not the installed MCP pipeline. This candidate still requires a private installed pilot and exact receiver comparison through the final package before any broader activation.
## Primary implementation references
- Public 2016 libxdo source and ABI: https://sources.debian.org/src/xdotool/1%3A3.20160805.1-5/
- XKB public structures: https://gitlab.freedesktop.org/xorg/proto/xorgproto/-/blob/master/include/X11/extensions/XKBstr.h
- X.Org XKB library specification, action and behavior tables: https://www.x.org/archive/X11R7.7/doc/libX11/XKB/xkblib.html
+1
View File
@@ -1,5 +1,6 @@
{ {
"schema_version": 4, "schema_version": 4,
"historical_snapshot_notice": "This classification snapshot retains its original counts, hashes and version. The matching release SOURCE-ARCHIVE-MANIFEST.json identifies the complete current source set; historical path classifications do not prove ownership.",
"project_license": "GPL-3.0-only", "project_license": "GPL-3.0-only",
"basis": "Owner decision for original FrameBridge material the rightsholders are entitled to license.", "basis": "Owner decision for original FrameBridge material the rightsholders are entitled to license.",
"scope_notice": "../LICENSING.md", "scope_notice": "../LICENSING.md",
+3 -3
View File
@@ -1,9 +1,9 @@
# Sources accompanying FrameBridge 0.3.1 # Sources accompanying FrameBridge 0.3.4
The matching release set is intended to be delivered together through the project download area. This record identifies the required assets; it does not assert that external access has already been opened. The matching release set is intended to be delivered together through the project download area. This record identifies the required assets; it does not assert that external access has already been opened.
- FrameBridge-0.3.1.deb, Debian version 0.3.1. - FrameBridge-0.3.4-debian13-amd64-py313.deb, FrameBridge-0.3.4-ubuntu24.04-amd64-py312.deb and FrameBridge-0.3.4-mint22-amd64-py312.deb; Debian package version 0.3.4.
- FrameBridge-0.3.1-source.tar.gz: the corresponding FrameBridge implementation, build scripts, tests and legal records. - FrameBridge-0.3.4-source.tar.gz and its matching SOURCE-ARCHIVE-MANIFEST.json: the corresponding FrameBridge implementation, reviewed cp313 and cp312 vendor inputs, build scripts, tests and legal records.
- FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz: immutable shared Python/Rust dependency source asset, 82,606,998 bytes, SHA-256 64540a85bd65eac3f394d8c0a967cacbc00e112676d57d644dcd1bb691c1926a. The historical filename is deliberately retained; verify bytes rather than inferring the current project version from that name. - FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz: immutable shared Python/Rust dependency source asset, 82,606,998 bytes, SHA-256 64540a85bd65eac3f394d8c0a967cacbc00e112676d57d644dcd1bb691c1926a. The historical filename is deliberately retained; verify bytes rather than inferring the current project version from that name.
- FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz: exact source module archives for the 47 Go dependencies embedded in the retained OpenAI tunnel executable, plus retained notices and provenance records. Its release hash is recorded in the adjacent release checksum manifest and TUNNEL-DEPENDENCY-SOURCES.json. - FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz: exact source module archives for the 47 Go dependencies embedded in the retained OpenAI tunnel executable, plus retained notices and provenance records. Its release hash is recorded in the adjacent release checksum manifest and TUNNEL-DEPENDENCY-SOURCES.json.
+42 -29
View File
@@ -1,19 +1,19 @@
{ {
"product": "FrameBridge", "product": "FrameBridge",
"version": "0.3.1", "version": "0.3.4",
"guide_schema": 1, "guide_schema": 1,
"topics": { "topics": {
"start": { "start": {
"ok": true, "ok": true,
"product": "FrameBridge", "product": "FrameBridge",
"version": "0.3.1", "version": "0.3.4",
"guide_schema": 1, "guide_schema": 1,
"topic": "start", "topic": "start",
"title": "Start with FrameBridge", "title": "Start with FrameBridge",
"sections": [ "sections": [
{ {
"heading": "One interface for people and agents", "heading": "One interface for people and agents",
"body": "FrameBridge provides tools and a shared command-line interface for human operators and authorized agents. fb is the short name for framebridge; both run the same program. The tools run on your Linux computers. The ChatGPT model remains hosted. Read this guide for the installed version before operating; confirm the receiver, target and available capabilities. Use the installed CLI and tools for normal work. Edit software only when the owner explicitly requests development, using a separate source clone and reviewable changes rather than rewriting the installed program.", "body": "FrameBridge provides network administration tools and a shared command-line interface for a home Linux network with one human administrator and authorized agents. Desktop access is an auxiliary tool. fb is the short name for framebridge; both run the same program. The tools run on your Linux computers. The ChatGPT model remains hosted. Read this guide for the installed version before operating; confirm the receiver, target and available capabilities. Use the installed CLI and tools for normal work. Edit software only when the owner explicitly requests development, using a separate source clone and reviewable changes rather than rewriting the installed program.",
"commands": [ "commands": [
"fb help", "fb help",
"fb status", "fb status",
@@ -22,15 +22,14 @@
}, },
{ {
"heading": "Install and connect", "heading": "Install and connect",
"body": "On a supported Debian system, install the downloaded package with the package manager. Normal first connection does not require a separate setup, doctor or start sequence. Run connect in a normal interactive terminal. Advanced setup is optional for selecting central/node roles and public settings.", "body": "Install the package built for your exact Debian 13, Ubuntu 24.04 or Linux Mint 22.x amd64 profile. These are the three release profiles; other distributions and architectures are outside this release. Keep the distribution system Python: 3.13 on Debian 13, 3.12 on Ubuntu 24.04 and Mint 22.x. Use the matching signed APT suite: trixie, noble or mint22. Normal first connection does not require a separate setup, doctor or start sequence. Run connect in a normal interactive terminal; advanced setup is optional for central/node roles and public settings.",
"commands": [ "commands": [
"sudo apt install ./FrameBridge-0.3.1.deb",
"fb connect" "fb connect"
] ]
}, },
{ {
"heading": "Updating and removal", "heading": "Updating and removal",
"body": "Install an official update through the normal package manager. Normal package removal stops package-owned managed processes and retains the profile and saved encrypted connection material for deliberate reuse; it is not a credential-forgetting operation. Use the separately documented explicit forget action first if that is your intended outcome. Do not assume purge erases user-profile data.", "body": "Install an official update through the normal package manager when that update has been published. Normal removal stops package-owned managed processes and can retain the user profile and encrypted connection material; it is not a credential-forgetting operation. On systemd 255, package removal also revokes the administrative boot-credential grant. After reinstalling, run fb connect interactively to provision that grant again; retained files alone do not restore startup. Use the explicit credentials forget action first when that is your intended outcome. Do not assume purge erases user-profile data. An explicit downgrade also revokes administrative helper grants before the older package files replace the helper. Helper-backed profiles require a new interactive connect with a compatible package before managed startup can be restored.",
"commands": [ "commands": [
"sudo dpkg --remove framebridge-original-expanded" "sudo dpkg --remove framebridge-original-expanded"
] ]
@@ -64,12 +63,12 @@
], ],
"read_only": true, "read_only": true,
"capabilities_verified": false, "capabilities_verified": false,
"guide_sha256": "ac55eed900b5d1687b784299c5dfa238c7502c8472b1135dfa2d7231352f9daa" "guide_sha256": "e2e7a61e9ba53021c155f3fc4d8ffad95ffbdb6e5ac253b171b6ce21a5dd42b4"
}, },
"commands": { "commands": {
"ok": true, "ok": true,
"product": "FrameBridge", "product": "FrameBridge",
"version": "0.3.1", "version": "0.3.4",
"guide_schema": 1, "guide_schema": 1,
"topic": "commands", "topic": "commands",
"title": "Command reference", "title": "Command reference",
@@ -86,6 +85,7 @@
"fb status boot", "fb status boot",
"fb status console", "fb status console",
"fb doctor", "fb doctor",
"fb health",
"fb setup", "fb setup",
"fb console", "fb console",
"fb help" "fb help"
@@ -136,12 +136,12 @@
], ],
"read_only": true, "read_only": true,
"capabilities_verified": false, "capabilities_verified": false,
"guide_sha256": "9bc22a59b75eba4a6a0734320f76b5216001bbe527b2575f9f5dcee2a6abfc99" "guide_sha256": "d218df5cdc9e9053f461e4a3807e5e9fa45b6194e4b45bf05b6fc2e6258d5d0d"
}, },
"connection": { "connection": {
"ok": true, "ok": true,
"product": "FrameBridge", "product": "FrameBridge",
"version": "0.3.1", "version": "0.3.4",
"guide_schema": 1, "guide_schema": 1,
"topic": "connection", "topic": "connection",
"title": "Connect and disconnect ChatGPT", "title": "Connect and disconnect ChatGPT",
@@ -190,12 +190,12 @@
], ],
"read_only": true, "read_only": true,
"capabilities_verified": false, "capabilities_verified": false,
"guide_sha256": "601d608bf403f0cf3cd531dc42013c1d53cddaf622dd73b8ec2a13aeb9c98fb8" "guide_sha256": "418bad3914a250d4c734e0d0a60fcbfcd7e52cd6c5d4dfacc9da236cc4d45e53"
}, },
"lifecycle": { "lifecycle": {
"ok": true, "ok": true,
"product": "FrameBridge", "product": "FrameBridge",
"version": "0.3.1", "version": "0.3.4",
"guide_schema": 1, "guide_schema": 1,
"topic": "lifecycle", "topic": "lifecycle",
"title": "Services, connection intent and boot policy", "title": "Services, connection intent and boot policy",
@@ -256,12 +256,12 @@
], ],
"read_only": true, "read_only": true,
"capabilities_verified": false, "capabilities_verified": false,
"guide_sha256": "e6aca4ba3fc004cc2475ea141a1f645070a5bde306387e89ec904daec2b2f317" "guide_sha256": "943322bb685d2dd37fd306bff8b6aed6c85fb54f95af95b986c3448a45dd96b7"
}, },
"nodes": { "nodes": {
"ok": true, "ok": true,
"product": "FrameBridge", "product": "FrameBridge",
"version": "0.3.1", "version": "0.3.4",
"guide_schema": 1, "guide_schema": 1,
"topic": "nodes", "topic": "nodes",
"title": "Central and node operation", "title": "Central and node operation",
@@ -285,7 +285,7 @@
}, },
{ {
"heading": "Targets", "heading": "Targets",
"body": "LOCAL means the actual receiving machine. Use enrolled, confirmed aliases or IDs and inspect the selected target before work. Do not guess SSH identities or use historical lab hostnames as defaults. CLI tty --node can use the authenticated node transport. The interactive console command is local; run it at the destination or use an explicitly configured SSH destination.", "body": "LOCAL means the actual receiving machine. Use enrolled, confirmed aliases or IDs and inspect the selected target before work. Fleet results include requested_target, resolved_target, node_id, hostname and transport from the confirmed descriptor. The compatibility target field can remain LOCAL; resolved_target names the actual local computer. Unknown targets never fall back to LOCAL. Do not guess SSH identities or use historical lab hostnames as defaults. CLI tty --node can use the authenticated node transport. The interactive console command is local; run it at the destination or use an explicitly configured SSH destination.",
"commands": [] "commands": []
}, },
{ {
@@ -310,12 +310,12 @@
], ],
"read_only": true, "read_only": true,
"capabilities_verified": false, "capabilities_verified": false,
"guide_sha256": "440ed8b07ab2d7c89d4383d91342801a36f46dba72692482d419f457bb412186" "guide_sha256": "79f6875d2d2486557092cbc5e5a4888f8661f00eddb13d93437737893eb343a5"
}, },
"terminals": { "terminals": {
"ok": true, "ok": true,
"product": "FrameBridge", "product": "FrameBridge",
"version": "0.3.1", "version": "0.3.4",
"guide_schema": 1, "guide_schema": 1,
"topic": "terminals", "topic": "terminals",
"title": "Persistent Bash terminals", "title": "Persistent Bash terminals",
@@ -376,12 +376,12 @@
], ],
"read_only": true, "read_only": true,
"capabilities_verified": false, "capabilities_verified": false,
"guide_sha256": "d58f36e8b96bf4687d99fe009e0061d0e76435ddb30a1601826c9eb941b64e9d" "guide_sha256": "41e9e1c93bb6c8cc2d84c55d33d70d71693ddf835b5d4c199810db59af932139"
}, },
"screens": { "screens": {
"ok": true, "ok": true,
"product": "FrameBridge", "product": "FrameBridge",
"version": "0.3.1", "version": "0.3.4",
"guide_schema": 1, "guide_schema": 1,
"topic": "screens", "topic": "screens",
"title": "Authentic screen observation and input", "title": "Authentic screen observation and input",
@@ -393,12 +393,17 @@
}, },
{ {
"heading": "Automatic graphical discovery", "heading": "Automatic graphical discovery",
"body": "The installer and control console stay text interfaces in both TTY and a desktop terminal. Compatible graphical CUA is discovered automatically; availability depends on the actual display/session and permissions. A loaded module is not proof that real pixel capture or input succeeded. New profiles use adaptive auto mode: without a verified active X11 session the runtime provides FBEXEC, FBTERM and CUA-TTY in text mode, without requiring a graphical login or automatically creating a private desktop. Explicit virtual-desktop settings remain advanced options and are labelled separately from physical-screen evidence.", "body": "The installer and control console stay text interfaces in both TTY and a desktop terminal. Compatible graphical CUA is discovered automatically; availability depends on the actual display/session and permissions. A loaded module is not proof that real pixel capture or input succeeded. New profiles use adaptive auto mode: without a verified active X11 session the runtime provides FBEXEC, FBTERM and CUA-TTY in text mode, without requiring a graphical login or automatically creating a private desktop. Explicit virtual-desktop settings remain advanced options and are labelled separately from physical-screen evidence. Xvfb and Openbox are optional package suggestions and are not installed for the normal connection. Only when you choose a private virtual desktop, install its tools explicitly with sudo apt install xvfb openbox xauth and rerun fb doctor. Installing Openbox can register it as the generic window-manager alternative; use your distribution settings to retain your preferred manager when needed. Physical-screen capture and input currently require the supported active X11 session. Native Wayland control is not validated; showing an XWayland window does not establish full-desktop capture or input.",
"commands": [ "commands": [
"fb status", "fb status",
"fb doctor" "fb doctor"
] ]
}, },
{
"heading": "Guarded X11 text compatibility",
"body": "Graphical TYPE in KEYS mode uses the guarded native X11 backend on x86-64 LP64 systems with libxdo 3.20160805.1. This private candidate accepts active keyboard group 0 only, with no held or latched modifiers and only conventional Caps Lock and Num Lock locked bits. The whole string and generated key actions are checked before any input. Unsupported groups, characters, actions or keyboard states fail closed. The backend does not change the keyboard group or map and does not use the clipboard. Display session, fixed target window, geometry and keyboard context are checked while sending. If input may have started and an error follows, inspect the target and do not automatically replay the text.",
"commands": []
},
{ {
"heading": "Frame-buffer read access", "heading": "Frame-buffer read access",
"body": "screen-access is a narrow, explicit administrative permission for reading the real frame buffer. It does not by itself enable graphical CUA, keyboard/mouse control or arbitrary VT switching. Persistent read access can remain effective after logout or a VT switch; review the command's displayed scope and use normal local administrator authentication.", "body": "screen-access is a narrow, explicit administrative permission for reading the real frame buffer. It does not by itself enable graphical CUA, keyboard/mouse control or arbitrary VT switching. Persistent read access can remain effective after logout or a VT switch; review the command's displayed scope and use normal local administrator authentication.",
@@ -426,12 +431,12 @@
], ],
"read_only": true, "read_only": true,
"capabilities_verified": false, "capabilities_verified": false,
"guide_sha256": "b186eb36570b870490eedf424b30eabb4c130ee2e2c72d658fe09b77549bbc59" "guide_sha256": "506c7fcba592ccc473774a809871871285a199e8a8f72b9e230eb23481fd8a85"
}, },
"diagnostics": { "diagnostics": {
"ok": true, "ok": true,
"product": "FrameBridge", "product": "FrameBridge",
"version": "0.3.1", "version": "0.3.4",
"guide_schema": 1, "guide_schema": 1,
"topic": "diagnostics", "topic": "diagnostics",
"title": "Status and diagnosis", "title": "Status and diagnosis",
@@ -448,11 +453,19 @@
}, },
{ {
"heading": "Doctor", "heading": "Doctor",
"body": "doctor diagnoses prerequisites and reports findings with suggested next steps. It is not an implicit repair, and it is not a substitute for checking actual live connection state. Inspect the diagnosis and relevant private application logs before considering configuration changes.", "body": "doctor diagnoses prerequisites and reports findings with suggested next steps. It is not an implicit repair, and it is not a substitute for checking actual live connection state. Inspect the diagnosis and relevant private application logs before considering configuration changes. Eligibility checks the exact OS release, amd64 architecture, package profile and CPython ABI before starting native components. The published 0.3.3 scope is Debian 13 with cp313 and Ubuntu 24.04/Mint 22.x with cp312. LMDE 7 and Ubuntu 26.04 builder experiments remain outside that validated scope. A matching profile or a successful development build does not establish a tested installation; never repair an ABI mismatch by replacing system Python or weakening package dependencies.",
"commands": [ "commands": [
"fb doctor" "fb doctor"
] ]
}, },
{
"heading": "Local health summary",
"body": "health combines this local profile's existing status and prerequisite observations into an allowlisted report. It identifies LOCAL as this computer and reports the profile, role, connection intent, local tools, boot policy and desktop availability. It never starts, repairs, connects, changes boot policy or creates an absent profile. It does not scan the fleet or probe terminal commands, screen capture or input. States are healthy, paused, degraded, unavailable and unconfigured. An intentional connection pause can be healthy; missing information cannot. JSON result.ok describes observation completeness; result.health.healthy describes health. Health exits 0 for a complete healthy or intentionally paused observation and 1 for degraded, unavailable or unconfigured state. The CLI version is not proof of the running version. Detailed troubleshooting remains local in status and doctor.",
"commands": [
"fb health",
"fb --json health"
]
},
{ {
"heading": "Logs and progress", "heading": "Logs and progress",
"body": "Normal connection presents concise progress and a final result, then returns to the prompt. Detailed diagnostics belong in protected application logs. Share only the minimum relevant redacted result; never expose credentials, private keys, browser session data or unrelated personal paths. Avoid rerunning setup, enrollment or broad lifecycle tests merely because a view is confusing.", "body": "Normal connection presents concise progress and a final result, then returns to the prompt. Detailed diagnostics belong in protected application logs. Share only the minimum relevant redacted result; never expose credentials, private keys, browser session data or unrelated personal paths. Avoid rerunning setup, enrollment or broad lifecycle tests merely because a view is confusing.",
@@ -476,12 +489,12 @@
], ],
"read_only": true, "read_only": true,
"capabilities_verified": false, "capabilities_verified": false,
"guide_sha256": "0e55b9cda6b528926ca2a297d8464908a300122edbc1550b841ab8c1b7673029" "guide_sha256": "6f91c71b82e945767d98f113ae1b958f2222939bf94c0657d1ada047b43d6932"
}, },
"security": { "security": {
"ok": true, "ok": true,
"product": "FrameBridge", "product": "FrameBridge",
"version": "0.3.1", "version": "0.3.4",
"guide_schema": 1, "guide_schema": 1,
"topic": "security", "topic": "security",
"title": "Authorization, distribution and safe operation", "title": "Authorization, distribution and safe operation",
@@ -516,12 +529,12 @@
], ],
"read_only": true, "read_only": true,
"capabilities_verified": false, "capabilities_verified": false,
"guide_sha256": "2be17b3d7cb261f6cb5fbb94bfd027ca34147b8a4ead902f6a53b477fb4c201c" "guide_sha256": "0b7c6e55958911306a71f0d0ea5a504df7b1447967b424b5e8a03b4b53275211"
}, },
"compatibility": { "compatibility": {
"ok": true, "ok": true,
"product": "FrameBridge", "product": "FrameBridge",
"version": "0.3.1", "version": "0.3.4",
"guide_schema": 1, "guide_schema": 1,
"topic": "compatibility", "topic": "compatibility",
"title": "Compatibility with earlier commands", "title": "Compatibility with earlier commands",
@@ -564,8 +577,8 @@
], ],
"read_only": true, "read_only": true,
"capabilities_verified": false, "capabilities_verified": false,
"guide_sha256": "a6aa6d12e37563866404fa5834bdd7e758f3782a93b59d454316a8c9359b7258" "guide_sha256": "970ed8c5f509e78adc6fd9d99fd14a3edc93fc597184e9de54723763d7807544"
} }
}, },
"guide_sha256": "504c010697017b50b26de89f793944072ca214ae92d772e8e282ad44a6645634" "guide_sha256": "aecf94c39b17d53be24aecd67089ed5cfa52e3b0499e6a615b3a4e04cfb6e319"
} }
+21 -8
View File
@@ -1,6 +1,6 @@
# FrameBridge — command guide # FrameBridge — command guide
Version: 0.3.1 | Guide schema: 1 Version: 0.3.4 | Guide schema: 1
This reference is generated from the shared CLI/MCP guide. Use the matching installed version. This reference is generated from the shared CLI/MCP guide. Use the matching installed version.
@@ -23,7 +23,7 @@ This reference is generated from the shared CLI/MCP guide. Use the matching inst
### One interface for people and agents ### One interface for people and agents
FrameBridge provides tools and a shared command-line interface for human operators and authorized agents. fb is the short name for framebridge; both run the same program. The tools run on your Linux computers. The ChatGPT model remains hosted. Read this guide for the installed version before operating; confirm the receiver, target and available capabilities. Use the installed CLI and tools for normal work. Edit software only when the owner explicitly requests development, using a separate source clone and reviewable changes rather than rewriting the installed program. FrameBridge provides network administration tools and a shared command-line interface for a home Linux network with one human administrator and authorized agents. Desktop access is an auxiliary tool. fb is the short name for framebridge; both run the same program. The tools run on your Linux computers. The ChatGPT model remains hosted. Read this guide for the installed version before operating; confirm the receiver, target and available capabilities. Use the installed CLI and tools for normal work. Edit software only when the owner explicitly requests development, using a separate source clone and reviewable changes rather than rewriting the installed program.
~~~sh ~~~sh
fb help fb help
@@ -33,16 +33,15 @@ fb --json status
### Install and connect ### Install and connect
On a supported Debian system, install the downloaded package with the package manager. Normal first connection does not require a separate setup, doctor or start sequence. Run connect in a normal interactive terminal. Advanced setup is optional for selecting central/node roles and public settings. Install the package built for your exact Debian 13, Ubuntu 24.04 or Linux Mint 22.x amd64 profile. These are the three release profiles; other distributions and architectures are outside this release. Keep the distribution system Python: 3.13 on Debian 13, 3.12 on Ubuntu 24.04 and Mint 22.x. Use the matching signed APT suite: trixie, noble or mint22. Normal first connection does not require a separate setup, doctor or start sequence. Run connect in a normal interactive terminal; advanced setup is optional for central/node roles and public settings.
~~~sh ~~~sh
sudo apt install ./FrameBridge-0.3.1.deb
fb connect fb connect
~~~ ~~~
### Updating and removal ### Updating and removal
Install an official update through the normal package manager. Normal package removal stops package-owned managed processes and retains the profile and saved encrypted connection material for deliberate reuse; it is not a credential-forgetting operation. Use the separately documented explicit forget action first if that is your intended outcome. Do not assume purge erases user-profile data. Install an official update through the normal package manager when that update has been published. Normal removal stops package-owned managed processes and can retain the user profile and encrypted connection material; it is not a credential-forgetting operation. On systemd 255, package removal also revokes the administrative boot-credential grant. After reinstalling, run fb connect interactively to provision that grant again; retained files alone do not restore startup. Use the explicit credentials forget action first when that is your intended outcome. Do not assume purge erases user-profile data. An explicit downgrade also revokes administrative helper grants before the older package files replace the helper. Helper-backed profiles require a new interactive connect with a compatible package before managed startup can be restored.
~~~sh ~~~sh
sudo dpkg --remove framebridge-original-expanded sudo dpkg --remove framebridge-original-expanded
@@ -80,6 +79,7 @@ fb status runtime
fb status boot fb status boot
fb status console fb status console
fb doctor fb doctor
fb health
fb setup fb setup
fb console fb console
fb help fb help
@@ -231,7 +231,7 @@ fb nodes status
### Targets ### Targets
LOCAL means the actual receiving machine. Use enrolled, confirmed aliases or IDs and inspect the selected target before work. Do not guess SSH identities or use historical lab hostnames as defaults. CLI tty --node can use the authenticated node transport. The interactive console command is local; run it at the destination or use an explicitly configured SSH destination. LOCAL means the actual receiving machine. Use enrolled, confirmed aliases or IDs and inspect the selected target before work. Fleet results include requested_target, resolved_target, node_id, hostname and transport from the confirmed descriptor. The compatibility target field can remain LOCAL; resolved_target names the actual local computer. Unknown targets never fall back to LOCAL. Do not guess SSH identities or use historical lab hostnames as defaults. CLI tty --node can use the authenticated node transport. The interactive console command is local; run it at the destination or use an explicitly configured SSH destination.
### Transport trust ### Transport trust
@@ -303,13 +303,17 @@ A CLI tty transcript describes a terminal and explicitly does not claim authenti
### Automatic graphical discovery ### Automatic graphical discovery
The installer and control console stay text interfaces in both TTY and a desktop terminal. Compatible graphical CUA is discovered automatically; availability depends on the actual display/session and permissions. A loaded module is not proof that real pixel capture or input succeeded. New profiles use adaptive auto mode: without a verified active X11 session the runtime provides FBEXEC, FBTERM and CUA-TTY in text mode, without requiring a graphical login or automatically creating a private desktop. Explicit virtual-desktop settings remain advanced options and are labelled separately from physical-screen evidence. The installer and control console stay text interfaces in both TTY and a desktop terminal. Compatible graphical CUA is discovered automatically; availability depends on the actual display/session and permissions. A loaded module is not proof that real pixel capture or input succeeded. New profiles use adaptive auto mode: without a verified active X11 session the runtime provides FBEXEC, FBTERM and CUA-TTY in text mode, without requiring a graphical login or automatically creating a private desktop. Explicit virtual-desktop settings remain advanced options and are labelled separately from physical-screen evidence. Xvfb and Openbox are optional package suggestions and are not installed for the normal connection. Only when you choose a private virtual desktop, install its tools explicitly with sudo apt install xvfb openbox xauth and rerun fb doctor. Installing Openbox can register it as the generic window-manager alternative; use your distribution settings to retain your preferred manager when needed. Physical-screen capture and input currently require the supported active X11 session. Native Wayland control is not validated; showing an XWayland window does not establish full-desktop capture or input.
~~~sh ~~~sh
fb status fb status
fb doctor fb doctor
~~~ ~~~
### Guarded X11 text compatibility
Graphical TYPE in KEYS mode uses the guarded native X11 backend on x86-64 LP64 systems with libxdo 3.20160805.1. This private candidate accepts active keyboard group 0 only, with no held or latched modifiers and only conventional Caps Lock and Num Lock locked bits. The whole string and generated key actions are checked before any input. Unsupported groups, characters, actions or keyboard states fail closed. The backend does not change the keyboard group or map and does not use the clipboard. Display session, fixed target window, geometry and keyboard context are checked while sending. If input may have started and an error follows, inspect the target and do not automatically replay the text.
### Frame-buffer read access ### Frame-buffer read access
screen-access is a narrow, explicit administrative permission for reading the real frame buffer. It does not by itself enable graphical CUA, keyboard/mouse control or arbitrary VT switching. Persistent read access can remain effective after logout or a VT switch; review the command's displayed scope and use normal local administrator authentication. screen-access is a narrow, explicit administrative permission for reading the real frame buffer. It does not by itself enable graphical CUA, keyboard/mouse control or arbitrary VT switching. Persistent read access can remain effective after logout or a VT switch; review the command's displayed scope and use normal local administrator authentication.
@@ -340,12 +344,21 @@ fb status boot
### Doctor ### Doctor
doctor diagnoses prerequisites and reports findings with suggested next steps. It is not an implicit repair, and it is not a substitute for checking actual live connection state. Inspect the diagnosis and relevant private application logs before considering configuration changes. doctor diagnoses prerequisites and reports findings with suggested next steps. It is not an implicit repair, and it is not a substitute for checking actual live connection state. Inspect the diagnosis and relevant private application logs before considering configuration changes. Eligibility checks the exact OS release, amd64 architecture, package profile and CPython ABI before starting native components. The published 0.3.3 scope is Debian 13 with cp313 and Ubuntu 24.04/Mint 22.x with cp312. LMDE 7 and Ubuntu 26.04 builder experiments remain outside that validated scope. A matching profile or a successful development build does not establish a tested installation; never repair an ABI mismatch by replacing system Python or weakening package dependencies.
~~~sh ~~~sh
fb doctor fb doctor
~~~ ~~~
### Local health summary
health combines this local profile's existing status and prerequisite observations into an allowlisted report. It identifies LOCAL as this computer and reports the profile, role, connection intent, local tools, boot policy and desktop availability. It never starts, repairs, connects, changes boot policy or creates an absent profile. It does not scan the fleet or probe terminal commands, screen capture or input. States are healthy, paused, degraded, unavailable and unconfigured. An intentional connection pause can be healthy; missing information cannot. JSON result.ok describes observation completeness; result.health.healthy describes health. Health exits 0 for a complete healthy or intentionally paused observation and 1 for degraded, unavailable or unconfigured state. The CLI version is not proof of the running version. Detailed troubleshooting remains local in status and doctor.
~~~sh
fb health
fb --json health
~~~
### Logs and progress ### Logs and progress
Normal connection presents concise progress and a final result, then returns to the prompt. Detailed diagnostics belong in protected application logs. Share only the minimum relevant redacted result; never expose credentials, private keys, browser session data or unrelated personal paths. Avoid rerunning setup, enrollment or broad lifecycle tests merely because a view is confusing. Normal connection presents concise progress and a final result, then returns to the prompt. Detailed diagnostics belong in protected application logs. Share only the minimum relevant redacted result; never expose credentials, private keys, browser session data or unrelated personal paths. Avoid rerunning setup, enrollment or broad lifecycle tests merely because a view is confusing.
+110
View File
@@ -0,0 +1,110 @@
# Protected connection credentials
The existing systemd 256+ backend uses owner-scoped encrypted credentials and
`systemd-creds decrypt --user`. Existing installation records without a backend
field retain that behavior. The systemd 255 backend preserves the same user
service and uses a separately installed, privileged credential helper. Node
startup without a ChatGPT connection does not need a runtime connection key.
New or rotated keys on both backends use an administrative, secret-free sudo
policy bootstrap so that key input cannot enter sudo I/O logs.
## Installation and authorization
The package supplies these root-owned files:
* `/usr/lib/framebridge-expanded/bin/framebridge-credential-helper`
* `/usr/lib/framebridge-expanded/expanded/framebridge_expanded/credential_helper.py`
The launcher executes the fixed system interpreter with `-I -S`; the helper uses
only the standard library and absolute systemd-creds/visudo paths. Merely
installing the package does not enable a connection or install a sudoers grant.
Connection setup first requires ordinary administrative sudo authorization.
The helper's `authorize` operation accepts no key and installs only literal,
owner/profile-specific commands tagged `NOLOG_INPUT`, `NOLOG_OUTPUT` and
`NOSETENV`. The frontend then queries the effective policy for the exact command
using command-specific `sudo -n -ll` in the C locale. It requires one effective
matching root rule, the exact command/arguments and explicit disabled I/O logging
and environment overrides before sending any key. Helper input is limited to
16384 bytes plus EOF with a five-second absolute deadline, so an open input pipe
cannot hold the shared registry lock indefinitely. Generic Defaults and absence
of logging flags are not accepted as evidence. Unknown formats fail closed.
On systemd 255, setup writes host-encrypted ciphertext and principal metadata under
`/var/lib/framebridge-expanded/credentials`, owned by root with private access.
It also installs one validated file in `/etc/sudoers.d` per credential version.
That file permits exactly the recorded owner's literal helper `decrypt` command
and fixed profile/cipher digest arguments, with `NOSETENV` and disabled I/O
logging. The same effective-policy check precedes runtime sudo decryption.
The one-use encryption bootstrap is consumed before the helper reads key input.
On systemd 256+, the bootstrap permits only the existing native encrypt command
with UID derived from the sudo caller and the fixed credential name; the
frontend revokes that temporary permission in `finally`. Owner-scoped ciphertext
and unprivileged `systemd-creds decrypt --user` remain unchanged. It does not permit
arbitrary systemd-creds commands, paths, shell execution, account selection,
provisioning, revocation or unrestricted root execution without authentication.
This narrow permission is necessary for unattended reconnection after reboot.
The owner must review this permission change before production setup is
activated. Building the package and running synthetic unit tests do not activate
the helper, create a host encryption key, alter sudoers or enable a service.
## Scope and recovery
The helper derives the caller from sudo's UID, username and primary group; it
does not accept a caller-selected UID. Its records bind the UID, username, home
identity, profile digest, ciphertext digest and randomized cryptographic name.
Only fixed root-owned paths are opened. Symlinks, unexpected hardlinks, writable
parents, broad metadata permissions and unrecognized arguments are rejected.
Provisioning keeps the old credential usable until the replacement connection
commits. Retired versions remain listed until revocation succeeds. Directory
fsync orders durable ciphertext registration before the decrypt grant, and
grant revocation before ciphertext removal. Failed output delivery rolls back a
new grant. After SIGKILL or power loss, administrative `prune` reconciles orphan
versions even if the owner never received their ciphertext digest. New setup
preserves known current versions while pruning orphans; explicit disable prunes
all versions for its owner/profile, including when no manifest was committed.
This reconciliation also runs after an upgrade to the systemd 256+ backend.
Uninstall uses administrative `purge-all` after the user workers have stopped.
Recognized root-owned staging files from interrupted publication are recovered.
Prune and purge also remove authorization bootstrap files left by interruption.
Administrative interface (run the installed launcher, never a user checkout):
* `protect PROFILE_SHA256`: read a bounded printable synthetic/runtime key from
stdin and return only encrypted ciphertext on stdout. Requires and consumes
its matching host authorization before reading input.
* `authorize PROFILE_SHA256 host|user`: secret-free administrative bootstrap for
literal NOLOG encryption and cleanup commands, bound to the actual caller UID.
* `unauthorize PROFILE_SHA256 host|user`: remove a bootstrap authorization.
* `decrypt PROFILE_SHA256 CIPHER_SHA256`: return the registered owner's key on a
pipe; this is the only operation granted passwordless sudo.
* `revoke PROFILE_SHA256 CIPHER_SHA256`: idempotently revoke one owner version.
* `prune PROFILE_SHA256 KEEP_DIGESTS`: remove other versions for this owner and
profile; `KEEP_DIGESTS` is a comma-separated digest allowlist, or `-` for none.
* `purge-all`: root administrative removal of all helper-owned records/grants.
## Security limits
No plaintext key is intentionally stored in an environment variable, command
argument, log or disk file. Plaintext exists in process and pipe memory while
used; this is not a guarantee against memory inspection, swap, a compromised
owner session or root. Both credential backends trust the owner Unix principal.
UID reuse with another username or home is rejected. Recreating exactly the
same UID, username and home can recreate the same Unix principal; Linux does
not supply an account-generation identity here. Administrators must revoke or
purge the grants before deleting/recycling an account, and before changing its
identity/home in a way that would prevent ordinary owner cleanup. A randomized
credential name is not proof of an account's generation.
The effective-policy parser is validated against sudo 1.9's command-specific
verbose listing. A different sudo implementation or ambiguous policy output
fails before key input and needs its own compatibility work and evidence.
The helper backend is not a Wayland capture/input backend and does not remove
Python ABI, architecture, libc, PAM, user-systemd, cgroup, sudo or X11 capability
requirements. Synthetic tests validate interfaces and failure handling; a
support claim additionally requires clean-system installation, real systemd
credential operations, boot/lifecycle and feature-specific acceptance tests.
+22 -9
View File
@@ -1,6 +1,6 @@
# FrameBridge — Debian installation and first use # FrameBridge — Debian, Ubuntu and Linux Mint installation and first use
Version: 0.3.1 | Guide schema: 1 Version: 0.3.4 | Guide schema: 1
This reference is generated from the shared CLI/MCP guide. Use the matching installed version. This reference is generated from the shared CLI/MCP guide. Use the matching installed version.
@@ -23,7 +23,7 @@ This reference is generated from the shared CLI/MCP guide. Use the matching inst
### One interface for people and agents ### One interface for people and agents
FrameBridge provides tools and a shared command-line interface for human operators and authorized agents. fb is the short name for framebridge; both run the same program. The tools run on your Linux computers. The ChatGPT model remains hosted. Read this guide for the installed version before operating; confirm the receiver, target and available capabilities. Use the installed CLI and tools for normal work. Edit software only when the owner explicitly requests development, using a separate source clone and reviewable changes rather than rewriting the installed program. FrameBridge provides network administration tools and a shared command-line interface for a home Linux network with one human administrator and authorized agents. Desktop access is an auxiliary tool. fb is the short name for framebridge; both run the same program. The tools run on your Linux computers. The ChatGPT model remains hosted. Read this guide for the installed version before operating; confirm the receiver, target and available capabilities. Use the installed CLI and tools for normal work. Edit software only when the owner explicitly requests development, using a separate source clone and reviewable changes rather than rewriting the installed program.
~~~sh ~~~sh
fb help fb help
@@ -33,16 +33,15 @@ fb --json status
### Install and connect ### Install and connect
On a supported Debian system, install the downloaded package with the package manager. Normal first connection does not require a separate setup, doctor or start sequence. Run connect in a normal interactive terminal. Advanced setup is optional for selecting central/node roles and public settings. Install the package built for your exact Debian 13, Ubuntu 24.04 or Linux Mint 22.x amd64 profile. These are the three release profiles; other distributions and architectures are outside this release. Keep the distribution system Python: 3.13 on Debian 13, 3.12 on Ubuntu 24.04 and Mint 22.x. Use the matching signed APT suite: trixie, noble or mint22. Normal first connection does not require a separate setup, doctor or start sequence. Run connect in a normal interactive terminal; advanced setup is optional for central/node roles and public settings.
~~~sh ~~~sh
sudo apt install ./FrameBridge-0.3.1.deb
fb connect fb connect
~~~ ~~~
### Updating and removal ### Updating and removal
Install an official update through the normal package manager. Normal package removal stops package-owned managed processes and retains the profile and saved encrypted connection material for deliberate reuse; it is not a credential-forgetting operation. Use the separately documented explicit forget action first if that is your intended outcome. Do not assume purge erases user-profile data. Install an official update through the normal package manager when that update has been published. Normal removal stops package-owned managed processes and can retain the user profile and encrypted connection material; it is not a credential-forgetting operation. On systemd 255, package removal also revokes the administrative boot-credential grant. After reinstalling, run fb connect interactively to provision that grant again; retained files alone do not restore startup. Use the explicit credentials forget action first when that is your intended outcome. Do not assume purge erases user-profile data. An explicit downgrade also revokes administrative helper grants before the older package files replace the helper. Helper-backed profiles require a new interactive connect with a compatible package before managed startup can be restored.
~~~sh ~~~sh
sudo dpkg --remove framebridge-original-expanded sudo dpkg --remove framebridge-original-expanded
@@ -80,6 +79,7 @@ fb status runtime
fb status boot fb status boot
fb status console fb status console
fb doctor fb doctor
fb health
fb setup fb setup
fb console fb console
fb help fb help
@@ -231,7 +231,7 @@ fb nodes status
### Targets ### Targets
LOCAL means the actual receiving machine. Use enrolled, confirmed aliases or IDs and inspect the selected target before work. Do not guess SSH identities or use historical lab hostnames as defaults. CLI tty --node can use the authenticated node transport. The interactive console command is local; run it at the destination or use an explicitly configured SSH destination. LOCAL means the actual receiving machine. Use enrolled, confirmed aliases or IDs and inspect the selected target before work. Fleet results include requested_target, resolved_target, node_id, hostname and transport from the confirmed descriptor. The compatibility target field can remain LOCAL; resolved_target names the actual local computer. Unknown targets never fall back to LOCAL. Do not guess SSH identities or use historical lab hostnames as defaults. CLI tty --node can use the authenticated node transport. The interactive console command is local; run it at the destination or use an explicitly configured SSH destination.
### Transport trust ### Transport trust
@@ -303,13 +303,17 @@ A CLI tty transcript describes a terminal and explicitly does not claim authenti
### Automatic graphical discovery ### Automatic graphical discovery
The installer and control console stay text interfaces in both TTY and a desktop terminal. Compatible graphical CUA is discovered automatically; availability depends on the actual display/session and permissions. A loaded module is not proof that real pixel capture or input succeeded. New profiles use adaptive auto mode: without a verified active X11 session the runtime provides FBEXEC, FBTERM and CUA-TTY in text mode, without requiring a graphical login or automatically creating a private desktop. Explicit virtual-desktop settings remain advanced options and are labelled separately from physical-screen evidence. The installer and control console stay text interfaces in both TTY and a desktop terminal. Compatible graphical CUA is discovered automatically; availability depends on the actual display/session and permissions. A loaded module is not proof that real pixel capture or input succeeded. New profiles use adaptive auto mode: without a verified active X11 session the runtime provides FBEXEC, FBTERM and CUA-TTY in text mode, without requiring a graphical login or automatically creating a private desktop. Explicit virtual-desktop settings remain advanced options and are labelled separately from physical-screen evidence. Xvfb and Openbox are optional package suggestions and are not installed for the normal connection. Only when you choose a private virtual desktop, install its tools explicitly with sudo apt install xvfb openbox xauth and rerun fb doctor. Installing Openbox can register it as the generic window-manager alternative; use your distribution settings to retain your preferred manager when needed. Physical-screen capture and input currently require the supported active X11 session. Native Wayland control is not validated; showing an XWayland window does not establish full-desktop capture or input.
~~~sh ~~~sh
fb status fb status
fb doctor fb doctor
~~~ ~~~
### Guarded X11 text compatibility
Graphical TYPE in KEYS mode uses the guarded native X11 backend on x86-64 LP64 systems with libxdo 3.20160805.1. This private candidate accepts active keyboard group 0 only, with no held or latched modifiers and only conventional Caps Lock and Num Lock locked bits. The whole string and generated key actions are checked before any input. Unsupported groups, characters, actions or keyboard states fail closed. The backend does not change the keyboard group or map and does not use the clipboard. Display session, fixed target window, geometry and keyboard context are checked while sending. If input may have started and an error follows, inspect the target and do not automatically replay the text.
### Frame-buffer read access ### Frame-buffer read access
screen-access is a narrow, explicit administrative permission for reading the real frame buffer. It does not by itself enable graphical CUA, keyboard/mouse control or arbitrary VT switching. Persistent read access can remain effective after logout or a VT switch; review the command's displayed scope and use normal local administrator authentication. screen-access is a narrow, explicit administrative permission for reading the real frame buffer. It does not by itself enable graphical CUA, keyboard/mouse control or arbitrary VT switching. Persistent read access can remain effective after logout or a VT switch; review the command's displayed scope and use normal local administrator authentication.
@@ -340,12 +344,21 @@ fb status boot
### Doctor ### Doctor
doctor diagnoses prerequisites and reports findings with suggested next steps. It is not an implicit repair, and it is not a substitute for checking actual live connection state. Inspect the diagnosis and relevant private application logs before considering configuration changes. doctor diagnoses prerequisites and reports findings with suggested next steps. It is not an implicit repair, and it is not a substitute for checking actual live connection state. Inspect the diagnosis and relevant private application logs before considering configuration changes. Eligibility checks the exact OS release, amd64 architecture, package profile and CPython ABI before starting native components. The published 0.3.3 scope is Debian 13 with cp313 and Ubuntu 24.04/Mint 22.x with cp312. LMDE 7 and Ubuntu 26.04 builder experiments remain outside that validated scope. A matching profile or a successful development build does not establish a tested installation; never repair an ABI mismatch by replacing system Python or weakening package dependencies.
~~~sh ~~~sh
fb doctor fb doctor
~~~ ~~~
### Local health summary
health combines this local profile's existing status and prerequisite observations into an allowlisted report. It identifies LOCAL as this computer and reports the profile, role, connection intent, local tools, boot policy and desktop availability. It never starts, repairs, connects, changes boot policy or creates an absent profile. It does not scan the fleet or probe terminal commands, screen capture or input. States are healthy, paused, degraded, unavailable and unconfigured. An intentional connection pause can be healthy; missing information cannot. JSON result.ok describes observation completeness; result.health.healthy describes health. Health exits 0 for a complete healthy or intentionally paused observation and 1 for degraded, unavailable or unconfigured state. The CLI version is not proof of the running version. Detailed troubleshooting remains local in status and doctor.
~~~sh
fb health
fb --json health
~~~
### Logs and progress ### Logs and progress
Normal connection presents concise progress and a final result, then returns to the prompt. Detailed diagnostics belong in protected application logs. Share only the minimum relevant redacted result; never expose credentials, private keys, browser session data or unrelated personal paths. Avoid rerunning setup, enrollment or broad lifecycle tests merely because a view is confusing. Normal connection presents concise progress and a final result, then returns to the prompt. Detailed diagnostics belong in protected application logs. Share only the minimum relevant redacted result; never expose credentials, private keys, browser session data or unrelated personal paths. Avoid rerunning setup, enrollment or broad lifecycle tests merely because a view is confusing.
+17 -8
View File
@@ -1,4 +1,4 @@
.TH FRAMEBRIDGE 1 "2026-10-26" "FrameBridge 0.3.1" "User Commands" .TH FRAMEBRIDGE 1 "2026-10-29" "FrameBridge 0.3.4" "User Commands"
.SH NAME .SH NAME
framebridge, fb \- use local Linux tools and enrolled nodes with ChatGPT framebridge, fb \- use local Linux tools and enrolled nodes with ChatGPT
.SH SYNOPSIS .SH SYNOPSIS
@@ -10,7 +10,7 @@ The names fb and framebridge execute the same program.
The tools run on the selected Linux computers; the ChatGPT model remains hosted. The tools run on the selected Linux computers; the ChatGPT model remains hosted.
.SH START WITH FRAMEBRIDGE .SH START WITH FRAMEBRIDGE
.SS One interface for people and agents .SS One interface for people and agents
FrameBridge provides tools and a shared command\-line interface for human operators and authorized agents. fb is the short name for framebridge; both run the same program. The tools run on your Linux computers. The ChatGPT model remains hosted. Read this guide for the installed version before operating; confirm the receiver, target and available capabilities. Use the installed CLI and tools for normal work. Edit software only when the owner explicitly requests development, using a separate source clone and reviewable changes rather than rewriting the installed program. FrameBridge provides network administration tools and a shared command\-line interface for a home Linux network with one human administrator and authorized agents. Desktop access is an auxiliary tool. fb is the short name for framebridge; both run the same program. The tools run on your Linux computers. The ChatGPT model remains hosted. Read this guide for the installed version before operating; confirm the receiver, target and available capabilities. Use the installed CLI and tools for normal work. Edit software only when the owner explicitly requests development, using a separate source clone and reviewable changes rather than rewriting the installed program.
.PP .PP
.nf .nf
fb help fb help
@@ -18,14 +18,13 @@ fb status
fb \-\-json status fb \-\-json status
.fi .fi
.SS Install and connect .SS Install and connect
On a supported Debian system, install the downloaded package with the package manager. Normal first connection does not require a separate setup, doctor or start sequence. Run connect in a normal interactive terminal. Advanced setup is optional for selecting central/node roles and public settings. Install the package built for your exact Debian 13, Ubuntu 24.04 or Linux Mint 22.x amd64 profile. These are the three release profiles; other distributions and architectures are outside this release. Keep the distribution system Python: 3.13 on Debian 13, 3.12 on Ubuntu 24.04 and Mint 22.x. Use the matching signed APT suite: trixie, noble or mint22. Normal first connection does not require a separate setup, doctor or start sequence. Run connect in a normal interactive terminal; advanced setup is optional for central/node roles and public settings.
.PP .PP
.nf .nf
sudo apt install ./FrameBridge\-0.3.1.deb
fb connect fb connect
.fi .fi
.SS Updating and removal .SS Updating and removal
Install an official update through the normal package manager. Normal package removal stops package\-owned managed processes and retains the profile and saved encrypted connection material for deliberate reuse; it is not a credential\-forgetting operation. Use the separately documented explicit forget action first if that is your intended outcome. Do not assume purge erases user\-profile data. Install an official update through the normal package manager when that update has been published. Normal removal stops package\-owned managed processes and can retain the user profile and encrypted connection material; it is not a credential\-forgetting operation. On systemd 255, package removal also revokes the administrative boot\-credential grant. After reinstalling, run fb connect interactively to provision that grant again; retained files alone do not restore startup. Use the explicit credentials forget action first when that is your intended outcome. Do not assume purge erases user\-profile data. An explicit downgrade also revokes administrative helper grants before the older package files replace the helper. Helper\-backed profiles require a new interactive connect with a compatible package before managed startup can be restored.
.PP .PP
.nf .nf
sudo dpkg \-\-remove framebridge\-original\-expanded sudo dpkg \-\-remove framebridge\-original\-expanded
@@ -53,6 +52,7 @@ fb status runtime
fb status boot fb status boot
fb status console fb status console
fb doctor fb doctor
fb health
fb setup fb setup
fb console fb console
fb help fb help
@@ -162,7 +162,7 @@ fb nodes pending
fb nodes status fb nodes status
.fi .fi
.SS Targets .SS Targets
LOCAL means the actual receiving machine. Use enrolled, confirmed aliases or IDs and inspect the selected target before work. Do not guess SSH identities or use historical lab hostnames as defaults. CLI tty \-\-node can use the authenticated node transport. The interactive console command is local; run it at the destination or use an explicitly configured SSH destination. LOCAL means the actual receiving machine. Use enrolled, confirmed aliases or IDs and inspect the selected target before work. Fleet results include requested_target, resolved_target, node_id, hostname and transport from the confirmed descriptor. The compatibility target field can remain LOCAL; resolved_target names the actual local computer. Unknown targets never fall back to LOCAL. Do not guess SSH identities or use historical lab hostnames as defaults. CLI tty \-\-node can use the authenticated node transport. The interactive console command is local; run it at the destination or use an explicitly configured SSH destination.
.SS Transport trust .SS Transport trust
certificates create manages a private central/node transport CA, not a public website certificate or ChatGPT account authentication. Private keys stay protected on the central host; distribute only the required public trust material. Off\-loopback broker use requires correctly configured verified TLS. Do not recreate trust merely to work around a connection symptom. certificates create manages a private central/node transport CA, not a public website certificate or ChatGPT account authentication. Private keys stay protected on the central host; distribute only the required public trust material. Off\-loopback broker use requires correctly configured verified TLS. Do not recreate trust merely to work around a connection symptom.
.PP .PP
@@ -210,12 +210,14 @@ fb status console
.SS Text transcripts and pixels .SS Text transcripts and pixels
A CLI tty transcript describes a terminal and explicitly does not claim authentic pixels. MCP real\-screen CUA\-TTY and graphical screen tools operate through the real screen client and capture\-linked interaction. Use those supported observation tools when authentic complete screen evidence is required. Never present a reconstructed terminal, animation or partial invented view as a real screenshot. A CLI tty transcript describes a terminal and explicitly does not claim authentic pixels. MCP real\-screen CUA\-TTY and graphical screen tools operate through the real screen client and capture\-linked interaction. Use those supported observation tools when authentic complete screen evidence is required. Never present a reconstructed terminal, animation or partial invented view as a real screenshot.
.SS Automatic graphical discovery .SS Automatic graphical discovery
The installer and control console stay text interfaces in both TTY and a desktop terminal. Compatible graphical CUA is discovered automatically; availability depends on the actual display/session and permissions. A loaded module is not proof that real pixel capture or input succeeded. New profiles use adaptive auto mode: without a verified active X11 session the runtime provides FBEXEC, FBTERM and CUA\-TTY in text mode, without requiring a graphical login or automatically creating a private desktop. Explicit virtual\-desktop settings remain advanced options and are labelled separately from physical\-screen evidence. The installer and control console stay text interfaces in both TTY and a desktop terminal. Compatible graphical CUA is discovered automatically; availability depends on the actual display/session and permissions. A loaded module is not proof that real pixel capture or input succeeded. New profiles use adaptive auto mode: without a verified active X11 session the runtime provides FBEXEC, FBTERM and CUA\-TTY in text mode, without requiring a graphical login or automatically creating a private desktop. Explicit virtual\-desktop settings remain advanced options and are labelled separately from physical\-screen evidence. Xvfb and Openbox are optional package suggestions and are not installed for the normal connection. Only when you choose a private virtual desktop, install its tools explicitly with sudo apt install xvfb openbox xauth and rerun fb doctor. Installing Openbox can register it as the generic window\-manager alternative; use your distribution settings to retain your preferred manager when needed. Physical\-screen capture and input currently require the supported active X11 session. Native Wayland control is not validated; showing an XWayland window does not establish full\-desktop capture or input.
.PP .PP
.nf .nf
fb status fb status
fb doctor fb doctor
.fi .fi
.SS Guarded X11 text compatibility
Graphical TYPE in KEYS mode uses the guarded native X11 backend on x86\-64 LP64 systems with libxdo 3.20160805.1. This private candidate accepts active keyboard group 0 only, with no held or latched modifiers and only conventional Caps Lock and Num Lock locked bits. The whole string and generated key actions are checked before any input. Unsupported groups, characters, actions or keyboard states fail closed. The backend does not change the keyboard group or map and does not use the clipboard. Display session, fixed target window, geometry and keyboard context are checked while sending. If input may have started and an error follows, inspect the target and do not automatically replay the text.
.SS Frame\-buffer read access .SS Frame\-buffer read access
screen\-access is a narrow, explicit administrative permission for reading the real frame buffer. It does not by itself enable graphical CUA, keyboard/mouse control or arbitrary VT switching. Persistent read access can remain effective after logout or a VT switch; review the command's displayed scope and use normal local administrator authentication. screen\-access is a narrow, explicit administrative permission for reading the real frame buffer. It does not by itself enable graphical CUA, keyboard/mouse control or arbitrary VT switching. Persistent read access can remain effective after logout or a VT switch; review the command's displayed scope and use normal local administrator authentication.
.PP .PP
@@ -235,11 +237,18 @@ fb status runtime
fb status boot fb status boot
.fi .fi
.SS Doctor .SS Doctor
doctor diagnoses prerequisites and reports findings with suggested next steps. It is not an implicit repair, and it is not a substitute for checking actual live connection state. Inspect the diagnosis and relevant private application logs before considering configuration changes. doctor diagnoses prerequisites and reports findings with suggested next steps. It is not an implicit repair, and it is not a substitute for checking actual live connection state. Inspect the diagnosis and relevant private application logs before considering configuration changes. Eligibility checks the exact OS release, amd64 architecture, package profile and CPython ABI before starting native components. The published 0.3.3 scope is Debian 13 with cp313 and Ubuntu 24.04/Mint 22.x with cp312. LMDE 7 and Ubuntu 26.04 builder experiments remain outside that validated scope. A matching profile or a successful development build does not establish a tested installation; never repair an ABI mismatch by replacing system Python or weakening package dependencies.
.PP .PP
.nf .nf
fb doctor fb doctor
.fi .fi
.SS Local health summary
health combines this local profile's existing status and prerequisite observations into an allowlisted report. It identifies LOCAL as this computer and reports the profile, role, connection intent, local tools, boot policy and desktop availability. It never starts, repairs, connects, changes boot policy or creates an absent profile. It does not scan the fleet or probe terminal commands, screen capture or input. States are healthy, paused, degraded, unavailable and unconfigured. An intentional connection pause can be healthy; missing information cannot. JSON result.ok describes observation completeness; result.health.healthy describes health. Health exits 0 for a complete healthy or intentionally paused observation and 1 for degraded, unavailable or unconfigured state. The CLI version is not proof of the running version. Detailed troubleshooting remains local in status and doctor.
.PP
.nf
fb health
fb \-\-json health
.fi
.SS Logs and progress .SS Logs and progress
Normal connection presents concise progress and a final result, then returns to the prompt. Detailed diagnostics belong in protected application logs. Share only the minimum relevant redacted result; never expose credentials, private keys, browser session data or unrelated personal paths. Avoid rerunning setup, enrollment or broad lifecycle tests merely because a view is confusing. Normal connection presents concise progress and a final result, then returns to the prompt. Detailed diagnostics belong in protected application logs. Share only the minimum relevant redacted result; never expose credentials, private keys, browser session data or unrelated personal paths. Avoid rerunning setup, enrollment or broad lifecycle tests merely because a view is confusing.
.PP .PP
@@ -1 +1 @@
__version__ = "0.3.1" __version__ = "0.3.4"
@@ -212,7 +212,10 @@ class RealTerminalMixin:
def shutdown(self): def shutdown(self):
with self._lock: with self._lock:
errors=[]
for sid in list(self._real): for sid in list(self._real):
try:self.abort(sid) try:self.abort(sid)
except (TerminalDenied,SudoControlError):pass except (TerminalDenied,SudoControlError) as error:
errors.append({'session_id':sid,'error':str(error)})
super().shutdown() super().shutdown()
return {'cleanup_verified':not self._real,'errors':errors}
+398 -154
View File
@@ -1,6 +1,7 @@
"""Explicit user-service startup with host-bound encrypted systemd credentials.""" """Explicit user-service startup with host-bound encrypted systemd credentials."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from contextlib import contextmanager
import fcntl import fcntl
import getpass import getpass
import hashlib import hashlib
@@ -20,6 +21,185 @@ import time
from .config import load_config, save_config, private_dir, state_path, default_config, validate from .config import load_config, save_config, private_dir, state_path, default_config, validate
CREDENTIAL = "chatgpt-runtime-key" CREDENTIAL = "chatgpt-runtime-key"
USER_BACKEND = "systemd-user-v1"
HOST_BACKEND = "systemd-host-helper-v1"
HELPER = Path("/usr/lib/framebridge-expanded/bin/framebridge-credential-helper")
def _helper_present():
return HELPER.exists() or HELPER.is_symlink()
def _helper_ready():
for target in (HELPER, HELPER.parent.parent / "expanded/framebridge_expanded/credential_helper.py"):
for path in reversed((target, *target.parents)):
try:
info = path.lstat()
except FileNotFoundError:
raise RuntimeError("This system needs the installed FrameBridge protected-credential helper. Install the matching package before connecting.") from None
if info.st_uid != 0 or info.st_mode & 0o022 or stat.S_ISLNK(info.st_mode):
raise RuntimeError("The installed protected-credential helper is unsafe.")
if path == target:
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
raise RuntimeError("The installed protected-credential helper is unsafe.")
elif not stat.S_ISDIR(info.st_mode):
raise RuntimeError("The installed protected-credential helper is unsafe.")
def _credential_backend(record=None):
if record is not None:
backend = record.get("credential_backend", USER_BACKEND)
if backend not in {USER_BACKEND, HOST_BACKEND}:
raise RuntimeError("Unsupported protected-credential backend.")
return backend
version = _command(["systemd-creds", "--version"]).stdout.decode(errors="replace")
match = re.match(r"systemd (\d+)", version)
if not match or int(match.group(1)) < 255:
raise RuntimeError("Protected connections require systemd 255 or later.")
if int(match.group(1)) >= 256:
return USER_BACKEND
_helper_ready()
return HOST_BACKEND
def _profile_id(state):
return hashlib.sha256(str(state_path(state)).encode()).hexdigest()
def _encrypt_credential(state, key, backend):
_helper_ready()
if backend == HOST_BACKEND:
kind = "host"
command = [str(HELPER), "protect", _profile_id(state)]
elif backend == USER_BACKEND:
kind = "user"
command = ["/usr/bin/systemd-creds", "encrypt", "--uid=" + str(os.getuid()), "--with-key=host", "--name=" + CREDENTIAL, "-", "-"]
else:
raise RuntimeError("Unsupported protected-credential backend.")
# Bootstrap is administratively authorized and carries no runtime key.
# A command-specific effective-policy check precedes ALL secret input.
_command(["sudo", "-n", "--", str(HELPER), "authorize", _profile_id(state), kind])
try:
_require_no_sudo_io_logging(command)
return _command(["sudo", "-n", "--", *command], data=key.encode()).stdout
finally:
# Host protect consumes its grant before reading input. A missing
# cancel grant after that successful consume is harmless. Native user
# encryption cannot consume the grant itself, so cancellation is strict.
_command(["sudo", "-n", "--", str(HELPER), "unauthorize", _profile_id(state), kind], check=kind == "user")
def _require_no_sudo_io_logging(command):
environment = dict(os.environ, LANG="C", LC_ALL="C")
result = _command(["sudo", "-n", "-ll", "--", *command], env=environment)
error = "The effective sudo policy must explicitly disable input/output logging for this exact credential command. No runtime key was sent."
if result.returncode or len(result.stdout) > 131072:
raise RuntimeError(error)
try:
text = result.stdout.decode("utf-8")
except UnicodeDecodeError:
raise RuntimeError(error) from None
# sudo 1.9's command-specific verbose list reports the effective matching
# rule. Unknown/ambiguous formats (including other policy plugins) fail
# closed; absence of log_* in generic Defaults is never accepted as proof.
if text.count("Sudoers entry:") != 1 or len(re.findall(r"^\s*Matched:", text, re.M)) != 1:
raise RuntimeError(error)
options = re.search(r"^\s*Options:\s*(.*?)^\s*Commands:\s*$", text, re.M | re.S)
matched = re.search(r"^\s*Matched:\s*(.*?)\s*\Z", text, re.M | re.S)
run_as = re.findall(r"^\s*RunAsUsers:\s*(.*?)\s*$", text, re.M)
if not options or not matched or run_as != ["root"]:
raise RuntimeError(error)
flags = {value.strip() for value in options[1].replace("\n", " ").split(",")}
logs = {"log_input", "log_output", "log_stdin", "log_stdout", "log_stderr", "log_ttyin", "log_ttyout"}
if not {"!log_input", "!log_output", "!setenv"}.issubset(flags) or logs.intersection(flags):
raise RuntimeError(error)
if " ".join(matched[1].split()) != " ".join(command):
raise RuntimeError(error)
def _revoke_helper(state, digest):
if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
raise RuntimeError("Invalid protected-credential cleanup record.")
_helper_ready()
_command(["sudo", "-n", "--", str(HELPER), "revoke", _profile_id(state), digest])
def _prune_helpers(state, keep=()):
values = list(dict.fromkeys(keep))
if len(values) > 128 or any(type(v) is not str or not re.fullmatch(r"[0-9a-f]{64}", v) for v in values):
raise RuntimeError("Invalid protected-credential cleanup record.")
_helper_ready()
_command(["sudo", "-n", "--", str(HELPER), "prune", _profile_id(state), ",".join(values) or "-"])
def _node_helper_cleanup_required(state):
"""Only omit privileged cleanup when no managed grant can authorize it.
The encrypted registry is deliberately private to root. Its orphaned
ciphertext does not grant access without a matching sudo policy. Inspect
only policy names in the secured, root-owned policy directory; never read
rule contents or treat a failed/incomplete inspection as an empty registry.
"""
from .credential_helper import SUDOERS, _root_path
_helper_ready()
uid, profile = str(os.getuid()), _profile_id(state)
prefixes = ("framebridge-credential-" + uid + "-" + profile + "-",
"framebridge-credential-authorize-" + uid + "-" + profile + "-")
patterns = (re.escape(prefixes[0]) + r"[0-9a-f]{64}",
re.escape(prefixes[1]) + r"(?:host|user)")
try:
directory = _root_path(SUDOERS, directory=True)
names = [path.name for path in directory.iterdir()]
except (OSError, RuntimeError):
return True
cleanup_required = False
for name in names:
if any(re.fullmatch(pattern, name) for pattern in patterns):
cleanup_required = True
continue
# A malformed entry in this exact owner's/profile's managed namespace
# is ambiguous. The helper's normal cleanup does not recognize every
# malformed authorization name, so require explicit review instead.
if name.startswith(prefixes):
raise RuntimeError("An ambiguous managed credential policy needs administrator review before node startup can be configured.")
return cleanup_required
@contextmanager
def _credential_transaction(state, encrypted, backend):
transaction = {"committed": False}
try:
yield transaction
finally:
if not transaction["committed"] and backend == HOST_BACKEND:
try:
_revoke_helper(state, hashlib.sha256(encrypted).hexdigest())
except Exception:
raise RuntimeError("Connection setup did not complete and protected-credential cleanup could not be verified. Ask the administrator to inspect the credential registry before retrying.") from None
def _retired_helpers(record):
values = record.get("retired_helper_credentials", [])
if type(values) is not list or len(values) > 128 or any(type(v) is not str or not re.fullmatch(r"[0-9a-f]{64}", v) for v in values):
raise RuntimeError("Invalid protected-credential cleanup record.")
return list(dict.fromkeys(values))
def _cleanup_retired_helpers(state, directory, record):
pending = _retired_helpers(record)
if not pending:
return
for digest in list(pending):
try:
_revoke_helper(state, digest)
except Exception:
# The committed current credential remains valid; keep an explicit
# cleanup list so disable/uninstall can revoke all older grants.
break
pending.remove(digest)
record["retired_helper_credentials"] = pending
_json(directory / "installation.json", record, replace=True)
_STOP = False _STOP = False
@@ -69,8 +249,8 @@ def _json(path, value, replace=False):
_write(path, (json.dumps(value, sort_keys=True, indent=2) + "\n").encode(), replace) _write(path, (json.dumps(value, sort_keys=True, indent=2) + "\n").encode(), replace)
def _command(args, *, data=None, timeout=35, check=True): def _command(args, *, data=None, timeout=35, check=True, env=None):
result = subprocess.run(args, input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) result = subprocess.run(args, input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, env=env)
if check and result.returncode: if check and result.returncode:
raise RuntimeError("Autostart command failed: " + Path(args[0]).name + ". No command output was exported.") raise RuntimeError("Autostart command failed: " + Path(args[0]).name + ". No command output was exported.")
return result return result
@@ -123,10 +303,17 @@ def _check_unit_directory(path):
for directory in [Path.home() / ".config", Path.home() / ".config/systemd", path.parent]: for directory in [Path.home() / ".config", Path.home() / ".config/systemd", path.parent]:
if directory.is_symlink(): if directory.is_symlink():
raise RuntimeError("User service directories must not be symbolic links.") raise RuntimeError("User service directories must not be symbolic links.")
directory.mkdir(mode=0o700, exist_ok=True) try:
info = directory.stat() directory.mkdir(mode=0o700, exist_ok=True)
if info.st_uid != os.getuid() or info.st_mode & 0o022: except PermissionError:
raise RuntimeError("User service directories must be owned by this user and not writable by others.") raise RuntimeError("This account cannot create its user service directory; existing ownership and permissions were left unchanged.") from None
info = directory.lstat()
# Service accounts can have administrator-owned .config ancestors.
# Those ancestors are safe when immutable to other accounts; the
# directory containing the unit must still belong to this owner.
allowed_owner = info.st_uid == os.getuid() or (directory != path.parent and info.st_uid == 0)
if not stat.S_ISDIR(info.st_mode) or not allowed_owner or info.st_mode & 0o022:
raise RuntimeError("User service directories must have a safe owner and not be writable by others; the final directory must belong to this user.")
def _manifest(directory): def _manifest(directory):
@@ -148,6 +335,8 @@ def status(state):
from . import runtime, connection_background from . import runtime, connection_background
result = {"configured": bool(manifest), "unit": unit.name, "service_active": values.get("ActiveState") == "active", "service_state": values.get("ActiveState", "unavailable"), "enabled": values.get("UnitFileState") == "enabled", "linger": _linger(pwd.getpwuid(os.getuid()).pw_name), "encrypted_credential_present": False, "runtime": _safe_runtime_status(runtime, state), "connection": connection_background.status(state)} result = {"configured": bool(manifest), "unit": unit.name, "service_active": values.get("ActiveState") == "active", "service_state": values.get("ActiveState", "unavailable"), "enabled": values.get("UnitFileState") == "enabled", "linger": _linger(pwd.getpwuid(os.getuid()).pw_name), "encrypted_credential_present": False, "runtime": _safe_runtime_status(runtime, state), "connection": connection_background.status(state)}
if manifest and not manifest.get("node"): if manifest and not manifest.get("node"):
result["credential_backend"] = _credential_backend(manifest)
result["credential_cleanup_pending"] = bool(_retired_helpers(manifest))
try: try:
cipher = _read(directory / "credential.cred") cipher = _read(directory / "credential.cred")
result["encrypted_credential_present"] = hashlib.sha256(cipher).hexdigest() == manifest.get("credential_sha256") result["encrypted_credential_present"] = hashlib.sha256(cipher).hexdigest() == manifest.get("credential_sha256")
@@ -336,11 +525,13 @@ def _configure_connection(state, settings, runtime_key):
raise RuntimeError("The profile backup changed outside FrameBridge; nothing was replaced.") raise RuntimeError("The profile backup changed outside FrameBridge; nothing was replaced.")
elif any(originals[path] is not None for path in (unit, blob, backup)): elif any(originals[path] is not None for path in (unit, blob, backup)):
raise RuntimeError("Existing unowned connection files; nothing was replaced.") raise RuntimeError("Existing unowned connection files; nothing was replaced.")
pending = _retired_helpers(previous or {})
if previous and _credential_backend(previous) == HOST_BACKEND:
pending = list(dict.fromkeys([*pending, previous["credential_sha256"]]))
if len(pending) > 128:
raise RuntimeError("Retired protected credentials must be cleaned before replacing this connection. Disable automatic startup and retry connection setup.")
_check_unit_directory(unit) _check_unit_directory(unit)
version = _command(["systemd-creds", "--version"]).stdout.decode(errors="replace") backend = _credential_backend()
match = re.match(r"systemd (\d+)", version)
if not match or int(match.group(1)) < 256:
raise RuntimeError("Protected connections require systemd 256 or later.")
_command(["sudo", "-v"], timeout=180) _command(["sudo", "-v"], timeout=180)
user = pwd.getpwuid(os.getuid()).pw_name user = pwd.getpwuid(os.getuid()).pw_name
linger_before = _linger(user) linger_before = _linger(user)
@@ -349,105 +540,116 @@ def _configure_connection(state, settings, runtime_key):
values = dict(line.split("=", 1) for line in properties.stdout.decode(errors="replace").splitlines() if "=" in line) values = dict(line.split("=", 1) for line in properties.stdout.decode(errors="replace").splitlines() if "=" in line)
was_enabled = values.get("UnitFileState") == "enabled" was_enabled = values.get("UnitFileState") == "enabled"
was_active = values.get("ActiveState") in {"active", "activating", "reloading"} was_active = values.get("ActiveState") in {"active", "activating", "reloading"}
if backend == HOST_BACKEND or _helper_present():
keep = _retired_helpers(previous or {})
if previous and _credential_backend(previous) == HOST_BACKEND:
keep.append(previous["credential_sha256"])
_prune_helpers(state, keep)
try: try:
encrypted = _command(["sudo", "-n", "--", "systemd-creds", "encrypt", "--uid=" + str(os.getuid()), "--with-key=host", "--name=" + CREDENTIAL, "-", "-"], data=runtime_key.encode()).stdout encrypted = _encrypt_credential(state, runtime_key, backend)
finally: finally:
runtime_key = "" runtime_key = ""
if len(encrypted) < 80 or len(encrypted) > 131072: with _credential_transaction(state, encrypted, backend) as transaction:
raise RuntimeError("Connection protection did not complete.") if len(encrypted) < 80 or len(encrypted) > 131072:
# Recheck the exact originals after authentication/encryption and before any stop. raise RuntimeError("Connection protection did not complete.")
if any(_optional_read(path) != data for path, data in originals.items()): # Recheck the exact originals after authentication/encryption and before any stop.
raise RuntimeError("The profile changed during connection setup; nothing was replaced.") if any(_optional_read(path) != data for path, data in originals.items()):
text = unit_text(state, blob).encode() raise RuntimeError("The profile changed during connection setup; nothing was replaced.")
record = dict(previous) if previous else {"schema": 1, "unit": unit.name, "node": False, "linger_before": linger_before, "enabled_units_before": units_before, "headless_before": headless_before, "headless_changed": changed_headless, "config_before_sha256": hashlib.sha256(original_config).hexdigest() if original_config is not None else ""} text = unit_text(state, blob).encode()
backup_bytes = originals[backup] record = dict(previous) if previous else {"schema": 1, "unit": unit.name, "node": False, "linger_before": linger_before, "enabled_units_before": units_before, "headless_before": headless_before, "headless_changed": changed_headless, "config_before_sha256": hashlib.sha256(original_config).hexdigest() if original_config is not None else ""}
if changed_headless and not record.get("headless_changed"): backup_bytes = originals[backup]
record.update(headless_before=headless_before, headless_changed=True, config_before_sha256=hashlib.sha256(original_config).hexdigest()) if changed_headless and not record.get("headless_changed"):
backup_bytes = original_config record.update(headless_before=headless_before, headless_changed=True, config_before_sha256=hashlib.sha256(original_config).hexdigest())
elif not previous and changed_headless: backup_bytes = original_config
backup_bytes = original_config elif not previous and changed_headless:
if record.get("headless_changed"): backup_bytes = original_config
# Disabling changes only the desktop preference, preserving the newly selected connection. if record.get("headless_changed"):
restored_profile = dict(config) # Disabling changes only the desktop preference, preserving the newly selected connection.
restored_profile["headless"] = record["headless_before"] restored_profile = dict(config)
backup_bytes = (json.dumps(restored_profile, indent=2, sort_keys=True) + "\n").encode() restored_profile["headless"] = record["headless_before"]
record["config_before_sha256"] = hashlib.sha256(backup_bytes).hexdigest() backup_bytes = (json.dumps(restored_profile, indent=2, sort_keys=True) + "\n").encode()
record["changed_config_sha256"] = hashlib.sha256(config_bytes).hexdigest() record["config_before_sha256"] = hashlib.sha256(backup_bytes).hexdigest()
record.update(unit_sha256=hashlib.sha256(text).hexdigest(), credential_sha256=hashlib.sha256(encrypted).hexdigest(), connection_settings_sha256=_connection_public_digest(config)) record["changed_config_sha256"] = hashlib.sha256(config_bytes).hexdigest()
from . import connection_background retired = _retired_helpers(previous or {})
background_before = connection_background.status(state) if previous and _credential_backend(previous) == HOST_BACKEND:
changed = {} retired.append(previous["credential_sha256"])
service_touched = False record.update(unit_sha256=hashlib.sha256(text).hexdigest(), credential_sha256=hashlib.sha256(encrypted).hexdigest(), credential_backend=backend, retired_helper_credentials=list(dict.fromkeys(retired)), connection_settings_sha256=_connection_public_digest(config))
manual_connection_stopped = False from . import connection_background
def write(path, data): background_before = connection_background.status(state)
changed[path] = data changed = {}
_write(path, data, replace=originals.get(path) is not None) service_touched = False
try: manual_connection_stopped = False
if previous: def write(path, data):
service_touched = True changed[path] = data
_systemctl("stop", unit.name) _write(path, data, replace=originals.get(path) is not None)
disconnected = connection_background.disconnect(state)
if disconnected.get("ok") is False or disconnected.get("alive"):
raise RuntimeError("The previous connection could not be stopped safely; nothing was replaced.")
manual_connection_stopped = not previous and background_before.get("alive") is True
if connection_background.status(state).get("alive"):
raise RuntimeError("The previous connection is still stopping; nothing was replaced.")
write(blob, encrypted)
write(unit, text)
write(config_path, config_bytes)
if backup_bytes is not None and backup_bytes != originals[backup]:
write(backup, backup_bytes)
write(record_path, (json.dumps(record, sort_keys=True, indent=2) + "\n").encode())
write(intent, (json.dumps({"schema": 1, "connect": True, "revision": connection_request_revision(state) + 1}, sort_keys=True) + "\n").encode())
_command(["sudo", "-n", "--", "loginctl", "enable-linger", user])
_systemctl("daemon-reload")
service_touched = True
if boot_requested(state) is False:
_systemctl("start", unit.name)
else:
_systemctl("enable", "--now", unit.name)
except BaseException as error:
rollback_failed = False
if service_touched or unit in changed:
try:
rollback_failed = bool(_systemctl("disable", "--now", unit.name, check=False).returncode)
except Exception:
rollback_failed = True
for path, installed in reversed(list(changed.items())):
try:
current = _optional_read(path)
if current == originals[path]:
continue
if current != installed:
rollback_failed = True
continue
if originals[path] is None:
path.unlink()
else:
_write(path, originals[path], replace=True)
except Exception:
rollback_failed = True
try: try:
if originals[manual_stop] is not None and _optional_read(manual_stop) is None:
_write(manual_stop, originals[manual_stop])
_systemctl("daemon-reload")
if previous: if previous:
result = _systemctl("enable" if was_enabled else "disable", unit.name, check=False) service_touched = True
rollback_failed = rollback_failed or bool(result.returncode) _systemctl("stop", unit.name)
if was_active: disconnected = connection_background.disconnect(state)
restarted = _systemctl("start", unit.name, check=False) if disconnected.get("ok") is False or disconnected.get("alive"):
rollback_failed = rollback_failed or bool(restarted.returncode) raise RuntimeError("The previous connection could not be stopped safely; nothing was replaced.")
if not linger_before and _enabled_units() == units_before: manual_connection_stopped = not previous and background_before.get("alive") is True
restored_linger = _command(["sudo", "-n", "--", "loginctl", "disable-linger", user], check=False) if connection_background.status(state).get("alive"):
rollback_failed = rollback_failed or bool(restored_linger.returncode) raise RuntimeError("The previous connection is still stopping; nothing was replaced.")
except Exception: write(blob, encrypted)
rollback_failed = True write(unit, text)
if rollback_failed: write(config_path, config_bytes)
raise RuntimeError("Connection setup did not complete, and restoration could not be fully verified. Inspect fb autostart status before retrying.") from None if backup_bytes is not None and backup_bytes != originals[backup]:
if manual_connection_stopped: write(backup, backup_bytes)
raise RuntimeError("Connection setup did not complete. The previous manually started connection was stopped; reconnect it using its original key.") from None write(record_path, (json.dumps(record, sort_keys=True, indent=2) + "\n").encode())
raise write(intent, (json.dumps({"schema": 1, "connect": True, "revision": connection_request_revision(state) + 1}, sort_keys=True) + "\n").encode())
return _committed_connection_status(state) _command(["sudo", "-n", "--", "loginctl", "enable-linger", user])
_systemctl("daemon-reload")
service_touched = True
if boot_requested(state) is False:
_systemctl("start", unit.name)
else:
_systemctl("enable", "--now", unit.name)
except BaseException as error:
rollback_failed = False
if service_touched or unit in changed:
try:
rollback_failed = bool(_systemctl("disable", "--now", unit.name, check=False).returncode)
except Exception:
rollback_failed = True
for path, installed in reversed(list(changed.items())):
try:
current = _optional_read(path)
if current == originals[path]:
continue
if current != installed:
rollback_failed = True
continue
if originals[path] is None:
path.unlink()
else:
_write(path, originals[path], replace=True)
except Exception:
rollback_failed = True
try:
if originals[manual_stop] is not None and _optional_read(manual_stop) is None:
_write(manual_stop, originals[manual_stop])
_systemctl("daemon-reload")
if previous:
result = _systemctl("enable" if was_enabled else "disable", unit.name, check=False)
rollback_failed = rollback_failed or bool(result.returncode)
if was_active:
restarted = _systemctl("start", unit.name, check=False)
rollback_failed = rollback_failed or bool(restarted.returncode)
if not linger_before and _enabled_units() == units_before:
restored_linger = _command(["sudo", "-n", "--", "loginctl", "disable-linger", user], check=False)
rollback_failed = rollback_failed or bool(restored_linger.returncode)
except Exception:
rollback_failed = True
if rollback_failed:
raise RuntimeError("Connection setup did not complete, and restoration could not be fully verified. Inspect fb autostart status before retrying.") from None
if manual_connection_stopped:
raise RuntimeError("Connection setup did not complete. The previous manually started connection was stopped; reconnect it using its original key.") from None
raise
transaction["committed"] = True
_cleanup_retired_helpers(state, directory, record)
return _committed_connection_status(state)
def _enable(state, *, start_now=True, confirm=True): def _enable(state, *, start_now=True, confirm=True):
@@ -475,11 +677,8 @@ def _enable(state, *, start_now=True, confirm=True):
else: else:
_systemctl("enable", unit.name) _systemctl("enable", unit.name)
return status(state) return status(state)
version = _command(["systemd-creds", "--version"]).stdout.decode(errors="replace")
match = re.match(r"systemd (\d+)", version)
if not match or int(match.group(1)) < 256:
raise RuntimeError("Encrypted user credentials require systemd 256 or later.")
node = config["role"] == "node" node = config["role"] == "node"
backend = None if node else _credential_backend()
if not node and not config.get("tunnel_id"): if not node and not config.get("tunnel_id"):
raise RuntimeError("Save a tunnel with fb connect chatgpt before enabling automatic startup.") raise RuntimeError("Save a tunnel with fb connect chatgpt before enabling automatic startup.")
print("FrameBridge will start automatically when this computer starts.") print("FrameBridge will start automatically when this computer starts.")
@@ -494,9 +693,15 @@ def _enable(state, *, start_now=True, confirm=True):
_check_unit_directory(unit) _check_unit_directory(unit)
if unit.exists() or unit.is_symlink() or (directory / "credential.cred").exists() or (directory / "credential.cred").is_symlink(): if unit.exists() or unit.is_symlink() or (directory / "credential.cred").exists() or (directory / "credential.cred").is_symlink():
raise RuntimeError("Existing unowned service or encrypted credential; nothing was replaced.") raise RuntimeError("Existing unowned service or encrypted credential; nothing was replaced.")
_command(["sudo", "-v"], timeout=180)
user = pwd.getpwuid(os.getuid()).pw_name user = pwd.getpwuid(os.getuid()).pw_name
linger_before = _linger(user) linger_before = _linger(user)
helper_cleanup_required = (node and _helper_present() and
(not linger_before or _node_helper_cleanup_required(state)))
# An administrator may already have enabled this node owner's user
# services at boot. Installing its own unit then needs no new privilege.
# Central profiles still authenticate before protecting connection input.
if not node or not linger_before or helper_cleanup_required:
_command(["sudo", "-v"], timeout=180)
units_before = _enabled_units() units_before = _enabled_units()
encrypted = b"" encrypted = b""
print("[2/3] Preparing this node..." if node else "[2/3] Protecting the connection key...", flush=True) print("[2/3] Preparing this node..." if node else "[2/3] Protecting the connection key...", flush=True)
@@ -506,64 +711,80 @@ def _enable(state, *, start_now=True, confirm=True):
if not runtime_key or not runtime_key.isascii() or len(runtime_key) > 16384 or any(ord(ch) < 33 or ord(ch) > 126 for ch in runtime_key): if not runtime_key or not runtime_key.isascii() or len(runtime_key) > 16384 or any(ord(ch) < 33 or ord(ch) > 126 for ch in runtime_key):
raise ValueError("The runtime key must be nonempty and contain no whitespace or control characters.") raise ValueError("The runtime key must be nonempty and contain no whitespace or control characters.")
try: try:
encrypted = _command(["sudo", "-n", "--", "systemd-creds", "encrypt", "--uid=" + str(os.getuid()), "--with-key=host", "--name=" + CREDENTIAL, "-", "-"], data=runtime_key.encode()).stdout if backend == HOST_BACKEND or _helper_present():
_prune_helpers(state)
encrypted = _encrypt_credential(state, runtime_key, backend)
finally: finally:
runtime_key = "" runtime_key = ""
if len(encrypted) < 80 or len(encrypted) > 131072: if len(encrypted) < 80 or len(encrypted) > 131072:
raise RuntimeError("Credential encryption returned an invalid result.") raise RuntimeError("Credential encryption returned an invalid result.")
blob = directory / "credential.cred" elif helper_cleanup_required:
text = unit_text(state, blob, node=node).encode() _prune_helpers(state)
record = {"schema": 1, "unit": unit.name, "unit_sha256": hashlib.sha256(text).hexdigest(), "credential_sha256": hashlib.sha256(encrypted).hexdigest() if encrypted else "", "node": node, "linger_before": linger_before, "enabled_units_before": units_before, "headless_before": original_headless, "headless_changed": changed_headless, "config_before_sha256": hashlib.sha256(original_config).hexdigest()} with _credential_transaction(state, encrypted, backend) as transaction:
created = [] blob = directory / "credential.cred"
try: text = unit_text(state, blob, node=node).encode()
if encrypted: record = {"schema": 1, "unit": unit.name, "unit_sha256": hashlib.sha256(text).hexdigest(), "credential_sha256": hashlib.sha256(encrypted).hexdigest() if encrypted else "", "credential_backend": backend or USER_BACKEND, "node": node, "linger_before": linger_before, "enabled_units_before": units_before, "headless_before": original_headless, "headless_changed": changed_headless, "config_before_sha256": hashlib.sha256(original_config).hexdigest()}
_write(blob, encrypted); created.append(blob) created = []
_write(unit, text); created.append(unit) try:
if changed_headless: if encrypted:
if _read(state / "config.json") != original_config: _write(blob, encrypted); created.append(blob)
raise RuntimeError("The profile changed during configuration; no profile settings were overwritten.") _write(unit, text); created.append(unit)
_write(directory / "config-before.json", original_config) if changed_headless:
created.append(directory / "config-before.json") if _read(state / "config.json") != original_config:
config["headless"] = "auto" raise RuntimeError("The profile changed during configuration; no profile settings were overwritten.")
save_config(state, config) _write(directory / "config-before.json", original_config)
record["changed_config_sha256"] = hashlib.sha256(_read(state / "config.json")).hexdigest() created.append(directory / "config-before.json")
_json(directory / "installation.json", record); created.append(directory / "installation.json") config["headless"] = "auto"
print("[3/3] Enabling automatic startup...", flush=True) save_config(state, config)
_command(["sudo", "-n", "--", "loginctl", "enable-linger", user]) record["changed_config_sha256"] = hashlib.sha256(_read(state / "config.json")).hexdigest()
_systemctl("daemon-reload") _json(directory / "installation.json", record); created.append(directory / "installation.json")
if start_now: print("[3/3] Enabling automatic startup...", flush=True)
_systemctl("enable", "--now", unit.name) if not node or not linger_before:
else: _command(["sudo", "-n", "--", "loginctl", "enable-linger", user])
_systemctl("enable", unit.name) _systemctl("daemon-reload")
except BaseException: if start_now:
if unit in created: _systemctl("enable", "--now", unit.name)
_systemctl("disable", "--now", unit.name, check=False) else:
for path in reversed(created): _systemctl("enable", unit.name)
try: path.unlink() except BaseException:
except FileNotFoundError: pass if unit in created:
if changed_headless and record.get("changed_config_sha256") == hashlib.sha256(_read(state / "config.json")).hexdigest(): _systemctl("disable", "--now", unit.name, check=False)
_write(state / "config.json", original_config, replace=True) for path in reversed(created):
_systemctl("daemon-reload", check=False) try: path.unlink()
if not linger_before: except FileNotFoundError: pass
try: if changed_headless and record.get("changed_config_sha256") == hashlib.sha256(_read(state / "config.json")).hexdigest():
if _enabled_units() == units_before: _write(state / "config.json", original_config, replace=True)
_command(["sudo", "-n", "--", "loginctl", "disable-linger", user], check=False) _systemctl("daemon-reload", check=False)
except Exception: if not linger_before:
pass try:
raise if _enabled_units() == units_before:
return status(state) _command(["sudo", "-n", "--", "loginctl", "disable-linger", user], check=False)
except Exception:
pass
raise
transaction["committed"] = True
return status(state)
def _disable(state): def _disable(state):
state, directory, unit = _paths(state) state, directory, unit = _paths(state)
record = _manifest(directory) record = _manifest(directory)
if not record: if not record:
return {"configured": False, "state": "already_disabled"} if _helper_present():
# A killed first provisioning process can publish a root grant
# before writing any owner manifest. Explicit disable still
# reconciles that profile under ordinary administrative approval.
_helper_ready()
_command(["sudo", "-v"], timeout=180)
_prune_helpers(state)
return {"configured": False, "state": "already_disabled", "orphan_credential_cleanup_verified": True}
return {"configured": False, "state": "already_disabled", "orphan_credential_cleanup_verified": False}
if record.get("unit") != unit.name or hashlib.sha256(_read(unit)).hexdigest() != record.get("unit_sha256"): if record.get("unit") != unit.name or hashlib.sha256(_read(unit)).hexdigest() != record.get("unit_sha256"):
raise RuntimeError("The service changed outside FrameBridge; refusing to remove it.") raise RuntimeError("The service changed outside FrameBridge; refusing to remove it.")
blob = directory / "credential.cred" blob = directory / "credential.cred"
if not record.get("node") and hashlib.sha256(_read(blob)).hexdigest() != record.get("credential_sha256"): if not record.get("node") and hashlib.sha256(_read(blob)).hexdigest() != record.get("credential_sha256"):
raise RuntimeError("The encrypted credential changed outside FrameBridge; refusing to remove it.") raise RuntimeError("The encrypted credential changed outside FrameBridge; refusing to remove it.")
retired = _retired_helpers(record)
backup = None backup = None
if record.get("headless_changed"): if record.get("headless_changed"):
backup = _read(directory / "config-before.json") backup = _read(directory / "config-before.json")
@@ -576,7 +797,14 @@ def _disable(state):
restore_linger = not record.get("linger_before") and [name for name in _enabled_units() if name != unit.name] == record.get("enabled_units_before") restore_linger = not record.get("linger_before") and [name for name in _enabled_units() if name != unit.name] == record.get("enabled_units_before")
if restore_linger: if restore_linger:
_command(["sudo", "-v"], timeout=180) _command(["sudo", "-v"], timeout=180)
helper_credentials = _helper_present() or (not record.get("node") and (_credential_backend(record) == HOST_BACKEND or retired))
if helper_credentials:
_command(["sudo", "-v"], timeout=180)
_systemctl("disable", "--now", unit.name) _systemctl("disable", "--now", unit.name)
if helper_credentials:
# Include grants whose receipt was lost to SIGKILL/power loss during a
# previous provisioning operation, not only digests in the manifest.
_prune_helpers(state)
unit.unlink() unit.unlink()
if not record.get("node"): if not record.get("node"):
blob.unlink() blob.unlink()
@@ -609,7 +837,13 @@ def _credential(state):
if hashlib.sha256(cipher).hexdigest() != record.get("credential_sha256"): if hashlib.sha256(cipher).hexdigest() != record.get("credential_sha256"):
raise RuntimeError("The encrypted runtime credential changed outside FrameBridge.") raise RuntimeError("The encrypted runtime credential changed outside FrameBridge.")
resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
result = _command(["systemd-creds", "decrypt", "--user", "--name=" + CREDENTIAL, "-", "-"], data=cipher, timeout=15) if _credential_backend(record) == HOST_BACKEND:
_helper_ready()
command = [str(HELPER), "decrypt", _profile_id(state), record["credential_sha256"]]
_require_no_sudo_io_logging(command)
result = _command(["sudo", "-n", "--", *command], timeout=15)
else:
result = _command(["systemd-creds", "decrypt", "--user", "--name=" + CREDENTIAL, "-", "-"], data=cipher, timeout=15)
try: try:
key = result.stdout.decode("ascii") key = result.stdout.decode("ascii")
except UnicodeDecodeError: except UnicodeDecodeError:
@@ -759,7 +993,17 @@ def _connection_action(state, action):
def _worker_status(directory, name): def _worker_status(directory, name):
_json(directory / "worker-status.json", {"state": name, "updated_at": time.time()}, replace=True) path = directory / "worker-status.json"
try:
previous = json.loads(_read(path))
except (FileNotFoundError, ValueError):
previous = None
# This is a transition timestamp, not a liveness heartbeat. systemd and the
# owned process identity report liveness separately. Avoid a durable fsync
# every two seconds while the worker remains in the same state.
if isinstance(previous, dict) and previous.get("state") == name:
return
_json(path, {"state": name, "updated_at": time.time()}, replace=True)
def worker(state): def worker(state):
+15 -9
View File
@@ -28,6 +28,8 @@ class BrokerError(ValueError):
"""A fixed public failure code; never include HTTP bodies or credentials.""" """A fixed public failure code; never include HTTP bodies or credentials."""
_SETTINGS_LOCK = threading.RLock()
_state = None _state = None
_url = None _url = None
_control_path = None _control_path = None
@@ -80,18 +82,22 @@ def validate_endpoint(url):
def configure(broker_url=None, state_dir=None, control_key_path=None): def configure(broker_url=None, state_dir=None, control_key_path=None):
global _state, _url, _control_path global _state, _url, _control_path
_state = state_path(state_dir) # Workers must see one fully validated profile, including explicit changes.
config = _config(_state) with _SETTINGS_LOCK:
port = config.get("ports", {}).get("broker", 20878) state = state_path(state_dir)
_url = validate_endpoint(broker_url or config.get("broker_control_url") or config.get("broker_url") config = _config(state)
or f"http://127.0.0.1:{port}") port = config.get("ports", {}).get("broker", 20878)
_control_path = Path(control_key_path or _state / "broker" / "control.key") url = validate_endpoint(broker_url or config.get("broker_control_url") or config.get("broker_url")
or f"http://127.0.0.1:{port}")
control_path = Path(control_key_path or state / "broker" / "control.key")
_state, _url, _control_path = state, url, control_path
def _settings(): def _settings():
if _state is None: with _SETTINGS_LOCK:
configure() if _state is None:
return _state, _url, _control_path configure()
return _state, _url, _control_path
def _private_read(path): def _private_read(path):
+40 -6
View File
@@ -13,7 +13,7 @@ import sys
import shlex import shlex
from .config import default_config, load_config, save_config from .config import default_config, load_config, save_config
from . import tunnel from . import tunnel, __version__
_EMAIL = re.compile(r"[^\s@]+@[^\s@]+\.[^\s@]+\Z") _EMAIL = re.compile(r"[^\s@]+@[^\s@]+\.[^\s@]+\Z")
@@ -317,7 +317,7 @@ _OUTPUT = None
def _structured_result(value): def _structured_result(value):
"""Versioned envelope used only when --json was explicitly requested.""" """Versioned envelope used only when --json was explicitly requested."""
return {"schema_version": 1, "product": "FrameBridge", "version": "0.3.1", return {"schema_version": 1, "product": "FrameBridge", "version": __version__,
"command": _OUTPUT.get("command") if _OUTPUT else None, "command": _OUTPUT.get("command") if _OUTPUT else None,
"ok": not isinstance(value, dict) or value.get("ok") is not False, "ok": not isinstance(value, dict) or value.get("ok") is not False,
"result": value} "result": value}
@@ -394,7 +394,7 @@ def _human_status(value, scope=None):
connection = connection if isinstance(connection, dict) else {} connection = connection if isinstance(connection, dict) else {}
boot = value.get("boot", {}) boot = value.get("boot", {})
boot = boot if isinstance(boot, dict) else {} boot = boot if isinstance(boot, dict) else {}
print("FrameBridge 0.3.1") print("FrameBridge " + __version__)
print("Role: " + str(runtime.get("role", "unavailable"))) print("Role: " + str(runtime.get("role", "unavailable")))
print("Local tools: " + _runtime_label(runtime)) print("Local tools: " + _runtime_label(runtime))
if runtime.get("role") == "node": if runtime.get("role") == "node":
@@ -430,16 +430,39 @@ def _doctor_command(args, state):
_show(value) _show(value)
else: else:
print("Diagnostics: " + ("passed" if value.get("ok") else "needs attention")) print("Diagnostics: " + ("passed" if value.get("ok") else "needs attention"))
print("Debian 13 amd64: " + ("compatible" if value.get("compatible_debian_13_amd64") else "not verified compatible")) compatibility = value.get("platform_compatibility") or {}
print("Platform: " + ("compatible" if compatibility.get("ok") else "not verified compatible"))
if compatibility.get("profile_id"):
print("Build profile: " + str(compatibility["profile_id"]))
for issue in compatibility.get("issues", []):
print("Platform issue: " + str(issue))
print("Desktop: " + str(value.get("display", "unavailable"))) print("Desktop: " + str(value.get("display", "unavailable")))
for item in value.get("missing_commands", []): for item in value.get("missing_commands", []):
print("Missing command: " + str(item)) print("Missing command: " + str(item))
for item in value.get("issues", []): for item in value.get("issues", []):
print("Issue: " + str(item)) print("Issue: " + str(item))
virtual_missing = value.get("optional_missing_commands", {}).get("private_desktop", [])
if virtual_missing:
print("Optional private desktop tools missing: " + ", ".join(virtual_missing))
hint = value.get("optional_install_commands", {}).get("private_desktop")
if hint:
print("Install only for an explicit virtual desktop: " + hint)
print("This checks prerequisites; use fb status to inspect the current connection.") print("This checks prerequisites; use fb status to inspect the current connection.")
return _result_code(value) return _result_code(value)
def _health_command(args, state):
health = _module("health")
value = health.collect(state)
if args.json:
_show(value)
else:
print(health.render(value))
# Query success and operational health are deliberately separate. An
# intentional pause can be healthy; missing/unknown state cannot pass.
return 0 if value.get("ok") is True and value.get("health", {}).get("healthy") is True else 1
def _service_command(args, state): def _service_command(args, state):
action = args.service_action action = args.service_action
lifecycle = _module("lifecycle") lifecycle = _module("lifecycle")
@@ -529,6 +552,7 @@ def build_parser():
status = sub.add_parser("status", help="Observe local tools, ChatGPT connection and startup policy without changing them.") status = sub.add_parser("status", help="Observe local tools, ChatGPT connection and startup policy without changing them.")
status.add_argument("scope", nargs="?", choices=("connection", "runtime", "boot", "console")) status.add_argument("scope", nargs="?", choices=("connection", "runtime", "boot", "console"))
sub.add_parser("doctor", help="Diagnose prerequisites and problems; does not repair or connect.") sub.add_parser("doctor", help="Diagnose prerequisites and problems; does not repair or connect.")
sub.add_parser("health", help="Read one local profile's health, identity and prerequisites; no repair or connection.")
sub.add_parser("setup", help="Optional advanced role/profile setup; does not start services or connect.") sub.add_parser("setup", help="Optional advanced role/profile setup; does not start services or connect.")
help_parser = sub.add_parser("help", help="Read the installed, versioned English guide.") help_parser = sub.add_parser("help", help="Read the installed, versioned English guide.")
help_parser.add_argument("topic", nargs="?", default="start", help="Guide topic; use all for the complete reference.") help_parser.add_argument("topic", nargs="?", default="start", help="Guide topic; use all for the complete reference.")
@@ -581,6 +605,8 @@ def build_parser():
con_sub = console.add_subparsers(dest="console_command") con_sub = console.add_subparsers(dest="console_command")
login = con_sub.add_parser("login", help="Optional work-view opening after authenticated tty1 login; not service startup.") login = con_sub.add_parser("login", help="Optional work-view opening after authenticated tty1 login; not service startup.")
login.add_argument("console_login_action", choices=("enable", "status", "disable")) login.add_argument("console_login_action", choices=("enable", "status", "disable"))
desktop = con_sub.add_parser("desktop", help="Migrate a recognized legacy desktop console into this package.")
desktop.add_argument("console_desktop_action", choices=("migrate-legacy",))
tty = sub.add_parser("tty", help="Manage persistent Bash sessions; persistence is between calls, not reboots.") tty = sub.add_parser("tty", help="Manage persistent Bash sessions; persistence is between calls, not reboots.")
ts = tty.add_subparsers(dest="tty_command", required=True, metavar="ACTION") ts = tty.add_subparsers(dest="tty_command", required=True, metavar="ACTION")
tty_service = ts.add_parser("service", help="Manage the terminal daemon.") tty_service = ts.add_parser("service", help="Manage the terminal daemon.")
@@ -652,14 +678,14 @@ def _dispatch(parser, args):
return _help_command(argparse.Namespace(command="help", topic="start", json=True, non_interactive=True)) return _help_command(argparse.Namespace(command="help", topic="start", json=True, non_interactive=True))
parser.print_help() parser.print_help()
return 0 return 0
print("FrameBridge\n1. Connect\n2. Status\n3. Doctor\n4. Open console\n5. Help\n6. Advanced setup\n0. Exit") print("FrameBridge\n1. Connect\n2. Status\n3. Doctor\n4. Open console\n5. Help\n6. Advanced setup\n7. Health summary\n0. Exit")
try: try:
choice = input("Choose an action: ").strip() choice = input("Choose an action: ").strip()
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):
print("\nCancelled.", file=sys.stderr) print("\nCancelled.", file=sys.stderr)
return 130 return 130
action = {"1":["connect"], "2":["status"], "3":["doctor"], "4":["console"], action = {"1":["connect"], "2":["status"], "3":["doctor"], "4":["console"],
"5":["help"], "6":["setup"]}.get(choice) "5":["help"], "6":["setup"], "7":["health"]}.get(choice)
if choice == "0": if choice == "0":
return 0 return 0
if action is None: if action is None:
@@ -699,6 +725,8 @@ def _dispatch(parser, args):
return _status_command(args, state) return _status_command(args, state)
if args.command == "doctor": if args.command == "doctor":
return _doctor_command(args, state) return _doctor_command(args, state)
if args.command == "health":
return _health_command(args, state)
if args.command == "service": if args.command == "service":
return _service_command(args, state) return _service_command(args, state)
if args.command == "connection": if args.command == "connection":
@@ -753,6 +781,12 @@ def _dispatch(parser, args):
return _interaction_required("fb nodes run") return _interaction_required("fb nodes run")
return nodes_command(args, state) return nodes_command(args, state)
if args.command == "console": if args.command == "console":
if getattr(args, "console_desktop_action", None):
if args.node != "LOCAL" or args.status or args.login_action:
raise ValueError("Desktop console migration must target the local profile alone.")
result = _module("console_desktop").migrate_legacy(state)
_show(result)
return _result_code(result)
action = getattr(args, "console_login_action", None) or args.login_action action = getattr(args, "console_login_action", None) or args.login_action
if getattr(args, "console_login_action", None) and args.login_action: if getattr(args, "console_login_action", None) and args.login_action:
raise ValueError("Choose one console login command form.") raise ValueError("Choose one console login command form.")
@@ -0,0 +1,147 @@
"""Explicit candidate Linux profiles and installed-artifact ABI checks.
Eligibility is not evidence of a successful installation on a target system.
Never import vendored native code merely to diagnose an incompatible artifact.
"""
from __future__ import annotations
import json
import os
import platform
import re
import sys
from pathlib import Path
PROFILES = {
"debian13-amd64-py313": ("debian", "13", "3.13", "cp313"),
"ubuntu24.04-amd64-py312": ("ubuntu", "24.04", "3.12", "cp312"),
"ubuntu26.04-amd64-py314": ("ubuntu", "26.04", "3.14", "cp314"),
"mint22-amd64-py312": ("linuxmint", "22", "3.12", "cp312"),
"lmde7-amd64-py313": ("linuxmint", "7", "3.13", "cp313"),
}
SYSTEM_PYTHONS = frozenset({"/usr/bin/python3", "/usr/bin/python3.12",
"/usr/bin/python3.13", "/usr/bin/python3.14"})
INSTALLED_PREFIX = Path("/usr/lib/framebridge-expanded")
VENDOR_RELATIVE = Path("original/geranas/FrameBridge-Fleet-Hub/vendor")
def profile_for(os_release):
"""Match named releases only; ID_LIKE never opts other derivatives in."""
distro = os_release.get("ID", "")
version = os_release.get("VERSION_ID", "")
for name, (expected, release, _minor, _abi) in PROFILES.items():
matches = version == release
if name == "mint22-amd64-py312":
matches = bool(re.fullmatch(r"22(?:\.\d+)?", version))
if distro == expected and matches:
return name
return None
def _libc():
try:
value = os.confstr("CS_GNU_LIBC_VERSION") or ""
if value.startswith("glibc "):
return "glibc", value.split()[-1]
except (OSError, ValueError):
pass
return platform.libc_ver()
def _vendor_issues(prefix, abi):
vendor = prefix / VENDOR_RELATIVE
issues = []
expected = abi.removeprefix("cp")
if not vendor.is_dir():
return ["Bundled vendor directory is missing; use a complete artifact for this profile."]
required = {
"_cffi_backend": list(vendor.glob("_cffi_backend.cpython-*.so")),
"pydantic_core": list((vendor / "pydantic_core").glob("_pydantic_core.cpython-*.so")),
"rpds": list((vendor / "rpds").glob("rpds.cpython-*.so")),
}
for module, files in required.items():
if len(files) != 1:
issues.append("Expected exactly one bundled native extension for " + module + ".")
for path in vendor.rglob("*.so"):
match = re.search(r"\.cpython-([^-]+)-", path.name)
if match and (match.group(1) != expected or "x86_64-linux-gnu" not in path.name):
issues.append("Bundled native extension does not match " + abi + " amd64: " + str(path.relative_to(vendor)))
return issues
def assess(prefix, *, os_release=None, machine=None, python_version=None, libc=None, implementation=None, abi_flags=None):
"""Check profile and artifact identity without importing native modules."""
prefix = Path(prefix)
issues = []
warnings = []
if os_release is None:
try:
os_release = platform.freedesktop_os_release()
except OSError:
os_release = {}
machine = platform.machine() if machine is None else machine
version = sys.version_info[:2] if python_version is None else python_version[:2]
minor = ".".join(str(x) for x in version)
abi = "cp" + "".join(str(x) for x in version)
implementation = platform.python_implementation() if implementation is None else implementation
abi_flags = getattr(sys, "abiflags", "") if abi_flags is None else abi_flags
if implementation != "CPython" or abi_flags:
issues.append("This artifact requires standard CPython; alternate, debug or free-threaded interpreter ABIs need separate native artifacts.")
selected = profile_for(os_release)
if selected is None:
issues.append("No candidate profile for this release. Choose Debian 13, Ubuntu 24.04/26.04, Linux Mint 22.x or LMDE 7.")
if machine not in {"x86_64", "amd64"}:
issues.append("This artifact supports amd64 only; ARM and other architectures need separate artifacts.")
if selected and minor != PROFILES[selected][2]:
issues.append("Profile " + selected + " requires Python " + PROFILES[selected][2] + "; running " + minor + ". Install its matching artifact without replacing the system Python.")
libc_name, libc_version = _libc() if libc is None else libc
match = re.match(r"(\d+)\.(\d+)", libc_version)
if libc_name != "glibc" or not match or tuple(map(int, match.groups())) < (2, 34):
issues.append("Bundled native libraries require glibc >= 2.34; this libc is not eligible.")
manifest = None
manifest_path = prefix / "release-profile.json"
if manifest_path.exists():
try:
if manifest_path.stat().st_size > 16384:
raise ValueError("manifest too large")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if not isinstance(manifest, dict) or type(manifest.get("schema")) is not int or manifest["schema"] != 1:
raise ValueError("unsupported schema")
if manifest.get("profile_id") not in PROFILES:
raise ValueError("unknown profile")
expected = PROFILES[manifest["profile_id"]]
if (manifest.get("architecture"), manifest.get("python_minor"), manifest.get("python_abi")) != ("amd64", expected[2], expected[3]):
raise ValueError("inconsistent profile ABI")
if manifest.get("validation_status") not in {"candidate", "release_build", "validated"}:
raise ValueError("missing validation status")
if manifest["profile_id"] != selected or manifest["python_minor"] != minor or manifest["python_abi"] != abi:
issues.append("Installed artifact profile/ABI does not match this operating system and interpreter.")
except (OSError, UnicodeError, ValueError, TypeError):
issues.append("Invalid release-profile.json; install a complete matching artifact.")
manifest = None
else:
# Source-tree fallback is intentionally the reviewed release's ABI only.
if prefix.resolve() == INSTALLED_PREFIX or selected != "debian13-amd64-py313" or minor != "3.13":
issues.append("Missing release-profile.json; this target requires a profile-specific artifact.")
else:
warnings.append("Source checkout has no artifact manifest; only the legacy Debian 13/cp313 profile is eligible.")
issues.extend(_vendor_issues(prefix, abi))
validation = manifest.get("validation_status") if manifest else "source-unvalidated"
if validation == "release_build":
warnings.append("Release assembly does not establish a tested installation on this target; consult the external release acceptance record.")
elif validation != "validated":
warnings.append("Candidate compatibility checks do not establish a tested installation on this target.")
return {"ok": not issues, "profile_id": selected, "artifact_profile_id": manifest.get("profile_id") if manifest else None,
"architecture": machine, "python_minor": minor, "python_abi": abi, "python_implementation": implementation, "python_abi_flags": abi_flags,
"libc": {"name": libc_name, "version": libc_version}, "validation_status": validation,
"issues": issues, "warnings": warnings}
def display_support(env, context):
wayland = env.get("XDG_SESSION_TYPE", "").lower() == "wayland" or bool(env.get("WAYLAND_DISPLAY"))
notes = []
if wayland:
notes.append("Native Wayland desktop capture/control is not implemented. XWayland does not establish access to the full Wayland desktop; text tools remain available, or explicitly select a private virtual X11 display.")
return {"session_type": "wayland" if wayland else env.get("XDG_SESSION_TYPE", "unknown"),
"physical_x11_verified": context.get("kind") == "physical-x11",
"private_virtual_x11": context.get("kind") == "virtual-x11",
"native_wayland_cua": False, "notes": notes}
@@ -1,6 +1,7 @@
"""Profile-scoped detached ChatGPT supervision without a persistent credential.""" """Profile-scoped detached ChatGPT supervision without a persistent credential."""
from __future__ import annotations from __future__ import annotations
import fcntl import fcntl
import hashlib
import json import json
import os import os
import resource import resource
@@ -17,6 +18,10 @@ from .config import private_dir, state_path
_BOOTSTRAP = "import sys;sys.path.insert(0,sys.argv[1]);from framebridge_expanded.connection_background import worker_main;worker_main(sys.argv[2],int(sys.argv[3]))" _BOOTSTRAP = "import sys;sys.path.insert(0,sys.argv[1]);from framebridge_expanded.connection_background import worker_main;worker_main(sys.argv[2],int(sys.argv[3]))"
_PACKAGE_PARENT = str(Path(__file__).resolve().parents[1]) _PACKAGE_PARENT = str(Path(__file__).resolve().parents[1])
_STATES = {"starting", "checking", "access", "ready", "retrying", "rejected", "unverified", "error", "stopped", "closed"} _STATES = {"starting", "checking", "access", "ready", "retrying", "rejected", "unverified", "error", "stopped", "closed"}
_RUNTIME_PARENT = Path("/run/user")
_HEARTBEAT_BACKEND = "user-runtime-v1"
_IDENTITY_FIELDS = ("pid", "uid", "start_ticks", "boot_id", "session_id", "process_group", "state_dir", "lock_fd")
_DURABLE_HEARTBEAT_INTERVAL = 5.0
_MESSAGES = { _MESSAGES = {
"starting": "[1/3] Starting the background tunnel client.", "starting": "[1/3] Starting the background tunnel client.",
"checking": "[2/3] Verifying the local MCP service.", "checking": "[2/3] Verifying the local MCP service.",
@@ -59,6 +64,40 @@ def _checked_fd(path, flags, create=False):
raise raise
def _heartbeat_directory(state, create=False):
"""Keep disposable liveness updates out of the durable profile filesystem.
Do not create a login runtime directory, follow symlinks, change existing
permissions, or trust an arbitrary XDG_RUNTIME_DIR supplied by a caller.
Hosts without a user runtime directory retain bounded durable heartbeats.
"""
root = _RUNTIME_PARENT / str(os.getuid())
for parent in (_RUNTIME_PARENT.parent, _RUNTIME_PARENT):
try:
parent_info = parent.lstat()
except FileNotFoundError:
return None
if not stat.S_ISDIR(parent_info.st_mode) or parent_info.st_uid not in (0, os.getuid()) or stat.S_IMODE(parent_info.st_mode) & 0o022:
raise RuntimeError("The user runtime parent directory is unsafe.")
try:
info = root.lstat()
except FileNotFoundError:
return None
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077:
raise RuntimeError("The user runtime directory is not private and owned by this user.")
name = "framebridge-connection-" + hashlib.sha256(str(state_path(state)).encode()).hexdigest()
directory = root / name
if create:
return private_dir(directory)
try:
info = directory.lstat()
except FileNotFoundError:
return None
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077:
raise RuntimeError("The connection heartbeat directory is not private and owned by this user.")
return directory
def _read(path): def _read(path):
try: try:
fd = _checked_fd(path, os.O_RDONLY) fd = _checked_fd(path, os.O_RDONLY)
@@ -146,6 +185,11 @@ def status(state):
if not alive and phase not in {"error", "closed", "stopped"}: if not alive and phase not in {"error", "closed", "stopped"}:
phase = "stopped" phase = "stopped"
checked = record.get("verified_at") checked = record.get("verified_at")
if record.get("heartbeat_backend") == _HEARTBEAT_BACKEND:
heartbeat_directory = _heartbeat_directory(state)
heartbeat = _read(heartbeat_directory / "status.json") if heartbeat_directory else {}
same_worker = all(heartbeat.get(key) == record.get(key) for key in _IDENTITY_FIELDS)
checked = heartbeat.get("verified_at") if same_worker and heartbeat.get("state") == phase else None
verified = alive and phase == "ready" and type(checked) in (int, float) and 0 <= time.time() - checked <= 15 verified = alive and phase == "ready" and type(checked) in (int, float) and 0 <= time.time() - checked <= 15
if alive and phase == "ready" and not verified: if alive and phase == "ready" and not verified:
phase = "retrying" phase = "retrying"
@@ -185,9 +229,14 @@ def disconnect(state, *, timeout=16):
class _StatusOutput: class _StatusOutput:
"""Progress destination owned by the worker, with no parent pipe or terminal.""" """Progress destination owned by the worker, with no parent pipe or terminal."""
def __init__(self, path, identity): def __init__(self, path, identity, heartbeat_path=None):
self.path = path self.path = path
self.record = dict(identity) self.record = dict(identity)
self.heartbeat_path = heartbeat_path
if heartbeat_path is not None:
self.record["heartbeat_backend"] = _HEARTBEAT_BACKEND
self.last_observed = None
self.last_durable_write = 0.0
self.pending = "" self.pending = ""
self.change("starting", "[1/3] Starting the background tunnel client.") self.change("starting", "[1/3] Starting the background tunnel client.")
@@ -198,13 +247,25 @@ class _StatusOutput:
pass pass
def change(self, state, message): def change(self, state, message):
if self.record.get("state") == state and self.record.get("message") == message:
return
self.record.update(state=state, message=message, updated_at=time.time()) self.record.update(state=state, message=message, updated_at=time.time())
_write(self.path, self.record) _write(self.path, self.record)
self.last_durable_write = time.monotonic()
def observe(self, state): def observe(self, state):
self.record["checked_at"] = time.time() self.record["checked_at"] = time.time()
self.record["verified_at"] = time.time() if state == "ready" else None self.record["verified_at"] = time.time() if state == "ready" else None
_write(self.path, self.record) if self.heartbeat_path is not None:
heartbeat = {key: self.record[key] for key in _IDENTITY_FIELDS}
heartbeat.update(state=state, checked_at=self.record["checked_at"], verified_at=self.record["verified_at"])
_write(self.heartbeat_path, heartbeat)
elif state != self.last_observed or time.monotonic() - self.last_durable_write >= _DURABLE_HEARTBEAT_INTERVAL:
# Legacy/manual sessions can run without systemd's user runtime
# directory. Preserve the 15-second freshness contract there too.
_write(self.path, self.record)
self.last_durable_write = time.monotonic()
self.last_observed = state
def write(self, text): def write(self, text):
self.pending += text self.pending += text
@@ -243,7 +304,9 @@ def worker_main(state, lock_fd):
return return
if not isinstance(command, list) or not command or any(not isinstance(v, str) or "\0" in v for v in command): if not isinstance(command, list) or not command or any(not isinstance(v, str) or "\0" in v for v in command):
return return
sink = _StatusOutput(directory / "status.json", _identity(os.getpid(), state, lock_fd)) heartbeat_directory = _heartbeat_directory(state, create=True)
heartbeat_path = heartbeat_directory / "status.json" if heartbeat_directory else None
sink = _StatusOutput(directory / "status.json", _identity(os.getpid(), state, lock_fd), heartbeat_path)
from .tunnel import run_foreground from .tunnel import run_foreground
rc = run_foreground(command, credential, state=state, output=sink, observe=sink.observe) rc = run_foreground(command, credential, state=state, output=sink, observe=sink.observe)
except BaseException: except BaseException:
@@ -49,8 +49,10 @@ def context(state, node="LOCAL", *, create=False):
label = _node(state, node) label = _node(state, node)
key = hashlib.sha256(str(state).encode()).hexdigest()[:20] key = hashlib.sha256(str(state).encode()).hexdigest()[:20]
runtime = _private_runtime(Path("/run/user") / str(os.getuid())) runtime = _private_runtime(Path("/run/user") / str(os.getuid()))
root = runtime / "framebridge-expanded-console" from .console_desktop import legacy_context
profile = root / key legacy = legacy_context(state, label, runtime)
root = legacy["socket"].parent if legacy else runtime / "framebridge-expanded-console"
profile = root if legacy else root / key
if create: if create:
private_dir(state) private_dir(state)
for path in (root, profile): for path in (root, profile):
@@ -63,7 +65,7 @@ def context(state, node="LOCAL", *, create=False):
return { return {
"state": state, "node": label, "runtime": runtime, "state": state, "node": label, "runtime": runtime,
"socket": profile / "console.sock", "socket": profile / "console.sock",
"session": "FB-" + label + "-" + key[:10], "session": legacy["session"] if legacy else "FB-" + label + "-" + key[:10],
"jobs_root": state / "home/.local/share/framebridge/fbterm", "jobs_root": state / "home/.local/share/framebridge/fbterm",
} }
@@ -75,10 +77,20 @@ def _active_graphical_display(env=None):
env = os.environ if env is None else env env = os.environ if env is None else env
display = env.get("DISPLAY", "") display = env.get("DISPLAY", "")
sid = env.get("XDG_SESSION_ID", "") sid = env.get("XDG_SESSION_ID", "")
if (re.fullmatch(r":[0-9]+(?:\.[0-9]+)?", display) is None if re.fullmatch(r":[0-9]+(?:\.[0-9]+)?", display) is None:
or re.fullmatch(r"[A-Za-z0-9_-]{1,80}", sid) is None):
return False return False
try: try:
# Desktop launchers started through the user bus may omit XDG_SESSION_ID.
# Ask logind for this user's display session and validate every field below.
if not sid:
session = subprocess.run([
"loginctl", "show-user", str(os.getuid()), "--property=Display", "--value",
], capture_output=True, text=True, timeout=5)
if session.returncode:
return False
sid = session.stdout.strip()
if re.fullmatch(r"[A-Za-z0-9_-]{1,80}", sid) is None:
return False
result = subprocess.run([ result = subprocess.run([
"loginctl", "show-session", sid, "--no-pager", "loginctl", "show-session", sid, "--no-pager",
"-p", "User", "-p", "Type", "-p", "Active", "-p", "Remote", "-p", "User", "-p", "Type", "-p", "Active", "-p", "Remote",
@@ -142,7 +154,7 @@ def status(state, node="LOCAL"):
try: try:
result = subprocess.run([ result = subprocess.run([
"/usr/bin/tmux", "-S", str(sock), "list-panes", "/usr/bin/tmux", "-S", str(sock), "list-panes",
"-t", "=" + ctx["session"], "-t", "=" + ctx["session"] + ":Console",
"-F", "#{pane_id}|#{pane_pid}|#{pane_current_command}|#{pane_width}|#{pane_height}|#{@framebridge_role}", "-F", "#{pane_id}|#{pane_pid}|#{pane_current_command}|#{pane_width}|#{pane_height}|#{@framebridge_role}",
], capture_output=True, text=True, timeout=5) ], capture_output=True, text=True, timeout=5)
except (OSError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError):
@@ -230,7 +242,8 @@ def run(state, node="LOCAL", *, inside=False, mode="auto"):
try: try:
result = subprocess.run([ result = subprocess.run([
sys.executable, str(ui), "--node", ctx["node"], sys.executable, str(ui), "--node", ctx["node"],
"--socket", str(ctx["socket"]), "--session", ctx["session"] , "--socket", str(ctx["socket"]), "--session", ctx["session"],
"--jobs-root", str(ctx["jobs_root"]),
"--layout", "horizontal" if inside else "vertical", "--apply", "--layout", "horizontal" if inside else "vertical", "--apply",
], env=env, capture_output=True, text=True, timeout=20) ], env=env, capture_output=True, text=True, timeout=20)
except (OSError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError):
@@ -0,0 +1,207 @@
"""Migrate the recognized pre-package desktop console without replacing its Bash."""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import pwd
import re
import stat
import subprocess
import tempfile
RECORD = "console-desktop.json"
LEGACY_WRAPPER_SHA256 = "71a6436d7411606312b009aee62a6d736a0d31a31194abb445f600740d8174f5"
def _need(value, error):
if not value:
raise ValueError(error)
def _owned(path, *, directory=False):
path = Path(path)
for parent in [path, *path.parents]:
_need(not parent.is_symlink(), "CONSOLE_DESKTOP_SYMLINK_REFUSED")
info = path.lstat()
kind = stat.S_ISDIR if directory else stat.S_ISREG
_need(kind(info.st_mode) and info.st_uid == os.getuid() and not info.st_mode & 0o022,
"CONSOLE_DESKTOP_PATH_NOT_OWNED")
return info
def _snapshot(path):
_owned(path)
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, "rb") as stream:
info = os.fstat(stream.fileno())
_need(stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid()
and not info.st_mode & 0o022 and info.st_size <= 32768,
"CONSOLE_DESKTOP_FILE_UNTRUSTED")
return stream.read(), stat.S_IMODE(info.st_mode)
def _atomic(path, data, mode=0o600):
fd, temporary = tempfile.mkstemp(prefix=".fb-desktop-", dir=path.parent)
try:
with os.fdopen(fd, "wb") as stream:
os.fchmod(stream.fileno(), mode)
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
def _legacy_entry(home):
return ("[Desktop Entry]\nVersion=1.0\nType=Application\nName=FrameBridge Console\n"
"Comment=Open the persistent local Bash and FBTERM activity console\n"
"Exec=" + str(home / ".local/bin/framebridge-console") + "\n"
"Icon=utilities-terminal\nTerminal=false\nCategories=System;TerminalEmulator;\n"
"StartupWMClass=Gnome-terminal\n").encode()
def _new_entry(state):
# Desktop Entry Exec quoting, without a shell or environment expansion.
_need(not any(ord(char) < 32 or ord(char) == 127 for char in str(state)),
"CONSOLE_DESKTOP_STATE_PATH_INVALID")
quoted = str(state).replace("%", "%%").replace("\\", "\\\\").replace('"', '\\"').replace("`", "\\`").replace("$", "\\$")
return ("[Desktop Entry]\nVersion=1.0\nType=Application\nName=FrameBridge Console\n"
"Comment=Open the persistent local Bash and FBTERM activity console\n"
'Exec=/usr/bin/fb --state-dir "' + quoted + '" console\n'
"Icon=utilities-terminal\nTerminal=false\nCategories=System;TerminalEmulator;\n"
"StartupWMClass=Gnome-terminal\n").encode()
def _new_wrapper(state):
command = ["/usr/bin/fb", "--state-dir", str(state), "console"]
return ("#!/usr/bin/python3\n"
'"""Open the packaged FrameBridge console using the retained local profile."""\n'
"import os\nimport sys\n"
"command = " + repr(command) + "\n"
"if sys.argv[1:] == ['--inside']:\n"
" command += ['--inside', '--mode', 'gui']\n"
"elif sys.argv[1:]:\n"
" raise SystemExit('Usage: framebridge-console')\n"
"os.execv(command[0], command)\n").encode()
def _migrate_wrapper(state, home):
path = home / ".local/bin/framebridge-console"
before, mode = _snapshot(path)
after = _new_wrapper(state)
if before == after:
return False
_need(hashlib.sha256(before).hexdigest() == LEGACY_WRAPPER_SHA256,
"CONSOLE_DESKTOP_LEGACY_WRAPPER_CHANGED")
backup = state / "console-wrapper-legacy.backup"
if backup.exists() or backup.is_symlink():
data, backup_mode = _snapshot(backup)
_need(data == before and backup_mode == 0o600, "CONSOLE_DESKTOP_WRAPPER_BACKUP_CHANGED")
else:
_atomic(backup, before)
_need(_snapshot(path)[0] == before, "CONSOLE_DESKTOP_WRAPPER_CHANGED_DURING_MIGRATION")
_atomic(path, after, mode)
return True
def _legacy_identity(node, runtime):
return {"socket": runtime / (node.lower() + "-tty1") / "console.sock",
"session": node.upper() + "-FBTERM"}
def legacy_context(state, node, runtime):
"""Return a user-approved legacy binding; ordinary profiles remain isolated."""
path = Path(state) / RECORD
if not path.exists() and not path.is_symlink():
return None
state_info = _owned(Path(state), directory=True)
_need(not state_info.st_mode & 0o077, "CONSOLE_DESKTOP_STATE_NOT_PRIVATE")
raw, mode = _snapshot(path)
_need(mode == 0o600, "CONSOLE_DESKTOP_RECORD_NOT_PRIVATE")
try:
record = json.loads(raw)
except (ValueError, TypeError):
raise ValueError("CONSOLE_DESKTOP_RECORD_INVALID") from None
expected = _legacy_identity(node, Path(runtime))
_need(type(record) is dict and record.get("schema") == 1
and record.get("state") == str(state) and record.get("node") == node
and record.get("socket") == str(expected["socket"])
and record.get("session") == expected["session"], "CONSOLE_DESKTOP_RECORD_INVALID")
return expected
def migrate_legacy(state, *, home=None, node=None, runtime=None):
"""Change only the exact known legacy entry; called under the profile owner's UID."""
from .config import private_dir, state_path
import socket
uid = os.getuid()
_need(uid != 0 and os.geteuid() == uid, "CONSOLE_DESKTOP_USER_REQUIRED")
state = state_path(state)
home = Path(home if home is not None else pwd.getpwuid(uid).pw_dir)
node = node or re.sub(r"[^A-Za-z0-9_-]", "_", socket.gethostname())[:64] or "NODE"
runtime = Path(runtime if runtime is not None else Path("/run/user") / str(uid))
desktop = home / ".local/share/applications/framebridge-console.desktop"
if not desktop.exists() and not desktop.is_symlink():
return {"ok": True, "changed": False, "reason": "NO_LEGACY_DESKTOP_ENTRY"}
_owned(home, directory=True)
_owned(desktop.parent, directory=True)
before, mode = _snapshot(desktop)
after = _new_entry(state)
if before == after:
_need(legacy_context(state, node, runtime) is not None, "CONSOLE_DESKTOP_RECORD_MISSING")
wrapper_changed = _migrate_wrapper(state, home)
return {"ok": True, "changed": wrapper_changed, "reason": "ALREADY_MIGRATED",
"wrapper_changed": wrapper_changed}
if before != _legacy_entry(home):
return {"ok": True, "changed": False, "reason": "CUSTOM_DESKTOP_ENTRY_PRESERVED"}
wrapper, _ = _snapshot(home / ".local/bin/framebridge-console")
_need(hashlib.sha256(wrapper).hexdigest() == LEGACY_WRAPPER_SHA256,
"CONSOLE_DESKTOP_LEGACY_WRAPPER_CHANGED")
identity = _legacy_identity(node, runtime)
parent = identity["socket"].parent
_owned(runtime, directory=True)
_owned(parent, directory=True)
info = identity["socket"].lstat()
_need(stat.S_ISSOCK(info.st_mode) and info.st_uid == uid and not info.st_mode & 0o077,
"CONSOLE_DESKTOP_LEGACY_SOCKET_UNTRUSTED")
result = subprocess.run(["/usr/bin/tmux", "-S", str(identity["socket"]), "list-windows",
"-t", "=" + identity["session"], "-F", "#{window_id}|#{window_name}"],
capture_output=True, text=True, timeout=5)
rows = result.stdout.splitlines()
_need(result.returncode == 0 and len([row for row in rows
if re.fullmatch(r"@[0-9]+\|Console", row)]) == 1,
"CONSOLE_DESKTOP_LEGACY_WINDOW_UNVERIFIED")
window = next(row.split("|", 1)[0] for row in rows if re.fullmatch(r"@[0-9]+\|Console", row))
panes = subprocess.run(["/usr/bin/tmux", "-S", str(identity["socket"]), "list-panes",
"-t", window, "-F", "#{pane_current_command}|#{pane_dead}|#{@framebridge_role}"],
capture_output=True, text=True, timeout=5)
pane_rows = [row.split("|") for row in panes.stdout.splitlines()]
_need(panes.returncode == 0 and len(pane_rows) == 2
and all(len(row) == 3 and row[1] == "0" for row in pane_rows)
and {row[2] for row in pane_rows} == {"shell", "monitor"}
and any(row == ["bash", "0", "shell"] for row in pane_rows),
"CONSOLE_DESKTOP_LEGACY_PANES_UNVERIFIED")
private_dir(state)
backup = state / "console-desktop-legacy.backup"
if backup.exists() or backup.is_symlink():
old, backup_mode = _snapshot(backup)
_need(old == before and backup_mode == 0o600, "CONSOLE_DESKTOP_BACKUP_CHANGED")
else:
_atomic(backup, before)
record = {"schema": 1, "state": str(state), "node": node,
"socket": str(identity["socket"]), "session": identity["session"],
"desktop_before_sha256": hashlib.sha256(before).hexdigest()}
_atomic(state / RECORD, (json.dumps(record, sort_keys=True, indent=2) + "\n").encode())
_need(_snapshot(desktop)[0] == before, "CONSOLE_DESKTOP_CHANGED_DURING_MIGRATION")
_atomic(desktop, after, mode)
wrapper_changed = _migrate_wrapper(state, home)
return {"ok": True, "changed": True, "desktop": str(desktop),
"wrapper_changed": wrapper_changed,
"session": identity["session"], "socket": str(identity["socket"]),
"backup": str(backup), "runtime_changed": False}
@@ -29,6 +29,7 @@ def arguments():
p.add_argument('--node', default=os.environ.get('FRAMEBRIDGE_NODE', socket.gethostname())) p.add_argument('--node', default=os.environ.get('FRAMEBRIDGE_NODE', socket.gethostname()))
p.add_argument('--socket') p.add_argument('--socket')
p.add_argument('--session') p.add_argument('--session')
p.add_argument('--jobs-root', type=Path)
p.add_argument('--apply', action='store_true', help='Set up without attaching a client') p.add_argument('--apply', action='store_true', help='Set up without attaching a client')
p.add_argument('--probe', action='store_true', help='Read current pane metadata only') p.add_argument('--probe', action='store_true', help='Read current pane metadata only')
p.add_argument('--reflow', action='store_true', help='Resize existing panes without taking focus') p.add_argument('--reflow', action='store_true', help='Resize existing panes without taking focus')
@@ -68,10 +69,14 @@ class Console:
if len(matches)!=1: if len(matches)!=1:
raise RuntimeError('Console session identity is ambiguous') raise RuntimeError('Console session identity is ambiguous')
self.session_target=matches[0] self.session_target=matches[0]
windows=self.tmux('list-windows','-t',self.session_target,'-F','#{window_id}').splitlines() windows=[line.split('|',1) for line in self.tmux(
if len(windows)!=1: 'list-windows','-t',self.session_target,'-F','#{window_id}|#{window_name}').splitlines()]
raise RuntimeError('Use a dedicated console session containing exactly one window') if not windows or any(len(row)!=2 or not re.fullmatch(r'@[0-9]+',row[0]) for row in windows):
self.window=windows[0] raise RuntimeError('Console window identity is invalid')
selected=windows if len(windows)==1 else [row for row in windows if row[1]=='Console']
if len(selected)!=1:
raise RuntimeError('The dedicated Console window is absent or ambiguous')
self.window=selected[0][0]
return True return True
return False return False
@@ -87,7 +92,10 @@ class Console:
return rows return rows
def monitor_command(self): def monitor_command(self):
return shlex.join(['/usr/bin/python3', str(HERE / 'framebridge_console.py'), '--monitor', '--node', self.a.node]) command=['/usr/bin/python3', str(HERE / 'framebridge_console.py'), '--monitor', '--node', self.a.node]
if getattr(self.a,'jobs_root',None) is not None:
command.extend(['--jobs-root',str(self.a.jobs_root)])
return shlex.join(command)
def locate(self, rows): def locate(self, rows):
shell = next((p for p in rows if p['role']=='shell'), None) shell = next((p for p in rows if p['role']=='shell'), None)
@@ -164,7 +172,7 @@ class Console:
cmd=Path('/proc',str(monitor['pid']),'cmdline').read_bytes() cmd=Path('/proc',str(monitor['pid']),'cmdline').read_bytes()
except OSError: except OSError:
cmd=b'' cmd=b''
if self.a.refresh_monitor or monitor['dead'] or b'framebridge_console.py' not in cmd: if self.a.refresh_monitor or monitor['dead'] or str(HERE/'framebridge_console.py').encode() not in cmd:
self.tmux('respawn-pane','-k','-t',monitor['id'],self.monitor_command()) self.tmux('respawn-pane','-k','-t',monitor['id'],self.monitor_command())
for role,pane in [('shell',shell),('monitor',monitor)]: for role,pane in [('shell',shell),('monitor',monitor)]:
self.tmux('set-option','-p','-t',pane['id'],'@framebridge_role',role) self.tmux('set-option','-p','-t',pane['id'],'@framebridge_role',role)
@@ -190,9 +198,12 @@ class Console:
# Reattachment must preserve the user's zoom and pane focus. # Reattachment must preserve the user's zoom and pane focus.
if not previous_zoom: if not previous_zoom:
self.layout(shell,monitor) self.layout(shell,monitor)
command=shlex.join(['/usr/bin/python3',str(HERE/'framebridge_console.py'),'--node',self.a.node, reflow=['/usr/bin/python3',str(HERE/'framebridge_console.py'),'--node',self.a.node,
'--socket',self.a.socket,'--session',self.a.session, '--socket',self.a.socket,'--session',self.a.session,
'--layout',getattr(self.a,'layout','auto'),'--reflow']) '--layout',getattr(self.a,'layout','auto'),'--reflow']
if getattr(self.a,'jobs_root',None) is not None:
reflow.extend(['--jobs-root',str(self.a.jobs_root)])
command=shlex.join(reflow)
self.tmux('set-hook','-t',self.session_target,'client-resized[90]','run-shell -b '+shlex.quote(command)) self.tmux('set-hook','-t',self.session_target,'client-resized[90]','run-shell -b '+shlex.quote(command))
self.tmux('set-hook','-t',self.session_target,'client-attached[90]','run-shell -b '+shlex.quote(command)) self.tmux('set-hook','-t',self.session_target,'client-attached[90]','run-shell -b '+shlex.quote(command))
self.tmux('select-window','-t',self.window) self.tmux('select-window','-t',self.window)
@@ -205,10 +216,13 @@ class Console:
raise RuntimeError('Bash identity changed unexpectedly') raise RuntimeError('Bash identity changed unexpectedly')
def supervise(node): def supervise(node, jobs_root=None):
command=['/usr/bin/python3','-u',str(HERE/'fbterm_dashboard.py'),'--node',node]
if jobs_root is not None:
command.extend(['--root',str(jobs_root)])
while True: while True:
try: try:
result=subprocess.run(['/usr/bin/python3','-u',str(HERE/'fbterm_dashboard.py'),'--node',node]) result=subprocess.run(command)
print('\r\nFBTERM monitor stopped (exit '+str(result.returncode)+'). Restarting in 2 seconds.',flush=True) print('\r\nFBTERM monitor stopped (exit '+str(result.returncode)+'). Restarting in 2 seconds.',flush=True)
print('Ctrl+B o switches to the shell.',flush=True) print('Ctrl+B o switches to the shell.',flush=True)
time.sleep(2) time.sleep(2)
@@ -220,7 +234,7 @@ def supervise(node):
def main(): def main():
args=arguments() args=arguments()
if args.monitor: if args.monitor:
supervise(args.node) supervise(args.node, args.jobs_root)
return 0 return 0
console=Console(args) console=Console(args)
if args.probe: if args.probe:
@@ -0,0 +1,513 @@
"""Narrow privileged host-credential backend for systemd 255.
Run only through the root-owned installed launcher. Administrative protect and
revoke operations use ordinary sudo authorization. Each protected credential
gets one literal, owner-specific sudoers decrypt command; there is no general
sudo permission for systemd-creds. No owner-controlled paths are opened here.
"""
from __future__ import annotations
import base64
from contextlib import contextmanager
import fcntl
import hashlib
import json
import os
from pathlib import Path
import pwd
import re
import resource
import secrets
import select
import stat
import subprocess
import sys
import time
ROOT = Path("/var/lib/framebridge-expanded/credentials")
SUDOERS = Path("/etc/sudoers.d")
LAUNCHER = Path("/usr/lib/framebridge-expanded/bin/framebridge-credential-helper")
CODE = Path("/usr/lib/framebridge-expanded/expanded/framebridge_expanded/credential_helper.py")
CREDS = "/usr/bin/systemd-creds"
VISUDO = "/usr/sbin/visudo"
HEX = re.compile(r"[0-9a-f]{64}\Z")
USER = re.compile(r"[A-Za-z_][A-Za-z0-9_-]{0,63}\$?\Z")
ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C", "LC_ALL": "C"}
MAX_KEY = 16384
MAX_CIPHER = 131072
INPUT_TIMEOUT = 5
def _fail():
raise RuntimeError("The protected credential operation could not be verified.")
def _root_path(path, *, directory=False, private=False):
"""Check every path component; root-owned parents cannot be swapped by users."""
path = Path(path)
if not path.is_absolute():
_fail()
for item in reversed((path, *path.parents)):
info = item.lstat()
last = item == path
if info.st_uid != 0 or info.st_mode & 0o022 or stat.S_ISLNK(info.st_mode):
_fail()
if not last or directory:
if not stat.S_ISDIR(info.st_mode):
_fail()
elif not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
_fail()
if last and private and info.st_mode & 0o077:
_fail()
return path
def _installation():
if os.geteuid() != 0 or not sys.flags.isolated or not sys.flags.no_site:
_fail()
if Path(__file__).absolute() != CODE:
_fail()
_root_path(CODE)
_root_path(LAUNCHER)
_root_path(Path(CREDS))
_root_path(Path(VISUDO))
def _caller():
raw = os.environ.get("SUDO_UID", "")
if not raw.isascii() or not raw.isdecimal() or str(int(raw)) != raw or int(raw) <= 0:
_fail()
owner = pwd.getpwuid(int(raw))
if not USER.fullmatch(owner.pw_name) or os.environ.get("SUDO_USER") != owner.pw_name:
_fail()
# sudo sets these identity variables, and generated rules explicitly deny
# SETENV. There is deliberately no caller-selected UID command argument.
if os.environ.get("SUDO_GID") != str(owner.pw_gid):
_fail()
return owner
def _run(args, data=None):
result = subprocess.run(args, input=data, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=ENV, cwd="/",
close_fds=True, timeout=30)
if result.returncode:
_fail()
return result.stdout
def _sync(directory):
fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
os.fsync(fd)
finally:
os.close(fd)
def _read_key():
# The frontend has already collected the key before launching this helper.
# A caller holding stdin open must not retain the shared registry lock.
deadline = time.monotonic() + INPUT_TIMEOUT
fd = sys.stdin.fileno()
value = bytearray()
while True:
remaining = deadline - time.monotonic()
if remaining <= 0 or not select.select([fd], [], [], remaining)[0]:
_fail()
chunk = os.read(fd, MAX_KEY + 1 - len(value))
if not chunk:
return bytes(value)
value.extend(chunk)
if len(value) > MAX_KEY:
_fail()
def _prepare_root():
# Only this fixed root-owned tree is created. No profile or HOME is opened.
for directory in (ROOT.parent, ROOT):
_root_path(directory.parent, directory=True)
try:
directory.mkdir(mode=0o700)
_sync(directory.parent)
except FileExistsError:
pass
_root_path(directory, directory=True, private=directory == ROOT)
_root_path(SUDOERS, directory=True)
def _read(path, maximum=200000, *, policy=False):
_root_path(path, private=not policy)
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
try:
info = os.fstat(fd)
if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_nlink != 1
or info.st_mode & (0o027 if policy else 0o077)):
_fail()
data = os.read(fd, maximum + 1)
if len(data) > maximum:
_fail()
return data
finally:
os.close(fd)
def _install(path, data, *, policy=False):
_root_path(path.parent, directory=True)
temporary = path.with_name("." + path.name + "." + secrets.token_hex(12))
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
if policy:
temporary.chmod(0o440)
_run([VISUDO, "-c", "-f", str(temporary)])
# link is atomic and never overwrites a pre-existing/symlink target.
os.link(temporary, path, follow_symlinks=False)
_sync(path.parent)
finally:
temporary.unlink(missing_ok=True)
_sync(path.parent)
def _cleanup_temporaries():
# Recover only this installer's root-owned, fixed-format staging files.
patterns = ((ROOT, r"\.[1-9][0-9]*-[0-9a-f]{64}-[0-9a-f]{64}\.json\.[0-9a-f]{24}", False),
(SUDOERS, r"\.framebridge-credential-[1-9][0-9]*-[0-9a-f]{64}-[0-9a-f]{64}\.[0-9a-f]{24}", True),
(SUDOERS, r"\.framebridge-credential-authorize-[1-9][0-9]*-[0-9a-f]{64}-(?:host|user)\.[0-9a-f]{24}", True))
for directory, pattern, policy in patterns:
removed = False
for path in directory.iterdir():
if re.fullmatch(pattern, path.name):
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
try:
info = os.fstat(fd)
if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0
or info.st_mode & (0o027 if policy else 0o077)
or info.st_nlink not in {1, 2}
or info.st_size > (4096 if policy else 200000)):
_fail()
if info.st_nlink == 2:
# A crash after publish/link but before staging unlink.
published = directory / path.name[1:].rsplit(".", 1)[0]
other = published.lstat()
if (not stat.S_ISREG(other.st_mode) or other.st_uid != 0
or (other.st_dev, other.st_ino) != (info.st_dev, info.st_ino)):
_fail()
finally:
os.close(fd)
path.unlink(); removed = True
if removed:
_sync(directory)
def _ids(profile, digest=None):
if not isinstance(profile, str) or not HEX.fullmatch(profile):
_fail()
if digest is not None and (not isinstance(digest, str) or not HEX.fullmatch(digest)):
_fail()
def _paths(uid, profile, digest):
stem = str(uid) + "-" + profile + "-" + digest
return ROOT / (stem + ".json"), SUDOERS / ("framebridge-credential-" + stem)
def _policy(owner, profile, digest):
# All fields are allowlisted tokens; no glob, free path, flags or shell.
return (owner.pw_name + " ALL=(root) NOLOG_INPUT: NOLOG_OUTPUT: NOSETENV: NOPASSWD: " + str(LAUNCHER)
+ " decrypt " + profile + " " + digest + "\n").encode("ascii")
def _authorization(owner, profile, kind):
_ids(profile)
if kind not in {"host", "user"}:
_fail()
path = SUDOERS / ("framebridge-credential-authorize-" + str(owner.pw_uid) + "-" + profile + "-" + kind)
if kind == "host":
command = str(LAUNCHER) + " protect " + profile
else:
command = CREDS + " encrypt --uid=" + str(owner.pw_uid) + " --with-key=host --name=chatgpt-runtime-key - -"
prefix = owner.pw_name + " ALL=(root) NOLOG_INPUT: NOLOG_OUTPUT: NOSETENV: NOPASSWD: "
cancel = str(LAUNCHER) + " unauthorize " + profile + " " + kind
return path, (prefix + command + "\n" + prefix + cancel + "\n").encode("ascii")
def authorize(owner, profile, kind):
"""Administrative, no secret input: exact no-I/O-log permission only."""
path, expected = _authorization(owner, profile, kind)
if path.exists() or path.is_symlink():
if _read(path, 4096, policy=True) != expected:
_fail()
return
_install(path, expected, policy=True)
def unauthorize(owner, profile, kind, *, required=False):
path, expected = _authorization(owner, profile, kind)
if not path.exists() and not path.is_symlink():
if required:
_fail()
return
if _read(path, 4096, policy=True) != expected:
_fail()
path.unlink()
_sync(SUDOERS)
def _record(owner, profile, digest, *, require_policy=True):
record_path, policy_path = _paths(owner.pw_uid, profile, digest)
value = json.loads(_read(record_path))
if (type(value) is not dict or value.get("schema") != 1
or value.get("uid") != owner.pw_uid or value.get("username") != owner.pw_name
or value.get("home") != owner.pw_dir or value.get("profile") != profile
or value.get("sha256") != digest):
_fail()
name = value.get("name", "")
if not re.fullmatch(r"framebridge-" + str(owner.pw_uid) + "-" + profile + r"-[0-9a-f]{32}", name):
_fail()
try:
cipher = base64.b64decode(value["cipher"], validate=True)
except (KeyError, TypeError, ValueError):
_fail()
if not 80 <= len(cipher) <= MAX_CIPHER or hashlib.sha256(cipher).hexdigest() != digest:
_fail()
if require_policy and _read(policy_path, 4096, policy=True) != _policy(owner, profile, digest):
_fail()
return value, cipher, record_path, policy_path
def protect(owner, profile, key):
_ids(profile)
if (not key or len(key) > MAX_KEY or any(c < 33 or c > 126 for c in key)):
_fail()
name = "framebridge-" + str(owner.pw_uid) + "-" + profile + "-" + secrets.token_hex(16)
try:
cipher = _run([CREDS, "encrypt", "--with-key=host", "--name=" + name, "-", "-"], key)
finally:
key = b""
if not 80 <= len(cipher) <= MAX_CIPHER:
_fail()
digest = hashlib.sha256(cipher).hexdigest()
record_path, policy_path = _paths(owner.pw_uid, profile, digest)
value = {"schema": 1, "uid": owner.pw_uid, "username": owner.pw_name,
"home": owner.pw_dir, "profile": profile, "name": name,
"sha256": digest, "cipher": base64.b64encode(cipher).decode("ascii")}
data = (json.dumps(value, sort_keys=True) + "\n").encode("ascii")
_install(record_path, data)
try:
_install(policy_path, _policy(owner, profile, digest), policy=True)
except BaseException:
if policy_path.exists() or policy_path.is_symlink():
if _read(policy_path, 4096, policy=True) != _policy(owner, profile, digest):
_fail()
policy_path.unlink()
_sync(SUDOERS)
record_path.unlink()
_sync(ROOT)
raise
return cipher
def decrypt(owner, profile, digest):
_ids(profile, digest)
value, cipher, _, _ = _record(owner, profile, digest)
key = _run([CREDS, "decrypt", "--name=" + value["name"], "-", "-"], cipher)
if not key or len(key) > MAX_KEY or any(c < 33 or c > 126 for c in key):
_fail()
return key
def revoke(owner, profile, digest):
_ids(profile, digest)
record_path, policy_path = _paths(owner.pw_uid, profile, digest)
if record_path.exists() or record_path.is_symlink():
_record(owner, profile, digest, require_policy=False)
if policy_path.exists() or policy_path.is_symlink():
if _read(policy_path, 4096, policy=True) != _policy(owner, profile, digest):
_fail()
# Remove authorization first. A failure cannot leave a decryption grant to
# a missing/replaced record. The lock excludes concurrent decrypt/rotation.
policy_path.unlink(missing_ok=True)
_sync(SUDOERS)
record_path.unlink(missing_ok=True)
_sync(ROOT)
def _keep_digests(value):
values = [] if value == "-" else value.split(",")
if len(values) > 128 or any(not HEX.fullmatch(v) for v in values):
_fail()
return set(values)
def prune(owner, profile, keep):
"""Administrative crash recovery: remove only this owner's profile grants."""
_ids(profile)
authorizations = []
for kind in ("host", "user"):
path, expected = _authorization(owner, profile, kind)
if path.exists() or path.is_symlink():
if _read(path, 4096, policy=True) != expected:
_fail()
authorizations.append(path)
digests = set()
prefix = str(owner.pw_uid) + "-" + profile + "-"
for path in ROOT.glob(prefix + "*"):
match = re.fullmatch(re.escape(prefix) + r"([0-9a-f]{64})\.json", path.name)
if not match:
_fail()
digest = match.group(1)
_record(owner, profile, digest, require_policy=False)
digests.add(digest)
for path in SUDOERS.glob("framebridge-credential-" + prefix + "*"):
digest = path.name.removeprefix("framebridge-credential-" + prefix)
_ids(profile, digest)
if _read(path, 4096, policy=True) != _policy(owner, profile, digest):
_fail()
digests.add(digest)
# Prevalidation above completes before any grant or encrypted record moves.
for digest in sorted(digests - keep):
revoke(owner, profile, digest)
for path in authorizations:
path.unlink()
if authorizations:
_sync(SUDOERS)
def purge_all():
"""Administrative uninstall cleanup, not permitted by decrypt sudo rules."""
_cleanup_temporaries()
entries = []
for path in sorted(ROOT.iterdir()):
if path.name == ".lock":
continue
match = re.fullmatch(r"([1-9][0-9]*)-([0-9a-f]{64})-([0-9a-f]{64})\.json", path.name)
if not match:
_fail()
uid, profile, digest = match.groups()
# A removed/reused account must not prevent revocation. Validate the
# original principal from the root-owned record without trusting passwd.
value = json.loads(_read(path))
if type(value) is not dict or not USER.fullmatch(value.get("username", "")):
_fail()
owner = type("Owner", (), {"pw_uid": int(uid), "pw_name": value["username"],
"pw_dir": value.get("home")})()
_, _, record_path, policy_path = _record(owner, profile, digest, require_policy=False)
if policy_path.exists() or policy_path.is_symlink():
if _read(policy_path, 4096, policy=True) != _policy(owner, profile, digest):
_fail()
entries.append((record_path, policy_path))
policies = []
for policy_path in sorted(SUDOERS.glob("framebridge-credential-*")):
auth = re.fullmatch(r"framebridge-credential-authorize-([1-9][0-9]*)-([0-9a-f]{64})-(host|user)", policy_path.name)
if auth:
uid, profile, kind = auth.groups()
data = _read(policy_path, 4096, policy=True)
username = data.split(b" ", 1)[0].decode("ascii")
if not USER.fullmatch(username):
_fail()
owner = type("Owner", (), {"pw_uid": int(uid), "pw_name": username})()
if data != _authorization(owner, profile, kind)[1]:
_fail()
policies.append(policy_path)
continue
match = re.fullmatch(r"framebridge-credential-([1-9][0-9]*)-([0-9a-f]{64})-([0-9a-f]{64})", policy_path.name)
if not match:
_fail()
uid, profile, digest = match.groups()
data = _read(policy_path, 4096, policy=True)
username = data.split(b" ", 1)[0].decode("ascii")
if not USER.fullmatch(username):
_fail()
owner = type("Owner", (), {"pw_name": username})()
if data != _policy(owner, profile, digest):
_fail()
policies.append(policy_path)
# Prevalidate every record and policy before removing any of them.
for policy_path in policies:
policy_path.unlink()
_sync(SUDOERS)
for record_path, _ in entries:
record_path.unlink()
_sync(ROOT)
@contextmanager
def _registry_lock():
fd = os.open(ROOT / ".lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_nlink != 1 or info.st_mode & 0o077:
_fail()
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
os.close(fd)
def main(argv=None):
os.umask(0o077)
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
args = list(sys.argv[1:] if argv is None else argv)
try:
_installation()
if not args or args[0] not in {"protect", "decrypt", "revoke", "prune", "authorize", "unauthorize", "purge-all"}:
_fail()
operation = args[0]
if len(args) != {"protect": 2, "decrypt": 3, "revoke": 3, "prune": 3, "authorize": 3, "unauthorize": 3, "purge-all": 1}[operation]:
_fail()
owner = _caller() if operation != "purge-all" else None
if operation in {"authorize", "unauthorize"}:
_ids(args[1])
if args[2] not in {"host", "user"}:
_fail()
elif operation == "prune":
_ids(args[1]); keep = _keep_digests(args[2])
elif operation != "purge-all":
_ids(*args[1:])
_prepare_root()
result = b""
with _registry_lock():
_cleanup_temporaries()
if operation == "protect":
# sudo already selected this exact NOLOG policy. Consume its
# encryption permission before accepting any key bytes.
unauthorize(owner, args[1], "host", required=True)
key = _read_key()
result = protect(owner, args[1], key)
key = b""
elif operation == "decrypt":
result = decrypt(owner, args[1], args[2])
elif operation == "revoke":
revoke(owner, args[1], args[2])
elif operation == "prune":
prune(owner, args[1], keep)
elif operation == "authorize":
authorize(owner, args[1], args[2])
elif operation == "unauthorize":
unauthorize(owner, args[1], args[2])
else:
purge_all()
# stdout belongs to an unprivileged caller and can block indefinitely.
# Never hold the shared registry lock while writing or flushing it.
try:
if result:
sys.stdout.buffer.write(result)
sys.stdout.buffer.flush()
except BaseException:
if operation == "protect" and result:
# A broken receipt pipe must not orphan an authorization. The
# lock is reacquired only for this bounded administrative undo.
with _registry_lock():
revoke(owner, args[1], hashlib.sha256(result).hexdigest())
raise
return 0
except (Exception, KeyboardInterrupt):
# Never export subprocess output, arguments, record contents or traces.
sys.stderr.write("FrameBridge protected credential operation failed.\n")
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -7,6 +7,11 @@ from .real_screen.screen_capture import CaptureError, foreground, _x11_context
BINDING = "FRAMEBRIDGE_DISPLAY_BINDING" BINDING = "FRAMEBRIDGE_DISPLAY_BINDING"
GRAPHICAL_COMMANDS = ("wmctrl", "xdotool", "xwininfo", "xclip") GRAPHICAL_COMMANDS = ("wmctrl", "xdotool", "xwininfo", "xclip")
VIRTUAL_COMMANDS = ("Xvfb", "openbox", "xauth") VIRTUAL_COMMANDS = ("Xvfb", "openbox", "xauth")
VIRTUAL_INSTALL_COMMAND = "sudo apt install xvfb openbox xauth"
def missing_virtual_commands(env):
return [name for name in VIRTUAL_COMMANDS if not shutil.which(name, path=env.get("PATH"))]
def missing_graphical_commands(env): def missing_graphical_commands(env):
+58 -23
View File
@@ -8,10 +8,13 @@ from pathlib import Path
import re import re
import socket import socket
import sys import sys
import threading
import time import time
import uuid import uuid
from typing import Any from typing import Any
_BOOTSTRAP_LOCK = threading.RLock()
JOB_RE = re.compile(r"^[A-Za-z0-9._-]{1,80}$") JOB_RE = re.compile(r"^[A-Za-z0-9._-]{1,80}$")
READ_CAPABILITIES = frozenset({ READ_CAPABILITIES = frozenset({
"system.status", "hardware.summary", "storage.inspect", "system.status", "hardware.summary", "storage.inspect",
@@ -56,18 +59,20 @@ def original_paths() -> tuple[Path, Path]:
def bootstrap_original() -> None: def bootstrap_original() -> None:
"""Extend the original package search path without copying or merging it.""" """Extend the original package search path without copying or merging it."""
broker_source, fleet_source = original_paths() # Registration now dispatches in workers; protect shared import paths.
for path in (fleet_source, broker_source): with _BOOTSTRAP_LOCK:
if str(path) in sys.path: broker_source, fleet_source = original_paths()
sys.path.remove(str(path)) for path in (fleet_source, broker_source):
sys.path.insert(0, str(path)) if str(path) in sys.path:
import framebridge_linux_chatgpt sys.path.remove(str(path))
package_file = Path(framebridge_linux_chatgpt.__file__).resolve() sys.path.insert(0, str(path))
_require(package_file.is_relative_to(broker_source.resolve()), "ORIGINAL_PACKAGE_SHADOWED") import framebridge_linux_chatgpt
for root in (broker_source, fleet_source): package_file = Path(framebridge_linux_chatgpt.__file__).resolve()
path = str(root / "framebridge_linux_chatgpt") _require(package_file.is_relative_to(broker_source.resolve()), "ORIGINAL_PACKAGE_SHADOWED")
if path not in framebridge_linux_chatgpt.__path__: for root in (broker_source, fleet_source):
framebridge_linux_chatgpt.__path__.append(path) path = str(root / "framebridge_linux_chatgpt")
if path not in framebridge_linux_chatgpt.__path__:
framebridge_linux_chatgpt.__path__.append(path)
def state_directory() -> Path: def state_directory() -> Path:
@@ -159,6 +164,19 @@ def resolve_node(alias: str) -> dict[str, Any]:
return matches[0] return matches[0]
def _target_identity(requested: str, node: dict[str, Any]) -> dict[str, Any]:
"""Describe the confirmed route, never identity claimed by a node response."""
return {
"requested_target": requested,
"resolved_target": node["alias"],
"node": node["alias"],
"target": "LOCAL" if node["transport"] == "local" else node["alias"],
"node_id": node["node_id"],
"hostname": node.get("hostname"),
"transport": node["transport"],
}
def _normalize_read(capability: str, args: dict[str, Any]) -> dict[str, Any]: def _normalize_read(capability: str, args: dict[str, Any]) -> dict[str, Any]:
bootstrap_original() bootstrap_original()
from framebridge_linux_chatgpt.fleet_routing import ReadCapability, normalize_read_args from framebridge_linux_chatgpt.fleet_routing import ReadCapability, normalize_read_args
@@ -180,6 +198,7 @@ def read_local(capability: str, args: dict[str, Any]) -> dict[str, Any]:
def node_read(alias: str, capability: str, args: dict[str, Any]) -> dict[str, Any]: def node_read(alias: str, capability: str, args: dict[str, Any]) -> dict[str, Any]:
node = None
try: try:
normalized = _normalize_read(capability, args) normalized = _normalize_read(capability, args)
node = resolve_node(alias) node = resolve_node(alias)
@@ -191,10 +210,13 @@ def node_read(alias: str, capability: str, args: dict[str, Any]) -> dict[str, An
result = broker.request(node["node_id"], capability, normalized, timeout_s=30) result = broker.request(node["node_id"], capability, normalized, timeout_s=30)
_require(type(result) is dict, "NODE_READ_RESPONSE_INVALID") _require(type(result) is dict, "NODE_READ_RESPONSE_INVALID")
response = dict(result) response = dict(result)
response.update(node=node["alias"], hostname=node["hostname"], transport=node["transport"]) response.update(_target_identity(alias, node))
return response return response
except Exception as exc: except Exception as exc:
return {"ok": False, "error": str(exc) if isinstance(exc, FleetError) else "NODE_READ_FAILED"} response = {"ok": False, "error": str(exc) if isinstance(exc, FleetError) else "NODE_READ_FAILED"}
if node is not None:
response.update(_target_identity(alias, node))
return response
def _exec_payload(script: str, authorized: bool, timeout_s: int, job_id: str, def _exec_payload(script: str, authorized: bool, timeout_s: int, job_id: str,
@@ -219,6 +241,7 @@ def _exec_payload(script: str, authorized: bool, timeout_s: int, job_id: str,
def execute_node(alias: str, script: str, authorized: bool, timeout_s: int = 120, def execute_node(alias: str, script: str, authorized: bool, timeout_s: int = 120,
job_id: str = "", sha256: str = "", include_stdout: bool = False) -> dict[str, Any]: job_id: str = "", sha256: str = "", include_stdout: bool = False) -> dict[str, Any]:
node = None
try: try:
payload = _exec_payload(script, authorized, timeout_s, job_id, sha256, include_stdout) payload = _exec_payload(script, authorized, timeout_s, job_id, sha256, include_stdout)
node = resolve_node(alias) node = resolve_node(alias)
@@ -236,20 +259,24 @@ def execute_node(alias: str, script: str, authorized: bool, timeout_s: int = 120
"NODE_EXEC_JOB_BINDING_INVALID") "NODE_EXEC_JOB_BINDING_INVALID")
_require(response.get("mcp_script_sha256", response.get("command_sha256")) == payload["sha256"], _require(response.get("mcp_script_sha256", response.get("command_sha256")) == payload["sha256"],
"NODE_EXEC_HASH_BINDING_INVALID") "NODE_EXEC_HASH_BINDING_INVALID")
response.update(target="LOCAL" if node["transport"] == "local" else node["alias"], response.update(_target_identity(alias, node))
node_id=node["node_id"], mcp_job_id=payload["job_id"], response.update(mcp_job_id=payload["job_id"],
mcp_script_sha256=payload["sha256"]) mcp_script_sha256=payload["sha256"])
if include_stdout is not True: if include_stdout is not True:
response.pop("stdout", None) response.pop("stdout", None)
return response return response
except Exception as exc: except Exception as exc:
return {"ok": False, "error": str(exc) if isinstance(exc, FleetError) else "NODE_EXEC_FAILED"} response = {"ok": False, "error": str(exc) if isinstance(exc, FleetError) else "NODE_EXEC_FAILED"}
if node is not None:
response.update(_target_identity(alias, node))
return response
def execute_screen_node(alias: str, script: str, authorized: bool, timeout_s: int = 30, def execute_screen_node(alias: str, script: str, authorized: bool, timeout_s: int = 30,
job_id: str = "", sha256: str = "", include_stdout: bool = False) -> dict[str, Any]: job_id: str = "", sha256: str = "", include_stdout: bool = False) -> dict[str, Any]:
"""Use the same confirmed-node authority with an explicit non-shell screen operation.""" """Use the same confirmed-node authority with an explicit non-shell screen operation."""
from .real_screen.screen_transport import REQUEST_TYPE, validate_payload, ScreenTransportError from .real_screen.screen_transport import REQUEST_TYPE, validate_payload, ScreenTransportError
node = None
try: try:
_require(type(timeout_s) is int and 1 <= timeout_s <= 30, "INVALID_SCREEN_TIMEOUT") _require(type(timeout_s) is int and 1 <= timeout_s <= 30, "INVALID_SCREEN_TIMEOUT")
payload = _exec_payload(script, authorized, timeout_s, job_id, sha256, include_stdout) payload = _exec_payload(script, authorized, timeout_s, job_id, sha256, include_stdout)
@@ -275,16 +302,20 @@ def execute_screen_node(alias: str, script: str, authorized: bool, timeout_s: in
from .real_screen.screen_rpc import encode from .real_screen.screen_rpc import encode
response.update(ok=False, exit_code="1", stdout_truncated=False, response.update(ok=False, exit_code="1", stdout_truncated=False,
stdout=encode({"ok": False, "error": {"code": "SCREEN_RPC_UNSUPPORTED_ON_NODE"}}).decode("ascii") + "\n") stdout=encode({"ok": False, "error": {"code": "SCREEN_RPC_UNSUPPORTED_ON_NODE"}}).decode("ascii") + "\n")
response.update(target="LOCAL" if node["transport"] == "local" else node["alias"], response.update(_target_identity(alias, node))
node_id=node["node_id"], mcp_job_id=payload["job_id"], response.update(mcp_job_id=payload["job_id"],
mcp_script_sha256=payload["sha256"]) mcp_script_sha256=payload["sha256"])
return response return response
except Exception as exc: except Exception as exc:
return {"ok": False, "error": str(exc) if isinstance(exc, (FleetError, ScreenTransportError)) else "SCREEN_RPC_FAILED"} response = {"ok": False, "error": str(exc) if isinstance(exc, (FleetError, ScreenTransportError)) else "SCREEN_RPC_FAILED"}
if node is not None:
response.update(_target_identity(alias, node))
return response
def cua_node(alias: str, payload: dict[str, Any], authorized: bool, def cua_node(alias: str, payload: dict[str, Any], authorized: bool,
timeout_s: int = 60, job_id: str = "") -> dict[str, Any]: timeout_s: int = 60, job_id: str = "") -> dict[str, Any]:
node = None
try: try:
_require(authorized is True, "AUTHORIZED_TRUE_REQUIRED") _require(authorized is True, "AUTHORIZED_TRUE_REQUIRED")
_require(type(payload) is dict, "CUA_PAYLOAD_REQUIRED") _require(type(payload) is dict, "CUA_PAYLOAD_REQUIRED")
@@ -311,10 +342,13 @@ def cua_node(alias: str, payload: dict[str, Any], authorized: bool,
response = dict(result) response = dict(result)
if response.get("ok") is True: if response.get("ok") is True:
_require(response.get("job_id") == job, "NODE_CUA_JOB_BINDING_INVALID") _require(response.get("job_id") == job, "NODE_CUA_JOB_BINDING_INVALID")
response.update(target="LOCAL" if node["transport"] == "local" else node["alias"], node_id=node["node_id"]) response.update(_target_identity(alias, node))
return response return response
except Exception as exc: except Exception as exc:
return {"ok": False, "error": str(exc) if isinstance(exc, FleetError) else "NODE_CUA_FAILED"} response = {"ok": False, "error": str(exc) if isinstance(exc, FleetError) else "NODE_CUA_FAILED"}
if node is not None:
response.update(_target_identity(alias, node))
return response
def main() -> None: def main() -> None:
@@ -333,7 +367,8 @@ def main() -> None:
streamable_http_path="/mcp") streamable_http_path="/mcp")
hub = FleetHub(discover_nodes, node_read) hub = FleetHub(discover_nodes, node_read)
register_fleet_read_tools(server, hub) register_fleet_read_tools(server, hub)
server.run(transport="streamable-http") from .http_server import run_streamable_mcp
run_streamable_mcp(server)
if __name__ == "__main__": if __name__ == "__main__":
@@ -1,33 +1,16 @@
"""Shared English guide content. Regenerate command docs after editing.""" """Shared English guide content. Regenerate command docs after editing."""
from . import __version__
GUIDE = {'start': {'title': 'Start with FrameBridge', GUIDE = {'start': {'title': 'Start with FrameBridge',
'sections': [{'heading': 'One interface for people and agents', 'sections': [{'heading': 'One interface for people and agents',
'body': 'FrameBridge provides tools and a shared command-line interface ' 'body': 'FrameBridge provides network administration tools and a shared command-line interface for a home Linux network with one human administrator and authorized agents. Desktop access is an auxiliary tool. fb is the short name for framebridge; both run the same program. The tools run on your Linux computers. The ChatGPT model remains hosted. Read this guide for the installed version before operating; confirm the receiver, target and available capabilities. Use the installed CLI and tools for normal work. Edit software only when the owner explicitly requests development, using a separate source clone and reviewable changes rather than rewriting the installed program.',
'for human operators and authorized agents. fb is the short name '
'for framebridge; both run the same program. The tools run on '
'your Linux computers. The ChatGPT model remains hosted. Read '
'this guide for the installed version before operating; confirm '
'the receiver, target and available capabilities. Use the '
'installed CLI and tools for normal work. Edit software only when '
'the owner explicitly requests development, using a separate '
'source clone and reviewable changes rather than rewriting the '
'installed program.',
'commands': ['fb help', 'fb status', 'fb --json status']}, 'commands': ['fb help', 'fb status', 'fb --json status']},
{'heading': 'Install and connect', {'heading': 'Install and connect',
'body': 'On a supported Debian system, install the downloaded package ' 'body': 'Install the package built for your exact Debian 13, Ubuntu 24.04 or Linux Mint 22.x amd64 profile. These are the three release profiles; other distributions and architectures are outside this release. Keep the distribution system Python: 3.13 on Debian 13, 3.12 on Ubuntu 24.04 and Mint 22.x. Use the matching signed APT suite: trixie, noble or mint22. Normal first connection does not require a separate setup, doctor or start sequence. Run connect in a normal interactive terminal; advanced setup is optional for central/node roles and public settings.',
'with the package manager. Normal first connection does not ' 'commands': ['fb connect']},
'require a separate setup, doctor or start sequence. Run connect '
'in a normal interactive terminal. Advanced setup is optional for '
'selecting central/node roles and public settings.',
'commands': ['sudo apt install ./FrameBridge-0.3.1.deb', 'fb connect']},
{'heading': 'Updating and removal', {'heading': 'Updating and removal',
'body': 'Install an official update through the normal package manager. ' 'body': 'Install an official update through the normal package manager when that update has been published. Normal removal stops package-owned managed processes and can retain the user profile and encrypted connection material; it is not a credential-forgetting operation. On systemd 255, package removal also revokes the administrative boot-credential grant. After reinstalling, run fb connect interactively to provision that grant again; retained files alone do not restore startup. Use the explicit credentials forget action first when that is your intended outcome. Do not assume purge erases user-profile data. An explicit downgrade also revokes administrative helper grants before the older package files replace the helper. Helper-backed profiles require a new interactive connect with a compatible package before managed startup can be restored.',
'Normal package removal stops package-owned managed processes and '
'retains the profile and saved encrypted connection material for '
'deliberate reuse; it is not a credential-forgetting operation. '
'Use the separately documented explicit forget action first if '
'that is your intended outcome. Do not assume purge erases '
'user-profile data.',
'commands': ['sudo dpkg --remove framebridge-original-expanded']}, 'commands': ['sudo dpkg --remove framebridge-original-expanded']},
{'heading': 'Human authentication', {'heading': 'Human authentication',
'body': 'The normal connection asks for an optional account label, a ' 'body': 'The normal connection asks for an optional account label, a '
@@ -67,6 +50,7 @@ GUIDE = {'start': {'title': 'Start with FrameBridge',
'fb status boot', 'fb status boot',
'fb status console', 'fb status console',
'fb doctor', 'fb doctor',
'fb health',
'fb setup', 'fb setup',
'fb console', 'fb console',
'fb help']}, 'fb help']},
@@ -227,7 +211,11 @@ GUIDE = {'start': {'title': 'Start with FrameBridge',
{'heading': 'Targets', {'heading': 'Targets',
'body': 'LOCAL means the actual receiving machine. Use enrolled, ' 'body': 'LOCAL means the actual receiving machine. Use enrolled, '
'confirmed aliases or IDs and inspect the selected target before ' 'confirmed aliases or IDs and inspect the selected target before '
'work. Do not guess SSH identities or use historical lab ' 'work. Fleet results include requested_target, resolved_target, '
'node_id, hostname and transport from the confirmed descriptor. '
'The compatibility target field can remain LOCAL; resolved_target '
'names the actual local computer. Unknown targets never fall back '
'to LOCAL. Do not guess SSH identities or use historical lab '
'hostnames as defaults. CLI tty --node can use the authenticated ' 'hostnames as defaults. CLI tty --node can use the authenticated '
'node transport. The interactive console command is local; run it ' 'node transport. The interactive console command is local; run it '
'at the destination or use an explicitly configured SSH ' 'at the destination or use an explicitly configured SSH '
@@ -310,18 +298,11 @@ GUIDE = {'start': {'title': 'Start with FrameBridge',
'partial invented view as a real screenshot.', 'partial invented view as a real screenshot.',
'commands': []}, 'commands': []},
{'heading': 'Automatic graphical discovery', {'heading': 'Automatic graphical discovery',
'body': 'The installer and control console stay text interfaces in both ' 'body': 'The installer and control console stay text interfaces in both TTY and a desktop terminal. Compatible graphical CUA is discovered automatically; availability depends on the actual display/session and permissions. A loaded module is not proof that real pixel capture or input succeeded. New profiles use adaptive auto mode: without a verified active X11 session the runtime provides FBEXEC, FBTERM and CUA-TTY in text mode, without requiring a graphical login or automatically creating a private desktop. Explicit virtual-desktop settings remain advanced options and are labelled separately from physical-screen evidence. Xvfb and Openbox are optional package suggestions and are not installed for the normal connection. Only when you choose a private virtual desktop, install its tools explicitly with sudo apt install xvfb openbox xauth and rerun fb doctor. Installing Openbox can register it as the generic window-manager alternative; use your distribution settings to retain your preferred manager when needed. Physical-screen capture and input currently require the supported active X11 session. Native Wayland control is not validated; showing an XWayland window does not establish full-desktop capture or input.',
'TTY and a desktop terminal. Compatible graphical CUA is '
'discovered automatically; availability depends on the actual '
'display/session and permissions. A loaded module is not proof '
'that real pixel capture or input succeeded. New profiles use '
'adaptive auto mode: without a verified active X11 session the '
'runtime provides FBEXEC, FBTERM and CUA-TTY in text mode, '
'without requiring a graphical login or automatically creating '
'a private desktop. Explicit virtual-desktop settings remain '
'advanced options and are labelled separately from '
'physical-screen evidence.',
'commands': ['fb status', 'fb doctor']}, 'commands': ['fb status', 'fb doctor']},
{'heading': 'Guarded X11 text compatibility',
'body': 'Graphical TYPE in KEYS mode uses the guarded native X11 backend on x86-64 LP64 systems with libxdo 3.20160805.1. This private candidate accepts active keyboard group 0 only, with no held or latched modifiers and only conventional Caps Lock and Num Lock locked bits. The whole string and generated key actions are checked before any input. Unsupported groups, characters, actions or keyboard states fail closed. The backend does not change the keyboard group or map and does not use the clipboard. Display session, fixed target window, geometry and keyboard context are checked while sending. If input may have started and an error follows, inspect the target and do not automatically replay the text.',
'commands': []},
{'heading': 'Frame-buffer read access', {'heading': 'Frame-buffer read access',
'body': 'screen-access is a narrow, explicit administrative permission ' 'body': 'screen-access is a narrow, explicit administrative permission '
'for reading the real frame buffer. It does not by itself ' 'for reading the real frame buffer. It does not by itself '
@@ -351,12 +332,11 @@ GUIDE = {'start': {'title': 'Start with FrameBridge',
'fb status runtime', 'fb status runtime',
'fb status boot']}, 'fb status boot']},
{'heading': 'Doctor', {'heading': 'Doctor',
'body': 'doctor diagnoses prerequisites and reports findings with ' 'body': 'doctor diagnoses prerequisites and reports findings with suggested next steps. It is not an implicit repair, and it is not a substitute for checking actual live connection state. Inspect the diagnosis and relevant private application logs before considering configuration changes. Eligibility checks the exact OS release, amd64 architecture, package profile and CPython ABI before starting native components. The published 0.3.3 scope is Debian 13 with cp313 and Ubuntu 24.04/Mint 22.x with cp312. LMDE 7 and Ubuntu 26.04 builder experiments remain outside that validated scope. A matching profile or a successful development build does not establish a tested installation; never repair an ABI mismatch by replacing system Python or weakening package dependencies.',
'suggested next steps. It is not an implicit repair, and it '
'is not a substitute for checking actual live connection '
'state. Inspect the diagnosis and relevant private '
'application logs before considering configuration changes.',
'commands': ['fb doctor']}, 'commands': ['fb doctor']},
{'heading': 'Local health summary',
'body': 'health combines this local profile\'s existing status and prerequisite observations into an allowlisted report. It identifies LOCAL as this computer and reports the profile, role, connection intent, local tools, boot policy and desktop availability. It never starts, repairs, connects, changes boot policy or creates an absent profile. It does not scan the fleet or probe terminal commands, screen capture or input. States are healthy, paused, degraded, unavailable and unconfigured. An intentional connection pause can be healthy; missing information cannot. JSON result.ok describes observation completeness; result.health.healthy describes health. Health exits 0 for a complete healthy or intentionally paused observation and 1 for degraded, unavailable or unconfigured state. The CLI version is not proof of the running version. Detailed troubleshooting remains local in status and doctor.',
'commands': ['fb health', 'fb --json health']},
{'heading': 'Logs and progress', {'heading': 'Logs and progress',
'body': 'Normal connection presents concise progress and a final ' 'body': 'Normal connection presents concise progress and a final '
'result, then returns to the prompt. Detailed diagnostics ' 'result, then returns to the prompt. Detailed diagnostics '
@@ -0,0 +1,234 @@
"""Read-only, allowlisted health observations for one local FrameBridge profile.
This combines existing status and prerequisite checks. It does not execute a
terminal/CUA probe, contact another node, repair a profile or change boot intent.
Snapshot success, observed readiness and compatibility are separate facts.
"""
from __future__ import annotations
from datetime import datetime, timezone
import re
import socket
import uuid
from . import __version__, lifecycle, runtime
from .config import state_path
_KINDS = frozenset({"physical-x11", "virtual-x11", "text", "not_started", "unavailable"})
_PHASES = frozenset({"starting", "checking", "access", "unverified", "ready",
"retrying", "error", "rejected", "stopped", "closed"})
_SERVICES = frozenset({"active", "activating", "deactivating", "inactive", "failed",
"reloading", "maintenance", "refreshing", "not_configured"})
_CAPABILITIES = ("fbexec", "fbterm", "cua_tty_module", "graphical_cua")
def _mapping(value):
return value if isinstance(value, dict) else {}
def _boolean(value):
return value if type(value) is bool else None
def _name(value):
return value if isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,252}", value) else None
def _node_id(value):
try:
return str(uuid.UUID(value)) if isinstance(value, str) else None
except (ValueError, AttributeError):
return None
def _count(value):
return len(value) if isinstance(value, list) else 0
def _choice(value, options, fallback="unavailable"):
return value if isinstance(value, str) and value in options else fallback
def _runtime_state(value):
if value.get("ok") is False or value.get("status_unavailable") or type(value.get("running")) is not bool:
return "unavailable"
if value.get("ready") is True and value.get("running") is True:
return "ready"
return "running" if value["running"] else "stopped"
def _boot_state(value):
if value.get("configured") is False:
return "not_configured"
if value.get("configured") is not True or type(value.get("enabled")) is not bool:
return "unavailable"
return "enabled" if value["enabled"] else "disabled"
def build_report(status_snapshot=None, doctor_snapshot=None, *, observed_at=None,
host=None, status_available=True, doctor_available=True):
"""Project snapshots onto a fixed public schema; never copy raw dictionaries.
Errors deliberately omit exception text and diagnostic free text. Existing
local ``fb doctor`` remains the detailed troubleshooting interface; this
summary can be shown to an assistant without connection metadata or paths.
"""
status = _mapping(status_snapshot)
diagnosis = _mapping(doctor_snapshot)
local = _mapping(status.get("runtime"))
connection = _mapping(status.get("connection"))
boot = _mapping(status.get("boot"))
platform = _mapping(diagnosis.get("platform_compatibility"))
capabilities = _mapping(local.get("capabilities"))
prerequisite_capabilities = _mapping(diagnosis.get("capabilities"))
context = _mapping(local.get("display_context"))
status_available = bool(status_available and isinstance(status_snapshot, dict) and status.get("ok") is not False)
configured = _boolean(status.get("configured")) if status_available else None
role = status.get("role") or local.get("role")
role = _choice(role, {"standalone", "central", "node"}, None)
local_state = _runtime_state(local) if configured is not False else "not_configured"
phase = _choice(connection.get("state"), _PHASES)
intent = _choice(status.get("desired_connection"), {"connected", "disconnected", "not_configured", "not_applicable"})
kind = _choice(context.get("kind"), _KINDS)
doctor_available = bool(doctor_available and isinstance(doctor_snapshot, dict) and type(diagnosis.get("ok")) is bool) if configured else False
diagnostics_ok = _boolean(diagnosis.get("ok")) if doctor_available else None
connection_available = role == "node" or (
bool(connection) and connection.get("ok") is not False
and phase != "unavailable" and type(connection.get("verified")) is bool)
boot_available = _boot_state(boot) != "unavailable"
issues = []
if not status_available:
issues.append("STATUS_UNAVAILABLE")
if configured and not doctor_available:
issues.append("DIAGNOSTICS_UNAVAILABLE")
if configured and local_state == "unavailable":
issues.append("RUNTIME_STATUS_UNAVAILABLE")
if configured and not connection_available:
issues.append("CONNECTION_STATUS_UNAVAILABLE")
if configured and not boot_available:
issues.append("BOOT_STATUS_UNAVAILABLE")
if diagnostics_ok is False:
issues.append("PREREQUISITES_NEED_ATTENTION")
if configured is False:
health_state, healthy = "unconfigured", False
elif (not status_available or configured is None or role is None or not doctor_available
or local_state == "unavailable" or not connection_available or not boot_available):
health_state, healthy = "unavailable", None
elif diagnostics_ok is not True or local_state != "ready":
health_state, healthy = "degraded", False
elif role == "node":
health_state, healthy = "healthy", True
elif intent == "disconnected":
if connection.get("verified") is False and connection.get("alive") is not True and phase in {"stopped", "closed"}:
health_state, healthy = "paused", True
else:
health_state, healthy = "degraded", False
issues.append("CONNECTION_INTENT_MISMATCH")
elif connection.get("verified") is True:
health_state, healthy = "healthy", True
else:
health_state, healthy = "degraded", False
issues.append("CONNECTION_NOT_VERIFIED")
if configured and local_state in {"running", "stopped"}:
issues.append("LOCAL_TOOLS_NOT_READY")
observation_ok = (status_available and configured is not None and local_state != "unavailable"
and (configured is False or (doctor_available and connection_available
and boot_available and role is not None)))
build_profile = platform.get("artifact_profile_id") or platform.get("profile_id")
missing = diagnosis.get("missing_commands")
missing = sorted({v for v in missing if isinstance(v, str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.+-]{0,63}", v)}) if isinstance(missing, list) else []
notes = ["This is a local profile observation, not a fleet scan.",
"Status and prerequisites are sampled sequentially; this is not an atomic snapshot.",
"No FBEXEC/FBTERM command, screen capture or input probe was performed."]
if role == "node":
notes.append("The central computer provides the ChatGPT connection; this report does not verify that central connection.")
if configured is False:
notes.append("This profile is not configured. No profile or connection was created.")
if kind == "text":
notes.append("Text mode does not require a graphical session.")
return {
"schema_version": 1, "ok": observation_ok, "read_only": True,
"observed_at": observed_at or datetime.now(timezone.utc).isoformat(),
"effective_target": {"selector": "LOCAL", "host": _name(host),
"profile_name": _name(status.get("name") or local.get("name")),
"role": role, "node_id": _node_id(local.get("node_id"))},
"software": {"cli_version": __version__, "runtime_version": None,
"runtime_version_status": "not_reported"},
"health": {"state": health_state, "healthy": healthy, "issues": issues},
"runtime": {"state": local_state, "running": _boolean(local.get("running")) if configured else None,
"ready": _boolean(local.get("ready")) if configured else None,
"availability": {k: _boolean(capabilities.get(k)) for k in _CAPABILITIES},
"operational_probe_performed": False},
"connection": {"state": "managed_by_central" if role == "node" else phase,
"verified": None if role == "node" else _boolean(connection.get("verified")),
"desired": intent},
"boot": {"policy": _boot_state(boot),
"service_state": _choice(boot.get("service_state"), _SERVICES)},
"prerequisites": {"state": "not_run" if configured is False else ("passed" if diagnostics_ok is True else "needs_attention" if diagnostics_ok is False else "unavailable"),
"compatible": _boolean(platform.get("ok")) if doctor_available else None,
"profile_id": _name(build_profile),
"validation_status": _choice(platform.get("validation_status"), {"candidate", "release_build", "validated", "source-unvalidated"}, "not_reported"),
"missing_commands": missing,
"issue_count": _count(diagnosis.get("issues")),
"compatibility_issue_count": _count(platform.get("issues")),
"availability": {k: _boolean(prerequisite_capabilities.get(k)) for k in _CAPABILITIES}},
"desktop": {"kind": kind, "graphical_available": _boolean(capabilities.get("graphical_cua")),
"capture_verified_by_this_report": False, "input_verified_by_this_report": False},
"notes": notes,
}
def collect(state):
"""Observe only the selected local profile without creating or repairing it."""
state = state_path(state)
status_snapshot, diagnosis = None, None
status_available = doctor_available = True
try:
status_snapshot = lifecycle.status(state)
except Exception:
status_available = False
if _mapping(status_snapshot).get("configured") is True:
try:
diagnosis = runtime.doctor(state)
except Exception:
doctor_available = False
else:
doctor_available = False
try:
host = socket.gethostname()
except OSError:
host = None
return build_report(status_snapshot, diagnosis, host=host,
status_available=status_available, doctor_available=doctor_available)
def render(report):
"""Render the same allowlisted report for a human terminal."""
target = report["effective_target"]
software = report["software"]
state = report["health"]["state"]
lines = ["FrameBridge " + software["cli_version"] + " — health: " + state,
"Target: LOCAL = this computer (" + (target["host"] or "hostname unavailable") + ")",
"Profile: " + (target["profile_name"] or "not available") + " | role: " + (target["role"] or "not available")]
if target["node_id"]:
lines.append("Node ID: " + target["node_id"])
lines.extend(["Observed: " + report["observed_at"],
"Local tools: " + report["runtime"]["state"],
"Connection: " + ("connected (verified)" if report["connection"]["verified"] is True else report["connection"]["state"]),
"Connection intent: " + report["connection"]["desired"],
"Automatic startup: " + report["boot"]["policy"],
"Current service: " + report["boot"]["service_state"],
"Prerequisites: " + report["prerequisites"]["state"]])
if report["prerequisites"]["profile_id"]:
lines.append("Build profile: " + report["prerequisites"]["profile_id"])
lines.append("Desktop: " + report["desktop"]["kind"] + " (capture/input not tested)")
for issue in report["health"]["issues"]:
lines.append("Finding: " + issue)
if report["prerequisites"]["missing_commands"]:
lines.append("Missing commands: " + ", ".join(report["prerequisites"]["missing_commands"]))
if state in {"degraded", "unavailable"}:
lines.append("Inspect details locally: fb status; fb doctor")
lines.append("Runtime version: not reported; the CLI version is not proof of the running version.")
lines.extend(report["notes"])
return "\n".join(lines)
@@ -0,0 +1,22 @@
"""Run FrameBridge's HTTP MCP endpoints without optional WebSocket detection.
The FastMCP app owns its routes, middleware and session-manager lifespan.
Other FrameBridge transports do not use this helper.
"""
def run_streamable_mcp(server):
import anyio
import uvicorn
async def serve():
config = uvicorn.Config(
server.streamable_http_app(),
host=server.settings.host,
port=server.settings.port,
log_level=server.settings.log_level.lower(),
ws="none",
)
await uvicorn.Server(config).serve()
anyio.run(serve)
@@ -5,7 +5,9 @@ import json
import os import os
from pathlib import Path from pathlib import Path
import sys import sys
import subprocess
from . import display_context as display from . import display_context as display
from . import x11_text
META = ("input_may_have_been_sent", "do_not_replay", "performed", "display_context") META = ("input_may_have_been_sent", "do_not_replay", "performed", "display_context")
@@ -17,12 +19,15 @@ def _input_primitive(argv):
"getwindowgeometry", "getwindowname", "getwindowpid", "getwindowgeometry", "getwindowname", "getwindowpid",
"search", "version", "help"} "search", "version", "help"}
def install_cua_guard(cua, *, env=None, checker=None): def install_cua_guard(cua, *, env=None, checker=None, text_sender=None):
env = os.environ if env is None else env env = os.environ if env is None else env
checker = display.check if checker is None else checker checker = display.check if checker is None else checker
original_run = cua._run original_run = cua._run
original_handle = cua.handle_cua_message original_handle = cua.handle_cua_message
state = {"active": False, "input_started": False, "failed_code": ""} original_context = getattr(cua, "_action_context", None)
text_sender = x11_text.bounded_type_text if text_sender is None else text_sender
state = {"active": False, "input_started": False, "failed_code": "", "action_context": None,
"type_keys": False}
def verify(): def verify():
try: try:
@@ -31,11 +36,60 @@ def install_cua_guard(cua, *, env=None, checker=None):
state["failed_code"] = str(exc) state["failed_code"] = str(exc)
raise cua.CuaTransportError(str(exc), str(exc)) from None raise cua.CuaTransportError(str(exc), str(exc)) from None
if original_context is not None:
def guarded_context(message):
context = original_context(message)
if (not isinstance(message, dict) or str(message.get("action", "")).strip().upper() != "TYPE"
or str(message.get("type_mode", "KEYS")).upper().strip() != "KEYS"):
return context
# Bind the original authorization/window/geometry gate, including
# when the caller omitted an explicit expected window identifier.
try:
bound = (int(context["before_id"]),
(int(context["width"]), int(context["height"])))
if bound[0] <= 0 or min(bound[1]) <= 0:
raise ValueError()
state["action_context"] = bound
except (KeyError, TypeError, ValueError):
state["failed_code"] = "TYPE_PRIMITIVE_UNSUPPORTED"
raise cua.CuaTransportError("TYPE_PRIMITIVE_UNSUPPORTED", "TYPE_PRIMITIVE_UNSUPPORTED") from None
return context
cua._action_context = guarded_context
def before_input():
verify()
state["input_started"] = True
def text_primitive(argv, timeout):
args = list(argv)
context = state.get("action_context")
if (len(args) != 7 or args[:6] != ["xdotool", "type", "--clearmodifiers", "--delay", "10", "--"]
or type(args[6]) is not str or context is None or not state.get("active")):
state["failed_code"] = "TYPE_PRIMITIVE_UNSUPPORTED"
raise cua.CuaTransportError("TYPE_PRIMITIVE_UNSUPPORTED", "TYPE_PRIMITIVE_UNSUPPORTED")
try:
text_sender(args[6], expected_window=context[0], expected_geometry=context[1],
before_input=before_input, timeout=timeout)
except x11_text.TextError as error:
state["failed_code"] = str(error)
raise cua.CuaTransportError(str(error), str(error)) from None
except cua.CuaTransportError:
raise
except Exception:
state["failed_code"] = "TYPE_NATIVE_FAILURE"
raise cua.CuaTransportError("TYPE_NATIVE_FAILURE", "TYPE_NATIVE_FAILURE") from None
return subprocess.CompletedProcess(args, 0, stdout="", stderr="")
def guard_run(run, track_input): def guard_run(run, track_input):
def guarded(argv, timeout=15): def guarded(argv, timeout=15):
# The captured pointer helper calls its base runner after an internal # The captured pointer helper calls its base runner after an internal
# query. Guard that final runner too, immediately before subprocess. # query. Guard that final runner too, immediately before subprocess.
verify() verify()
args = list(argv)
if (track_input and len(args) >= 2 and Path(str(args[0])).name == "xdotool"
and (str(args[1]).lower() == "type" or
(state["type_keys"] and any(str(arg).lower() == "type" for arg in args[1:])))):
return text_primitive(args, timeout)
if track_input and _input_primitive(argv): if track_input and _input_primitive(argv):
state["input_started"] = True state["input_started"] = True
return run(argv, timeout=timeout) return run(argv, timeout=timeout)
@@ -47,7 +101,10 @@ def install_cua_guard(cua, *, env=None, checker=None):
guarded_run = guard_run(original_run, primitive_run is None) guarded_run = guard_run(original_run, primitive_run is None)
def guarded_handle(message): def guarded_handle(message):
state.update(active=True, input_started=False, failed_code="") state.update(active=True, input_started=False, failed_code="", action_context=None)
state["type_keys"] = (isinstance(message, dict)
and str(message.get("action", "")).strip().upper() == "TYPE"
and str(message.get("type_mode", "KEYS")).upper().strip() == "KEYS")
verify() verify()
result = original_handle(message) result = original_handle(message)
verify() verify()
+65 -4
View File
@@ -1,18 +1,27 @@
"""Original FBPAIR identity and outbound protocol for an explicitly enrolled node.""" """Original FBPAIR identity and outbound protocol for an explicitly enrolled node."""
from __future__ import annotations from __future__ import annotations
import errno
import hashlib import hashlib
import http.client
import importlib import importlib
import json import json
import logging
import os import os
import socket import socket
import ssl
import time import time
import uuid import uuid
import urllib.error
from pathlib import Path from pathlib import Path
from . import broker from . import broker
_LOG = logging.getLogger(__name__)
_RETRY_MAX_SECONDS = 30
class NodeError(ValueError): class NodeError(ValueError):
pass pass
@@ -177,6 +186,54 @@ def dispatch_request(request, adapter_request=None, state_dir=None):
return {"ok": False, "error": "NODE_DISPATCH_FAILED"} return {"ok": False, "error": "NODE_DISPATCH_FAILED"}
def _retryable_broker_failure(exc):
# post_json intentionally hides transport details from its public error. Its
# exception context still distinguishes interruptions from TLS/JSON failures;
# never log that context or treat an unknown failure as transient.
code = str(exc)
if code in {"BROKER_HTTP_REJECTED_502", "BROKER_HTTP_REJECTED_503", "BROKER_HTTP_REJECTED_504"}:
return True
if code != "BROKER_TRANSPORT_FAILED":
return False
cause = exc.__context__
if isinstance(cause, urllib.error.URLError):
cause = cause.reason
if isinstance(cause, ssl.SSLError):
return False
if isinstance(cause, socket.gaierror):
return cause.errno == socket.EAI_AGAIN
if isinstance(cause, (ConnectionError, TimeoutError, http.client.IncompleteRead)):
return True
return isinstance(cause, OSError) and cause.errno in {
errno.ECONNREFUSED, errno.ECONNRESET, errno.ECONNABORTED, errno.ETIMEDOUT,
errno.EPIPE, errno.ENETUNREACH, errno.EHOSTUNREACH, errno.ENETDOWN,
}
def _post_with_recovery(url, operation, payload, *, ctx, state, outbound, recover):
delay = 1
retries = 0
while True:
# A new signature/nonce is mandatory: the previous attempt may have been
# verified even when its response was lost. Do not replay its envelope.
envelope = outbound._signed_envelope(ctx, operation, payload)
try:
result = broker.post_json(url, envelope, state_dir=state)
except broker.BrokerError as exc:
if not recover or not _retryable_broker_failure(exc):
raise
retries += 1
_LOG.warning("NODE_BROKER_RETRY operation=%s error=%s delay_seconds=%d",
operation, str(exc), delay)
# Signals and cancellation exceptions are deliberately not caught.
time.sleep(delay)
delay = min(delay * 2, _RETRY_MAX_SECONDS)
else:
if retries:
_LOG.warning("NODE_BROKER_RECOVERED operation=%s retries=%d", operation, retries)
return result
def run(state_dir=None, once=False): def run(state_dir=None, once=False):
state, config, ctx, identity = _context(state_dir) state, config, ctx, identity = _context(state_dir)
url = broker.validate_endpoint(config.get("broker_url", "")) url = broker.validate_endpoint(config.get("broker_url", ""))
@@ -187,16 +244,20 @@ def run(state_dir=None, once=False):
raise NodeError("PAIRING_BINDING_MISMATCH") raise NodeError("PAIRING_BINDING_MISMATCH")
outbound = _original("outbound_agent") outbound = _original("outbound_agent")
while True: while True:
poll = broker.post_json(url + "/fbpair/v1/node/poll", poll = _post_with_recovery(url + "/fbpair/v1/node/poll", "node.poll", {},
outbound._signed_envelope(ctx, "node.poll", {}), state_dir=state) ctx=ctx, state=state, outbound=outbound, recover=not once)
if poll.get("ok") is not True: if poll.get("ok") is not True:
raise NodeError("NODE_POLL_REJECTED") raise NodeError("NODE_POLL_REJECTED")
req = poll.get("request") req = poll.get("request")
result = {"ok": True, "idle": True} result = {"ok": True, "idle": True}
if isinstance(req, dict): if isinstance(req, dict):
value = dispatch_request(req, state_dir=state) value = dispatch_request(req, state_dir=state)
posted = broker.post_json(url + "/fbpair/v1/node/result", outbound._signed_envelope( # Original put_result only records this request's result; repeating
ctx, "node.result", {"request_id": req.get("request_id"), "result": value}), state_dir=state) # that acknowledgement cannot execute the command. Keep the value
# here and never return to dispatch/poll while its delivery is pending.
posted = _post_with_recovery(url + "/fbpair/v1/node/result", "node.result",
{"request_id": req.get("request_id"), "result": value},
ctx=ctx, state=state, outbound=outbound, recover=not once)
if posted.get("ok") is not True: if posted.get("ok") is not True:
raise NodeError("NODE_RESULT_REJECTED") raise NodeError("NODE_RESULT_REJECTED")
result = {"ok": True, "idle": False, "request_id": req.get("request_id")} result = {"ok": True, "idle": False, "request_id": req.get("request_id")}
@@ -0,0 +1,440 @@
"""Resume only previously running installed profiles after an APT upgrade.
The pre-install snapshot never stops the process carrying APT. The configure
hook schedules an isolated system service; it waits for dpkg to release its
lock before restarting the captured profiles as their original Linux account.
No credentials, boot policy or unrelated services are changed.
"""
from __future__ import annotations
import argparse
from contextlib import contextmanager
import fcntl
import hashlib
import json
import os
from pathlib import Path
import pwd
import re
import stat
import subprocess
import sys
import tempfile
import time
ROOT = Path('/var/lib/framebridge-expanded/package-upgrade')
MODULE = '/usr/lib/framebridge-expanded/expanded/framebridge_expanded/package_upgrade.py'
PREFIX = '/usr/lib/framebridge-expanded'
PYTHONS = {'/usr/bin/python3', '/usr/bin/python3.12', '/usr/bin/python3.13', '/usr/bin/python3.14'}
ENV = {'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8'}
UNIT = re.compile(r'framebridge-(?:autostart-[0-9a-f]{16}|profile-[0-9a-f]{20}|qa-[A-Za-z0-9-]{1,100})\.service\Z')
MAXIMUM = 2 * 1024 * 1024
def _need(value, message):
if not value:
raise RuntimeError(message)
def _read(path, uid, maximum=MAXIMUM):
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
try:
info = os.fstat(fd)
_need(stat.S_ISREG(info.st_mode) and info.st_uid == uid
and info.st_nlink == 1 and not info.st_mode & 0o077
and info.st_size <= maximum, 'UPGRADE_FILE_UNTRUSTED')
data = os.read(fd, maximum + 1)
_need(len(data) <= maximum, 'UPGRADE_FILE_TOO_LARGE')
return data
finally:
os.close(fd)
def _private(path, uid):
info = path.lstat()
_need(stat.S_ISDIR(info.st_mode) and info.st_uid == uid
and stat.S_IMODE(info.st_mode) == 0o700, 'UPGRADE_DIRECTORY_UNTRUSTED')
def _root():
_need(os.getuid() == 0 and os.geteuid() == 0, 'UPGRADE_ROOT_REQUIRED')
for parent in [Path('/var/lib'), Path('/var/lib/framebridge-expanded')]:
if not parent.exists():
parent.mkdir(mode=0o700)
info = parent.lstat()
_need(stat.S_ISDIR(info.st_mode) and info.st_uid == 0
and not info.st_mode & 0o022, 'UPGRADE_ROOT_PARENT_UNTRUSTED')
ROOT.mkdir(mode=0o700, exist_ok=True)
_private(ROOT, 0)
def _write(path, value):
fd, name = tempfile.mkstemp(prefix='.upgrade-', dir=path.parent)
try:
with os.fdopen(fd, 'wb') as stream:
os.fchmod(stream.fileno(), 0o600)
stream.write((json.dumps(value, indent=2, sort_keys=True) + '\n').encode())
stream.flush()
os.fsync(stream.fileno())
os.replace(name, path)
finally:
if os.path.exists(name):
os.unlink(name)
def _birth(proc):
fields = (proc / 'stat').read_text().rsplit(') ', 1)[1].split()
return fields[19] if fields[0] not in {'Z', 'X'} else None
def identify(proc):
args = [part.decode() for part in (proc / 'cmdline').read_bytes().rstrip(b'\0').split(b'\0')]
if (len(args) != 6 or args[0] not in PYTHONS
or args[1:5] != ['-m', 'framebridge_expanded.runtime', '_manager', '--state']):
return None
env = dict(x.split(b'=', 1) for x in (proc / 'environ').read_bytes().split(b'\0') if b'=' in x)
if (env.get(b'FRAMEBRIDGE_EXPANDED_PREFIX') != PREFIX.encode()
or env.get(b'FRAMEBRIDGE_EXPANDED_STATE') != args[5].encode()
or env.get(b'PYTHONPATH', b'').split(b':')[0] != (PREFIX + '/expanded').encode()):
return None
uid = proc.stat().st_uid
_need(uid != 0, 'UPGRADE_ROOT_PROFILE_REFUSED')
state = Path(args[5])
_need(state.is_absolute() and str(state) == os.path.normpath(str(state))
and not any(ord(c) < 32 for c in str(state)), 'UPGRADE_PROFILE_PATH_INVALID')
for parent in [state, *state.parents]:
_need(not parent.is_symlink(), 'UPGRADE_PROFILE_SYMLINK_REFUSED')
_private(state, uid)
birth = _birth(proc)
_need(birth is not None, 'UPGRADE_MANAGER_NOT_ALIVE')
record = json.loads(_read(state / 'manager.json', uid))
_need(record.get('pid') == int(proc.name) and record.get('birth') == birth
and record.get('uid') == uid, 'UPGRADE_MANAGER_RECORD_MISMATCH')
account = pwd.getpwuid(uid)
config = json.loads(_read(state / 'config.json', uid))
_need(config.get('role') in {'node', 'central', 'standalone'}, 'UPGRADE_CONFIG_INVALID')
group = (proc / 'cgroup').read_text().splitlines()
units = [part for line in group for part in line.split('/') if UNIT.fullmatch(part)]
_need(len(set(units)) <= 1, 'UPGRADE_MANAGER_UNIT_AMBIGUOUS')
return {'uid': uid, 'user': account.pw_name, 'home': account.pw_dir,
'state': str(state), 'pid': int(proc.name), 'birth': birth,
'config_sha256': hashlib.sha256(_read(state / 'config.json', uid)).hexdigest(),
'node_id': config.get('node_id'), 'unit': next(iter(set(units)), None),
'phase': 'CAPTURED'}
def snapshot(proc_root=Path('/proc')):
_root()
rows = []
for proc in proc_root.iterdir():
if not proc.name.isdigit():
continue
try:
row = identify(proc)
except (FileNotFoundError, ProcessLookupError, PermissionError, UnicodeError, IndexError):
continue
if row:
rows.append(row)
keys = [(r['uid'], r['state']) for r in rows]
_need(len(keys) == len(set(keys)), 'UPGRADE_DUPLICATE_PROFILE_MANAGERS')
value = {'schema': 1, 'phase': 'CAPTURED', 'profiles': rows,
'transaction': os.urandom(8).hex(),
'captured_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
'boot_policy_changed': False, 'credentials_changed': False}
pending = ROOT / 'pending.json'
if pending.exists() or pending.is_symlink():
previous = json.loads(_read(pending, 0))
_need(previous.get('phase') not in {'SCHEDULED', 'RUNNING'}, 'UPGRADE_PREVIOUS_WORKER_PENDING')
_write(ROOT / 'previous.json', previous)
_write(pending, value)
return value
def schedule():
_root()
pending = ROOT / 'pending.json'
if not pending.exists():
return {'phase': 'NO_UPGRADE_SNAPSHOT'}
value = json.loads(_read(pending, 0))
if value.get('phase') in {'COMPLETE', 'SCHEDULED', 'RUNNING'}:
return value
_need(value.get('phase') == 'CAPTURED', 'UPGRADE_REVIEW_PREVIOUS_FAILURE')
if not value['profiles']:
value['phase'] = 'COMPLETE'
_write(pending, value)
return value
transaction = value.get('transaction', '')
_need(re.fullmatch(r'[a-f0-9]{16}', transaction), 'UPGRADE_TRANSACTION_INVALID')
value['phase'] = 'SCHEDULED'
_write(pending, value)
try:
result = subprocess.run([
'/usr/bin/systemd-run', '--quiet', '--collect',
'--unit=framebridge-package-upgrade-' + transaction, '--on-active=2s',
'--timer-property=RemainAfterElapse=no',
'--property=Type=oneshot', '--property=TimeoutStartSec=600',
'/usr/bin/python3', '-I', '-S', MODULE, 'resume'],
env=ENV, capture_output=True, timeout=15)
_need(result.returncode == 0, 'UPGRADE_WORKER_SCHEDULE_FAILED')
except BaseException:
value['phase'] = 'CAPTURED'
_write(pending, value)
raise
return value
@contextmanager
def _wait_dpkg(timeout=180):
"""Hold APT/dpkg locks throughout bounded runtime replacement."""
deadline = time.monotonic() + timeout
descriptors = []
held = []
try:
for name in ('lock-frontend', 'lock'):
descriptors.append(os.open('/var/lib/dpkg/' + name, os.O_RDWR | os.O_NOFOLLOW))
while True:
try:
for fd in descriptors:
fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
held.append(fd)
break
except BlockingIOError:
for fd in held:
fcntl.lockf(fd, fcntl.LOCK_UN)
held = []
_need(time.monotonic() < deadline, 'UPGRADE_DPKG_LOCK_TIMEOUT')
time.sleep(0.25)
yield
finally:
for fd in held:
fcntl.lockf(fd, fcntl.LOCK_UN)
for fd in descriptors:
os.close(fd)
def _owner(row, operation):
account = pwd.getpwuid(row['uid'])
_need(row['uid'] != 0 and account.pw_name == row['user']
and account.pw_dir == row['home'], 'UPGRADE_ACCOUNT_CHANGED')
args = ['/usr/sbin/runuser', '-u', account.pw_name, '--', '/usr/bin/env', '-i',
'HOME=' + account.pw_dir, 'USER=' + account.pw_name, 'LOGNAME=' + account.pw_name,
'PATH=/usr/bin:/bin', 'LANG=C.UTF-8', 'LC_ALL=C.UTF-8',
'XDG_RUNTIME_DIR=/run/user/' + str(row['uid']),
'DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/' + str(row['uid']) + '/bus',
'/usr/bin/python3', '-I', '-S', MODULE, operation,
'--state', row['state'], '--config-sha256', row['config_sha256'],
'--manager-pid', str(row['pid']), '--manager-birth', row['birth']]
background = row.get('background')
if background:
args += ['--connection-pid', str(background['pid']),
'--connection-birth', str(background['start_ticks'])]
result = subprocess.run(args, env=ENV, capture_output=True, text=True, timeout=120)
_need(result.returncode == 0, 'UPGRADE_OWNER_OPERATION_FAILED:' + operation)
value = json.loads(result.stdout)
_need(value.get('ok') is True, 'UPGRADE_OWNER_NOT_READY:' + operation)
return value
def _start_manual(row):
unit = 'framebridge-profile-' + hashlib.sha256(row['state'].encode()).hexdigest()[:20] + '.service'
base = _unit(row)[:-2] # runuser/env for the owner's actual user bus
result = subprocess.run([*base, '/usr/bin/systemd-run', '--user', '--quiet', '--collect',
'--unit=' + unit, '--property=Type=exec', '--property=ExitType=cgroup',
'--property=KillMode=control-group',
'--setenv=FRAMEBRIDGE_EXPANDED_PREFIX=' + PREFIX,
'--setenv=FRAMEBRIDGE_EXPANDED_STATE=' + row['state'],
'--setenv=HOME=' + row['home'], '--setenv=PATH=/usr/bin:/bin', '/usr/bin/fb', '--state-dir', row['state'], '--json', 'start'],
env=ENV, capture_output=True, timeout=20)
_need(result.returncode == 0, 'UPGRADE_NATIVE_USER_UNIT_START_FAILED')
return unit
def _unit(row):
account = pwd.getpwuid(row['uid'])
_need(row['uid'] != 0 and account.pw_name == row['user']
and account.pw_dir == row['home'], 'UPGRADE_ACCOUNT_CHANGED')
return ['/usr/sbin/runuser', '-u', account.pw_name, '--', '/usr/bin/env', '-i',
'HOME=' + account.pw_dir, 'PATH=/usr/bin:/bin',
'XDG_RUNTIME_DIR=/run/user/' + str(row['uid']),
'DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/' + str(row['uid']) + '/bus',
'/usr/bin/systemctl', '--user']
def _resume():
_root()
pending = ROOT / 'pending.json'
value = json.loads(_read(pending, 0))
_need(value.get('schema') == 1 and value.get('phase') == 'SCHEDULED', 'UPGRADE_INTENT_INVALID')
value['phase'] = 'RUNNING'
_write(pending, value)
failures = []
for row in value['profiles']:
try:
current = identify(Path('/proc') / str(row['pid']))
_need(current and all(current[k] == row[k] for k in
('uid', 'user', 'home', 'state', 'pid', 'birth', 'config_sha256', 'unit')),
'UPGRADE_MANAGER_IDENTITY_CHANGED')
# Migration runs as the profile owner and only recognizes the old
# FrameBridge desktop entry; no third-party launcher is overwritten.
try:
row['desktop'] = _owner(row, 'user-desktop')
except Exception as desktop_error:
row['desktop'] = {'ok': False, 'warning': str(desktop_error)[:160]}
if row['unit']:
current = identify(Path('/proc') / str(row['pid']))
_need(current and all(current[k] == row[k] for k in
('uid', 'state', 'pid', 'birth', 'config_sha256', 'unit')),
'UPGRADE_MANAGER_IDENTITY_CHANGED')
_need(UNIT.fullmatch(row['unit']), 'UPGRADE_UNIT_INVALID')
result = subprocess.run([*_unit(row), 'restart', row['unit']],
env=ENV, capture_output=True, timeout=100)
_need(result.returncode == 0, 'UPGRADE_UNIT_RESTART_FAILED')
row['runtime'] = _owner(row, 'user-ready')
else:
stopped = _owner(row, 'user-stop')
row['background'] = stopped.get('background')
row['unit'] = _start_manual(row)
row['runtime'] = _owner(row, 'user-ready')
row['phase'] = 'READY'
except Exception as error:
row['phase'] = 'FAILED'
row['error'] = str(error)[:160]
failures.append(row['state'])
_write(pending, value)
value['phase'] = 'FAILED' if failures else 'COMPLETE'
value['completed_utc'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
_write(pending, value)
_need(not failures, 'UPGRADE_PROFILE_RESTART_FAILED: inspect /var/lib/framebridge-expanded/package-upgrade/pending.json')
return value
@contextmanager
def _resume_lock():
fd = os.open(ROOT / '.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
try:
info = os.fstat(fd)
_need(stat.S_ISREG(info.st_mode) and info.st_uid == 0
and info.st_nlink == 1 and stat.S_IMODE(info.st_mode) == 0o600,
'UPGRADE_LOCK_UNTRUSTED')
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
yield
finally:
os.close(fd)
def resume():
_root()
with _resume_lock(), _wait_dpkg():
return _resume()
def _background(state):
from framebridge_expanded import connection_background as background
directory = background._directory(state, False)
record = background._read(directory / 'status.json') if directory else {}
if not record:
return None
valid = background._valid_identity(record, state)
pid = record.get('pid')
if not valid:
_need(type(pid) is not int or not Path('/proc', str(pid)).exists(),
'UPGRADE_BACKGROUND_IDENTITY_UNVERIFIED')
return None
return {'pid': pid, 'start_ticks': record['start_ticks'],
'verified': background.status(state)['verified'],
'preserved_in_memory': True, 'process_reloaded': False}
def user_operation(operation, state, config_sha256, *, manager_pid=None, manager_birth=None,
connection_pid=None, connection_birth=None):
_need(os.getuid() != 0 and os.getuid() == os.geteuid(), 'UPGRADE_OWNER_REQUIRED')
state = Path(state)
_private(state, os.getuid())
data = _read(state / 'config.json', os.getuid())
_need(hashlib.sha256(data).hexdigest() == config_sha256, 'UPGRADE_CONFIG_CHANGED')
sys.path.insert(0, PREFIX + '/expanded')
if operation == 'user-desktop':
from framebridge_expanded.console_desktop import migrate_legacy
return migrate_legacy(state)
from framebridge_expanded import runtime
if operation == 'user-stop':
from framebridge_expanded import autostart
def restart(value):
# runtime.stop writes a manual-stop marker. Retain its former bytes
# even when stopping fails; upgrading must not change boot intent.
marker = state / 'autostart/manual-stop.json'
before = _read(marker, os.getuid()) if os.path.lexists(marker) else None
manager = json.loads(_read(state / 'manager.json', os.getuid()))
_need(manager.get('pid') == manager_pid and manager.get('birth') == manager_birth
and _birth(Path('/proc') / str(manager_pid)) == manager_birth,
'UPGRADE_MANAGER_IDENTITY_CHANGED')
background = _background(state)
try:
current = runtime.stop(state, preserve_connection=True)
deadline = time.monotonic() + 20
while current.get('running') or any(x.get('alive') for x in current.get('processes', [])):
_need(time.monotonic() < deadline, 'UPGRADE_OLD_RUNTIME_STOP_TIMEOUT')
time.sleep(0.1)
current = runtime.status(state)
finally:
if before is None:
marker.unlink(missing_ok=True)
else:
fd, temporary = tempfile.mkstemp(prefix='.upgrade-stop-', dir=marker.parent)
with os.fdopen(fd, 'wb') as stream:
os.fchmod(stream.fileno(), 0o600)
stream.write(before)
os.replace(temporary, marker)
return {'ok': True, 'stopped': True, 'background': background,
'boot_policy_changed': False}
return autostart._configuration_action(state, restart)
else:
deadline = time.monotonic() + 60
while True:
result = runtime.status(state)
manager = json.loads(_read(state / 'manager.json', os.getuid()))
if (result.get('running') and result.get('ready')
and (manager.get('pid'), manager.get('birth')) != (manager_pid, manager_birth)):
break
_need(time.monotonic() < deadline, 'UPGRADE_NEW_RUNTIME_NOT_READY')
time.sleep(0.25)
_need(result.get('running') is True and result.get('ready') is True, 'UPGRADE_NEW_RUNTIME_NOT_READY')
background = _background(state)
if connection_pid:
_need(background and background['pid'] == connection_pid
and background['start_ticks'] == connection_birth, 'UPGRADE_BACKGROUND_IDENTITY_CHANGED')
return {'ok': True, 'ready': True, 'running': True, 'background': background,
'manager': json.loads(_read(state / 'manager.json', os.getuid())),
'boot_policy_changed': False}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('operation', choices=['snapshot', 'schedule', 'resume',
'user-stop', 'user-ready', 'user-desktop'])
parser.add_argument('--state')
parser.add_argument('--config-sha256')
parser.add_argument('--manager-pid', type=int)
parser.add_argument('--manager-birth')
parser.add_argument('--connection-pid', type=int)
parser.add_argument('--connection-birth', type=int)
args = parser.parse_args(argv)
try:
if args.operation.startswith('user-'):
value = user_operation(args.operation, args.state, args.config_sha256,
manager_pid=args.manager_pid, manager_birth=args.manager_birth,
connection_pid=args.connection_pid, connection_birth=args.connection_birth)
else:
value = globals()[args.operation]()
print(json.dumps(value))
return 0
except Exception as error:
print('FrameBridge package upgrade: ' + str(error), file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())
@@ -32,7 +32,7 @@ import zlib
MAX_PIXELS = 4096 * 2160 MAX_PIXELS = 4096 * 2160
MAX_FRAMEBUFFER_BYTES = 128 * 1024 * 1024 MAX_FRAMEBUFFER_BYTES = 128 * 1024 * 1024
MAX_IMAGE_BYTES = 32 * 1024 * 1024 MAX_IMAGE_BYTES = 32 * 1024 * 1024
MAX_CHUNK_BYTES = 2400 MAX_CHUNK_BYTES = 45000
CAPTURE_ID = re.compile(r"[0-9a-f]{32}\Z") CAPTURE_ID = re.compile(r"[0-9a-f]{32}\Z")
SESSION_ID = re.compile(r"[A-Za-z0-9_-]{1,64}\Z") SESSION_ID = re.compile(r"[A-Za-z0-9_-]{1,64}\Z")
DISPLAY_ID = re.compile(r":[0-9]{1,5}(?:\.[0-9]{1,3})?\Z") DISPLAY_ID = re.compile(r":[0-9]{1,5}(?:\.[0-9]{1,3})?\Z")
@@ -13,12 +13,17 @@ import math
import re import re
import shlex import shlex
import struct import struct
import time
import uuid import uuid
from typing import Callable from typing import Callable
CHUNK_BYTES = 2100 CHUNK_BYTES = 2100
MAX_IMAGE_BYTES = 32 * 1024 * 1024 MAX_IMAGE_BYTES = 32 * 1024 * 1024
MAX_STDOUT_BYTES = 4096 MAX_STDOUT_BYTES = 4096
# Only the explicit typed screen transport preserves this larger stdout budget.
EXPANDED_STDOUT_BYTES = 65536
EXPANDED_CHUNK_BYTES = 45000
TRANSFER_TIMEOUT_SECONDS = 45
RPC_COMMAND = '/usr/bin/python3 -B -m framebridge_expanded.real_screen.screen_rpc ' RPC_COMMAND = '/usr/bin/python3 -B -m framebridge_expanded.real_screen.screen_rpc '
HEX_ID = re.compile(r"[0-9a-f]{32}\Z") HEX_ID = re.compile(r"[0-9a-f]{32}\Z")
SHA = re.compile(r"[0-9a-f]{64}\Z") SHA = re.compile(r"[0-9a-f]{64}\Z")
@@ -28,14 +33,25 @@ ERROR = re.compile(r"[A-Z0-9_]{1,100}\Z")
READ = {"readOnlyHint": True, "destructiveHint": False, READ = {"readOnlyHint": True, "destructiveHint": False,
"idempotentHint": False, "openWorldHint": False} "idempotentHint": False, "openWorldHint": False}
CHANGE = {**READ, "readOnlyHint": False} CHANGE = {**READ, "readOnlyHint": False}
# These explicit backend checks run before an input capture is consumed. Generic
# dispatch failures do not prove whether input was sent, even with valid framing.
INPUT_REJECTION_CODES = {
"REQUEST_INVALID", "OPERATION_INVALID", "UNKNOWN_REQUEST_FIELDS",
"SESSION_ID_INVALID", "KEY_INVALID", "TEXT_INVALID", "CAPTURE_ID_INVALID",
"CAPTURE_RECORD_INVALID", "CAPTURE_RECORD_MISMATCH",
"USE_GRAPHICAL_CUA_FOR_X11_INPUT", "VISIBLE_INPUT_UNAVAILABLE",
"CAPTURE_ALREADY_USED_OBSERVE_AGAIN", "SESSION_NOT_VISIBLE",
}
class ScreenError(RuntimeError): class ScreenError(RuntimeError):
"""A bounded public error suitable for the MCP response.""" """A bounded public error suitable for the MCP response."""
def __init__(self, code, details=None): def __init__(self, code, details=None, *, backend_rejection=False):
super().__init__(code) super().__init__(code)
self.details = details or {} self.details = details or {}
# Local proof, never accepted from a remote JSON field.
self.backend_rejection = backend_rejection
def require(ok, code): def require(ok, code):
@@ -62,7 +78,7 @@ class _AuthenticatedReply(dict):
_IDENTITY_FIELDS = ("capture_id", "node", "sha256", "session_id", "bytes", _IDENTITY_FIELDS = ("capture_id", "node", "sha256", "session_id", "bytes",
"width", "height", "source", "screen_type", "width", "height", "source", "screen_type",
"full_screen", "authentic_pixels") "full_screen", "authentic_pixels", "max_chunk_bytes")
def __init__(self, payload, owner, node, node_id): def __init__(self, payload, owner, node, node_id):
super().__init__(payload) super().__init__(payload)
@@ -80,23 +96,51 @@ class _AuthenticatedReply(dict):
class ScreenClient: class ScreenClient:
"""Transport only; execution policy and node resolution stay in the host.""" """Transport only; execution policy and node resolution stay in the host.
The monotonic transfer deadline bounds admission of RPCs and successful
publication. It is cooperative: the executor can spend its existing bounded
transport grace on the final in-flight RPC. Late replies never become a
successful image, and no further chunks are requested after the deadline.
"""
def __init__(self, exec_request: Callable, image_factory: Callable, def __init__(self, exec_request: Callable, image_factory: Callable,
resolve_node: Callable | None = None): resolve_node: Callable | None = None, *,
stdout_limit_bytes: int = MAX_STDOUT_BYTES,
transfer_timeout_s: int = TRANSFER_TIMEOUT_SECONDS):
if (type(stdout_limit_bytes) is not int or
stdout_limit_bytes not in (MAX_STDOUT_BYTES, EXPANDED_STDOUT_BYTES)):
raise ValueError("unsupported screen stdout budget")
if type(transfer_timeout_s) is not int or not 1 <= transfer_timeout_s <= 120:
raise ValueError("screen transfer timeout must be 1..120 seconds")
self.stdout_limit_bytes = stdout_limit_bytes
self.chunk_bytes = (EXPANDED_CHUNK_BYTES
if stdout_limit_bytes == EXPANDED_STDOUT_BYTES else CHUNK_BYTES)
self.transfer_timeout_s = transfer_timeout_s
self.exec_request = exec_request self.exec_request = exec_request
self.image_factory = image_factory self.image_factory = image_factory
self.resolve_node = resolve_node or (lambda node: node.upper()) self.resolve_node = resolve_node or (lambda node: node.upper())
self._reply_owner = object() self._reply_owner = object()
def remote(self, node, request): def remote(self, node, request, *, deadline=None, submitted=None):
timeout_s = 30
if deadline is not None:
remaining = deadline - time.monotonic()
# The authenticated EXEC protocol accepts whole seconds only. Rounding
# down prevents any individual request exceeding the remaining budget.
require(remaining >= 1, "SCREEN_TRANSFER_TIMEOUT")
timeout_s = min(timeout_s, int(remaining))
payload = base64.b64encode(encode(request)).decode("ascii") payload = base64.b64encode(encode(request)).decode("ascii")
script = RPC_COMMAND + shlex.quote(payload) + "\n" script = RPC_COMMAND + shlex.quote(payload) + "\n"
digest = hashlib.sha256(script.encode("utf-8")).hexdigest() digest = hashlib.sha256(script.encode("utf-8")).hexdigest()
job_id = "cua-screen-" + uuid.uuid4().hex job_id = "cua-screen-" + uuid.uuid4().hex
outer = self.exec_request(script=script, authorized=True, timeout_s=30, if submitted is not None:
submitted["attempted"] = True
outer = self.exec_request(script=script, authorized=True, timeout_s=timeout_s,
job_id=job_id, sha256=digest, job_id=job_id, sha256=digest,
include_stdout=True, target=node) include_stdout=True, target=node)
if deadline is not None:
require(time.monotonic() < deadline, "SCREEN_TRANSFER_TIMEOUT")
require(type(outer) is dict, "SCREEN_EXEC_FAILED") require(type(outer) is dict, "SCREEN_EXEC_FAILED")
require(type(outer.get("exit_code")) in (int, str) and require(type(outer.get("exit_code")) in (int, str) and
str(outer["exit_code"]) in ("0", "1"), "SCREEN_EXEC_FAILED") str(outer["exit_code"]) in ("0", "1"), "SCREEN_EXEC_FAILED")
@@ -109,7 +153,7 @@ class ScreenClient:
except (ValueError, AttributeError): except (ValueError, AttributeError):
raise ScreenError("SCREEN_EXEC_NODE_ID_INVALID") from None raise ScreenError("SCREEN_EXEC_NODE_ID_INVALID") from None
stdout = outer.get("stdout") stdout = outer.get("stdout")
require(type(stdout) is str and len(stdout.encode("utf-8")) <= MAX_STDOUT_BYTES and require(type(stdout) is str and len(stdout.encode("utf-8")) <= self.stdout_limit_bytes and
outer.get("stdout_truncated") is not True, "SCREEN_STDOUT_INVALID") outer.get("stdout_truncated") is not True, "SCREEN_STDOUT_INVALID")
try: try:
envelope = json.loads(stdout) envelope = json.loads(stdout)
@@ -129,12 +173,19 @@ class ScreenClient:
if valid_reference(code.get("cause"), ERROR): if valid_reference(code.get("cause"), ERROR):
details["cause"] = code["cause"] details["cause"] = code["cause"]
code = code.get("code") code = code.get("code")
raise ScreenError(code if valid_reference(code, ERROR) else "SCREEN_BACKEND_FAILED", details) valid_code = valid_reference(code, ERROR)
raise ScreenError(code if valid_code else "SCREEN_BACKEND_FAILED", details,
backend_rejection=(valid_code and outer.get("ok") is False
and str(outer["exit_code"]) == "1"
and (code in INPUT_REJECTION_CODES
or details.get("performed") is False)))
require(outer.get("ok") is True and str(outer["exit_code"]) == "0", "SCREEN_EXEC_FAILED") require(outer.get("ok") is True and str(outer["exit_code"]) == "0", "SCREEN_EXEC_FAILED")
require(type(envelope.get("result")) is dict, "SCREEN_RESPONSE_INVALID") require(type(envelope.get("result")) is dict, "SCREEN_RESPONSE_INVALID")
return _AuthenticatedReply(envelope["result"], self._reply_owner, node, node_id) return _AuthenticatedReply(envelope["result"], self._reply_owner, node, node_id)
def image(self, node, manifest): def image(self, node, manifest, *, deadline=None):
if deadline is None:
deadline = time.monotonic() + self.transfer_timeout_s
capture_id = manifest.get("capture_id") capture_id = manifest.get("capture_id")
total, digest = manifest.get("bytes"), manifest.get("sha256") total, digest = manifest.get("bytes"), manifest.get("sha256")
require(valid_reference(capture_id, HEX_ID) and valid_reference(digest, SHA), require(valid_reference(capture_id, HEX_ID) and valid_reference(digest, SHA),
@@ -151,11 +202,23 @@ class ScreenClient:
"SCREEN_PIXEL_SOURCE_INVALID") "SCREEN_PIXEL_SOURCE_INVALID")
require(manifest.get("full_screen") is True and manifest.get("authentic_pixels") is True, require(manifest.get("full_screen") is True and manifest.get("authentic_pixels") is True,
"SCREEN_PIXEL_PROVENANCE_INVALID") "SCREEN_PIXEL_PROVENANCE_INVALID")
# Capability belongs to this authenticated manifest, never a shared cache.
# A new central must retain the old request schema for a frozen receiver.
chunk_bytes = CHUNK_BYTES
if "max_chunk_bytes" in manifest:
capability = manifest["max_chunk_bytes"]
require(type(capability) is int and 1 <= capability <= EXPANDED_CHUNK_BYTES,
"SCREEN_TRANSFER_CAPABILITY_INVALID")
chunk_bytes = min(self.chunk_bytes, capability)
parts = [] parts = []
count = math.ceil(total / CHUNK_BYTES) count = math.ceil(total / chunk_bytes)
for index in range(count): for index in range(count):
part = self.remote(node, {"op": "read_chunk", "capture_id": capture_id, request = {"op": "read_chunk", "capture_id": capture_id,
"index": index, "expected_sha256": digest}) "index": index, "expected_sha256": digest}
# Omit the extension entirely on constrained legacy transports.
if chunk_bytes != CHUNK_BYTES:
request["chunk_bytes"] = chunk_bytes
part = self.remote(node, request, deadline=deadline)
require(isinstance(part, _AuthenticatedReply) and require(isinstance(part, _AuthenticatedReply) and
part.bound_to(self._reply_owner, node, manifest.node_id), part.bound_to(self._reply_owner, node, manifest.node_id),
"SCREEN_CHUNK_NODE_MISMATCH") "SCREEN_CHUNK_NODE_MISMATCH")
@@ -167,12 +230,12 @@ class ScreenClient:
type(part.get("more")) is bool and part["more"] == (index + 1 < count), type(part.get("more")) is bool and part["more"] == (index + 1 < count),
"SCREEN_CHUNK_MANIFEST_INVALID") "SCREEN_CHUNK_MANIFEST_INVALID")
text = part.get("data") text = part.get("data")
require(type(text) is str and len(text) <= 2800, "SCREEN_CHUNK_INVALID") require(type(text) is str and len(text) <= 4 * math.ceil(chunk_bytes / 3), "SCREEN_CHUNK_INVALID")
try: try:
data = base64.b64decode(text, validate=True) data = base64.b64decode(text, validate=True)
except (ValueError, UnicodeError, binascii.Error): except (ValueError, UnicodeError, binascii.Error):
raise ScreenError("SCREEN_CHUNK_INVALID") from None raise ScreenError("SCREEN_CHUNK_INVALID") from None
require(len(data) == min(CHUNK_BYTES, total - index * CHUNK_BYTES), require(len(data) == min(chunk_bytes, total - index * chunk_bytes),
"SCREEN_CHUNK_LENGTH_INVALID") "SCREEN_CHUNK_LENGTH_INVALID")
parts.append(data) parts.append(data)
data = b"".join(parts) data = b"".join(parts)
@@ -181,10 +244,15 @@ class ScreenClient:
require(len(data) >= 33 and data[:8] == b"\x89PNG\r\n\x1a\n" and require(len(data) >= 33 and data[:8] == b"\x89PNG\r\n\x1a\n" and
data[12:16] == b"IHDR" and struct.unpack(">II", data[16:24]) == (width, height), data[12:16] == b"IHDR" and struct.unpack(">II", data[16:24]) == (width, height),
"SCREEN_PNG_INVALID") "SCREEN_PNG_INVALID")
require(time.monotonic() < deadline, "SCREEN_TRANSFER_TIMEOUT")
return self.image_factory(data=data, format="png") return self.image_factory(data=data, format="png")
def call(self, op, node, authorized, **kwargs): def call(self, op, node, authorized, **kwargs):
requested_node = node requested_node = node
action_confirmed = False
input_reply_received = False
submitted = {}
deadline = time.monotonic() + self.transfer_timeout_s
try: try:
require(authorized is True, "AUTHORIZED_TRUE_REQUIRED") require(authorized is True, "AUTHORIZED_TRUE_REQUIRED")
require(valid_reference(node, NODE), "SCREEN_NODE_INVALID") require(valid_reference(node, NODE), "SCREEN_NODE_INVALID")
@@ -202,11 +270,20 @@ class ScreenClient:
if op == "text": if op == "text":
require(type(kwargs.get("text")) is str and 0 < len(kwargs["text"].encode("utf-8")) <= 4096, require(type(kwargs.get("text")) is str and 0 < len(kwargs["text"].encode("utf-8")) <= 4096,
"SCREEN_TEXT_INVALID") "SCREEN_TEXT_INVALID")
manifest = self.remote(node, {"op": op, **kwargs}) manifest = self.remote(node, {"op": op, **kwargs}, deadline=deadline,
submitted=submitted)
input_reply_received = op in ("key", "text")
action = manifest.get("action")
summary = action.get("result") if type(action) is dict else None
action_confirmed = (op in ("key", "text") and type(action) is dict and
action.get("kind") == op and
action.get("previous_capture_id") == kwargs["capture_id"] and
type(summary) is dict and summary.get("performed") is True)
if op == "read_capture": if op == "read_capture":
require(manifest.get("capture_id") == kwargs["capture_id"], require(manifest.get("capture_id") == kwargs["capture_id"],
"SCREEN_CAPTURE_REFERENCE_MISMATCH") "SCREEN_CAPTURE_REFERENCE_MISMATCH")
image = self.image(node, manifest) image = self.image(node, manifest, deadline=deadline)
require(time.monotonic() < deadline, "SCREEN_TRANSFER_TIMEOUT")
metadata = {"ok": True, "node": node, "requested_node": requested_node, metadata = {"ok": True, "node": node, "requested_node": requested_node,
"node_id": manifest.node_id, "hostname": manifest["node"], "node_id": manifest.node_id, "hostname": manifest["node"],
"result": dict(manifest, transport_node=node, node_id=manifest.node_id)} "result": dict(manifest, transport_node=node, node_id=manifest.node_id)}
@@ -217,9 +294,21 @@ class ScreenClient:
metadata["result"]["authentic_pixels"] = True metadata["result"]["authentic_pixels"] = True
return [metadata, image] return [metadata, image]
except ScreenError as exc: except ScreenError as exc:
if op in ("key", "text") and (input_reply_received or
(submitted.get("attempted") and not exc.backend_rejection)):
exc.details.setdefault("observe_again", True)
if action_confirmed:
exc.details.update(performed=True, capture_consumed=True)
exc.details.setdefault("capture_id", kwargs["capture_id"])
return {"ok": False, "node": requested_node, "error": str(exc), **exc.details} return {"ok": False, "node": requested_node, "error": str(exc), **exc.details}
except Exception: except Exception:
return {"ok": False, "node": requested_node, "error": "SCREEN_BACKEND_UNAVAILABLE"} details = {}
if op in ("key", "text") and submitted.get("attempted"):
details.update(observe_again=True, capture_id=kwargs["capture_id"])
if action_confirmed:
details.update(performed=True, capture_consumed=True)
return {"ok": False, "node": requested_node,
"error": "SCREEN_BACKEND_UNAVAILABLE", **details}
def resolve_profile_node(alias): def resolve_profile_node(alias):
@@ -233,12 +322,16 @@ def resolve_profile_node(alias):
def register_framebridge_screen_tools(mcp, exec_request, *, image_factory=None, def register_framebridge_screen_tools(mcp, exec_request, *, image_factory=None,
resolve_node=None): resolve_node=None,
stdout_limit_bytes=MAX_STDOUT_BYTES,
transfer_timeout_s=TRANSFER_TIMEOUT_SECONDS):
"""Register real image tools. The host supplies its existing executor.""" """Register real image tools. The host supplies its existing executor."""
if image_factory is None: if image_factory is None:
from mcp.server.fastmcp import Image from mcp.server.fastmcp import Image
image_factory = Image image_factory = Image
client = ScreenClient(exec_request, image_factory, resolve_node) client = ScreenClient(exec_request, image_factory, resolve_node,
stdout_limit_bytes=stdout_limit_bytes,
transfer_timeout_s=transfer_timeout_s)
@mcp.tool(annotations=dict(READ)) @mcp.tool(annotations=dict(READ))
def framebridge_screen_observe(node: str, authorized: bool, def framebridge_screen_observe(node: str, authorized: bool,
@@ -13,7 +13,7 @@ import stat
import sys import sys
import time import time
from .screen_capture import CaptureError, capture_screen, read_chunk from .screen_capture import CaptureError, capture_screen, read_chunk, MAX_CHUNK_BYTES
from .screen_control import ControlError, act, bind_screen, snapshot_binding from .screen_control import ControlError, act, bind_screen, snapshot_binding
MAX_REQUEST = 16384 MAX_REQUEST = 16384
@@ -89,7 +89,10 @@ def write_record(path: Path, value, *, replace=False):
def public_manifest(manifest): def public_manifest(manifest):
# Internal path is unnecessary to use or understand a captured screen. # Internal path is unnecessary to use or understand a captured screen.
return {k: v for k, v in manifest.items() if k not in {'path', '_action_consumed'}} result = {k: v for k, v in manifest.items() if k not in {'path', '_action_consumed'}}
# An old receiver omits this capability and only accepts the original schema.
result['max_chunk_bytes'] = MAX_CHUNK_BYTES
return result
def observe(directory: Path, requested_session_id=None): def observe(directory: Path, requested_session_id=None):
@@ -138,7 +141,7 @@ def validate_request(request):
allowed = { allowed = {
'observe': {'op', 'session_id'}, 'observe': {'op', 'session_id'},
'status': {'op'}, 'status': {'op'},
'read_chunk': {'op', 'capture_id', 'index', 'expected_sha256'}, 'read_chunk': {'op', 'capture_id', 'index', 'expected_sha256', 'chunk_bytes'},
'read_capture': {'op', 'capture_id'}, 'read_capture': {'op', 'capture_id'},
'key': {'op', 'capture_id', 'session_id', 'key'}, 'key': {'op', 'capture_id', 'session_id', 'key'},
'text': {'op', 'capture_id', 'session_id', 'text'}, 'text': {'op', 'capture_id', 'session_id', 'text'},
@@ -149,6 +152,9 @@ def validate_request(request):
sid = request['session_id'] sid = request['session_id']
require(type(sid) is str and len(sid) <= 96 and re.fullmatch(r'[A-Za-z0-9_-]+', sid), 'SESSION_ID_INVALID') require(type(sid) is str and len(sid) <= 96 and re.fullmatch(r'[A-Za-z0-9_-]+', sid), 'SESSION_ID_INVALID')
if op == 'read_chunk': if op == 'read_chunk':
if 'chunk_bytes' in request:
require(type(request['chunk_bytes']) is int and
1 <= request['chunk_bytes'] <= MAX_CHUNK_BYTES, 'CHUNK_BOUNDS_INVALID')
require(type(request.get('index')) is int and request['index'] >= 0, 'CHUNK_INDEX_INVALID') require(type(request.get('index')) is int and request['index'] >= 0, 'CHUNK_INDEX_INVALID')
require(type(request.get('expected_sha256')) is str and HEX_SHA.fullmatch(request['expected_sha256']), 'EXPECTED_SHA256_REQUIRED') require(type(request.get('expected_sha256')) is str and HEX_SHA.fullmatch(request['expected_sha256']), 'EXPECTED_SHA256_REQUIRED')
if op == 'key': if op == 'key':
@@ -179,7 +185,7 @@ def dispatch(request, directory=None):
if op == 'read_chunk': if op == 'read_chunk':
require(manifest['sha256'] == request['expected_sha256'], 'CAPTURE_SHA256_MISMATCH') require(manifest['sha256'] == request['expected_sha256'], 'CAPTURE_SHA256_MISMATCH')
chunk = read_chunk(directory, manifest['capture_id'], request['index'], chunk = read_chunk(directory, manifest['capture_id'], request['index'],
chunk_bytes=2100, expected_sha256=request['expected_sha256']) chunk_bytes=request.get('chunk_bytes', 2100), expected_sha256=request['expected_sha256'])
return {'ok': True, 'result': chunk} return {'ok': True, 'result': chunk}
if manifest.get('screen_type') == 'x11': if manifest.get('screen_type') == 'x11':
raise ScreenRpcError('USE_GRAPHICAL_CUA_FOR_X11_INPUT') raise ScreenRpcError('USE_GRAPHICAL_CUA_FOR_X11_INPUT')
@@ -17,7 +17,10 @@ import signal
import subprocess import subprocess
import time import time
from .screen_frontdoor import RPC_COMMAND, MAX_STDOUT_BYTES from .screen_frontdoor import RPC_COMMAND, EXPANDED_STDOUT_BYTES
# This typed route bypasses the legacy 4096-byte administrative sanitizer.
MAX_STDOUT_BYTES = EXPANDED_STDOUT_BYTES
from .screen_rpc import MAX_REQUEST, encode, validate_request, ScreenRpcError from .screen_rpc import MAX_REQUEST, encode, validate_request, ScreenRpcError
REQUEST_TYPE = "screen_rpc" REQUEST_TYPE = "screen_rpc"
+53 -19
View File
@@ -16,6 +16,7 @@ import socket
import stat import stat
import subprocess import subprocess
import sys import sys
import threading
import time import time
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -24,16 +25,20 @@ from .config import load_config, state_path, private_dir
PREFIX = Path(os.environ.get("FRAMEBRIDGE_EXPANDED_PREFIX", Path(__file__).resolve().parents[2])) PREFIX = Path(os.environ.get("FRAMEBRIDGE_EXPANDED_PREFIX", Path(__file__).resolve().parents[2]))
_STOP = False _STOP = False
_LEGACY = None _LEGACY = None
_LEGACY_LOCK = threading.RLock()
def _legacy(): def _legacy():
global _LEGACY global _LEGACY
if _LEGACY is None: # Publish only a fully initialized module; a failed load remains retryable.
path = PREFIX / "bin/framebridge-original" with _LEGACY_LOCK:
loader = importlib.machinery.SourceFileLoader("framebridge_original_launcher", str(path)) if _LEGACY is None:
spec = importlib.util.spec_from_loader(loader.name, loader) path = PREFIX / "bin/framebridge-original"
_LEGACY = importlib.util.module_from_spec(spec) loader = importlib.machinery.SourceFileLoader("framebridge_original_launcher", str(path))
loader.exec_module(_LEGACY) spec = importlib.util.spec_from_loader(loader.name, loader)
return _LEGACY module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
_LEGACY = module
return _LEGACY
def _args(state, config): def _args(state, config):
return SimpleNamespace(state_dir=str(state), port=config["ports"]["mcp"], return SimpleNamespace(state_dir=str(state), port=config["ports"]["mcp"],
@@ -151,18 +156,18 @@ def doctor(state):
commands = ["bash", "tmux", "tee", "flock", "awk", "sha256sum", "timeout", commands = ["bash", "tmux", "tee", "flock", "awk", "sha256sum", "timeout",
"dbus-run-session", "ip", "lsblk", "systemctl", "systemd-run", "sudo", "uptime"] "dbus-run-session", "ip", "lsblk", "systemctl", "systemd-run", "sudo", "uptime"]
missing = [x for x in commands if not shutil.which(x, path=env.get("PATH"))] missing = [x for x in commands if not shutil.which(x, path=env.get("PATH"))]
from .display_context import missing_graphical_commands, VIRTUAL_COMMANDS from .display_context import missing_graphical_commands, missing_virtual_commands, VIRTUAL_INSTALL_COMMAND
graphical_missing = missing_graphical_commands(env) graphical_missing = missing_graphical_commands(env)
virtual_missing = [x for x in VIRTUAL_COMMANDS if not shutil.which(x, path=env.get("PATH"))] virtual_missing = missing_virtual_commands(env)
console_missing = [x for x in ("gnome-terminal", "xterm") if not shutil.which(x, path=env.get("PATH"))] console_missing = [x for x in ("gnome-terminal", "xterm") if not shutil.which(x, path=env.get("PATH"))]
if config.get("headless") in ("no", False): if config.get("headless") in ("no", False):
missing.extend(graphical_missing) missing.extend(graphical_missing)
elif config.get("headless") in ("yes", True): elif config.get("headless") in ("yes", True):
missing.extend(graphical_missing + virtual_missing) missing.extend(graphical_missing + virtual_missing)
os_release = platform.freedesktop_os_release() from .compatibility import assess, display_support
compatible = (sys.version_info[:2] == (3, 13) and platform.machine() == "x86_64" compatibility = assess(PREFIX)
and os_release.get("ID") == "debian" and os_release.get("VERSION_ID") == "13") compatible = compatibility["ok"]
issues = [] issues = list(compatibility["issues"])
if config["role"] == "central" and config.get("broker_listen_host", "127.0.0.1") not in ("127.0.0.1", "::1", "localhost"): if config["role"] == "central" and config.get("broker_listen_host", "127.0.0.1") not in ("127.0.0.1", "::1", "localhost"):
if not config.get("tls_cert") or not config.get("tls_key"): if not config.get("tls_cert") or not config.get("tls_key"):
issues.append("A central listener outside loopback requires a TLS certificate and private-key reference.") issues.append("A central listener outside loopback requires a TLS certificate and private-key reference.")
@@ -173,14 +178,19 @@ def doctor(state):
display_context = describe(env) display_context = describe(env)
if config.get("headless") in ("no", False) and display_context.get("kind") != "physical-x11": if config.get("headless") in ("no", False) and display_context.get("kind") != "physical-x11":
issues.append("No verified active physical X11 session. Log in graphically or explicitly choose a virtual headless display.") issues.append("No verified active physical X11 session. Log in graphically or explicitly choose a virtual headless display.")
return {"ok": compatible and not missing and not issues, "compatible_debian_13_amd64": compatible, return {"ok": compatible and not missing and not issues,
"compatible_debian_13_amd64": compatible and compatibility["profile_id"] == "debian13-amd64-py313",
"platform_compatibility": compatibility,
"display_support": display_support(env, display_context),
"python": platform.python_version(), "missing_commands": missing, "python": platform.python_version(), "missing_commands": missing,
"display": {"physical-x11": "existing physical X11", "virtual-x11": "explicit private virtual display", "text": "text tools"}.get(display_context.get("kind"), "unavailable"), "display": {"physical-x11": "existing physical X11", "virtual-x11": "explicit private virtual display", "text": "text tools"}.get(display_context.get("kind"), "unavailable"),
"display_context": display_context, "display_context": display_context,
"capabilities": {"fbexec": not missing, "fbterm": not missing, "cua_tty_module": True, "capabilities": {"fbexec": not missing, "fbterm": not missing, "cua_tty_module": True,
"graphical_cua": display_context.get("kind") in ("physical-x11", "virtual-x11") and not graphical_missing, "graphical_cua": display_context.get("kind") in ("physical-x11", "virtual-x11") and not graphical_missing,
"private_desktop": compatible and not missing and not graphical_missing and not virtual_missing,
"mode": "graphical" if display_context.get("kind") in ("physical-x11", "virtual-x11") else "text"}, "mode": "graphical" if display_context.get("kind") in ("physical-x11", "virtual-x11") else "text"},
"optional_missing_commands": {"graphical_cua": graphical_missing, "private_desktop": virtual_missing, "graphical_terminal": console_missing}, "optional_missing_commands": {"graphical_cua": graphical_missing, "private_desktop": virtual_missing, "graphical_terminal": console_missing},
"optional_install_commands": {"private_desktop": VIRTUAL_INSTALL_COMMAND if virtual_missing else None},
"network": inspect_network(), "network": inspect_network(),
"role": config["role"], "issues": issues} "role": config["role"], "issues": issues}
@@ -369,6 +379,10 @@ def _manager(state):
from .display_context import bind_text from .display_context import bind_text
bind_text(env, state, reason="PHYSICAL_X11_UNAVAILABLE_TEXT_TOOLS_ACTIVE") bind_text(env, state, reason="PHYSICAL_X11_UNAVAILABLE_TEXT_TOOLS_ACTIVE")
if private_display: if private_display:
from .display_context import missing_virtual_commands, VIRTUAL_INSTALL_COMMAND
missing_virtual = missing_virtual_commands(env)
if missing_virtual:
raise RuntimeError("Private virtual desktop tools are missing: " + ", ".join(missing_virtual) + ". Install them explicitly: " + VIRTUAL_INSTALL_COMMAND)
display = next((n for n in range(140, 240) if not Path("/tmp/.X11-unix/X" + str(n)).exists() and not Path("/tmp/.X" + str(n) + "-lock").exists()), None) display = next((n for n in range(140, 240) if not Path("/tmp/.X11-unix/X" + str(n)).exists() and not Path("/tmp/.X" + str(n) + "-lock").exists()), None)
if display is None: if display is None:
raise RuntimeError("No private X11 display number is available.") raise RuntimeError("No private X11 display number is available.")
@@ -421,7 +435,11 @@ def start(state):
return current return current
checks = doctor(state) checks = doctor(state)
if not checks["ok"]: if not checks["ok"]:
return {"ok": False, "error": "Preflight failed. Run framebridge doctor.", "doctor": checks} error = "Preflight failed. Run framebridge doctor."
hint = checks.get("optional_install_commands", {}).get("private_desktop")
if config.get("headless") in ("yes", True) and hint:
error = "Private virtual desktop tools are missing. Install them explicitly: " + hint + ". Then run framebridge doctor."
return {"ok": False, "error": error, "doctor": checks}
for name in ("logs", "run"): for name in ("logs", "run"):
private_dir(state / name) private_dir(state / name)
for name in ("ready.json", "last-error.json", "worker.json"): for name in ("ready.json", "last-error.json", "worker.json"):
@@ -448,13 +466,26 @@ def start(state):
result.update(ok=False, error=result["error"] or "Startup failed. Check this profile's logs.") result.update(ok=False, error=result["error"] or "Startup failed. Check this profile's logs.")
return result return result
def stop(state): def stop(state, *, preserve_connection=False):
state = state_path(state) state = state_path(state)
load_config(state) load_config(state)
from .connection_background import disconnect from .connection_background import disconnect, status as connection_status
connection = disconnect(state) # Upgrade-only internal scope: retain an identity-verified background
# supervisor while local tools restart. Ordinary CLI stop is unchanged.
connection = connection_status(state) if preserve_connection else disconnect(state)
if not connection.get("ok"): if not connection.get("ok"):
raise RuntimeError("The background ChatGPT connection could not be stopped safely; local services were left unchanged.") raise RuntimeError("The background ChatGPT connection could not be stopped safely; local services were left unchanged.")
from . import tty
def verify_tty_stop():
outcome = tty.stop(state)
if (not isinstance(outcome, dict) or outcome.get("ok") is not True
or outcome.get("cleanup_verified") is not True):
raise RuntimeError("TTY cleanup could not be verified; inspect this profile's terminal status before retrying.")
# Verify terminal cleanup before stopping local services, then again after
# their termination to cover a terminal opened during the stop operation.
verify_tty_stop()
from .autostart import mark_manual_stop from .autostart import mark_manual_stop
mark_manual_stop(state) mark_manual_stop(state)
rec = _read(state / "manager.json") rec = _read(state / "manager.json")
@@ -465,7 +496,10 @@ def stop(state):
for child in reversed(_read(state / "worker.json").get("processes", [])): for child in reversed(_read(state / "worker.json").get("processes", [])):
_terminate(None, child) _terminate(None, child)
_atomic(state / "ready.json", {"ready": False}) _atomic(state / "ready.json", {"ready": False})
return status(state) verify_tty_stop()
result = status(state)
result["tty_cleanup_verified"] = True
return result
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
+117 -16
View File
@@ -64,6 +64,7 @@ _core, _server = _load_captured()
TerminalDenied = _core.TerminalDenied TerminalDenied = _core.TerminalDenied
from ._tty_backend import RealTerminalMixin from ._tty_backend import RealTerminalMixin
from .tty_sudo_control import SudoControlError from .tty_sudo_control import SudoControlError
from . import tty_cleanup
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -192,6 +193,7 @@ class PortableManager(RealTerminalMixin, _core.Manager):
idle_timeout_s=600) idle_timeout_s=600)
self.max_sessions = limits.max_sessions self.max_sessions = limits.max_sessions
self._last_used = {} self._last_used = {}
self.cleanup_daemon = None
@staticmethod @staticmethod
def _local_context(context): def _local_context(context):
@@ -213,9 +215,13 @@ class PortableManager(RealTerminalMixin, _core.Manager):
if program != "bash": if program != "bash":
raise TerminalDenied("PROGRAM_NOT_SUPPORTED") raise TerminalDenied("PROGRAM_NOT_SUPPORTED")
self._size(rows, cols) self._size(rows, cols)
result = super().open("bash", rows, cols) self._record_cleanup(operation_pending=True)
self._last_used[result["session_id"]] = time.monotonic() try:
return result result = super().open("bash", rows, cols)
self._last_used[result["session_id"]] = time.monotonic()
return result
finally:
self._record_cleanup()
def reap(self): def reap(self):
super().reap() super().reap()
@@ -238,8 +244,57 @@ class PortableManager(RealTerminalMixin, _core.Manager):
raise TerminalDenied("SESSION_NOT_FOUND") raise TerminalDenied("SESSION_NOT_FOUND")
result = super().abort(sid) result = super().abort(sid)
self._last_used.pop(sid, None) self._last_used.pop(sid, None)
self._record_cleanup()
return result return result
def sudo(self, sid, mode):
if mode == "status":
return super().sudo(sid, mode)
self._record_cleanup(operation_pending=True)
try:
return super().sudo(sid, mode)
finally:
self._record_cleanup()
def _cleanup_records(self):
keys = ("session_id", "pid", "birth", "server_pid", "server_birth",
"unit_name", "control_group", "pane_control_group")
records = []
source = {}
if self._real:
source.update({sid: session._sudo_record for sid, session in self._real.items()})
controller = getattr(self, "_sudo", None)
if controller is not None:
source.update(controller._records)
for record in source.values():
records.append({key: record.get(key) for key in keys})
if record.get("pending_old_record"):
records.append({key: record["pending_old_record"].get(key) for key in keys})
return records
def _record_cleanup(self, *, verified=False, errors=(), operation_pending=False):
if self.cleanup_daemon is not None:
tty_cleanup.write(self.profile, self.cleanup_daemon, self._cleanup_records(),
verified=verified, errors=errors,
operation_pending=operation_pending or self._cleanup_uncertain())
def _cleanup_uncertain(self):
controller = getattr(self, "_sudo", None)
return bool(controller is not None and controller._uncertain_launches)
def shutdown(self):
with self._lock:
outcome = {"cleanup_verified": False, "errors": [{"error": "TTY_INTERNAL_ERROR"}]}
try:
outcome = super().shutdown()
if self._cleanup_records() or self._cleanup_uncertain():
outcome["cleanup_verified"] = False
if not outcome["errors"]:
outcome["errors"] = [{"error": "TTY_CLEANUP_PENDING"}]
finally:
self._record_cleanup(verified=outcome["cleanup_verified"], errors=outcome["errors"])
return outcome
def status(self): def status(self):
result = super().status() result = super().status()
for key in ("owner_device_connected", "owner_signature_required"): for key in ("owner_device_connected", "owner_signature_required"):
@@ -297,6 +352,13 @@ class PortableServer(_server.LocalServer):
os.umask(old) os.umask(old)
p.chmod(0o600) p.chmod(0o600)
def server_close(self):
try:
self.manager.shutdown()
finally:
socketserver.UnixStreamServer.server_close(self)
self._socket_path.unlink(missing_ok=True)
_SHAPES = { _SHAPES = {
"status": (set(), set()), "status": (set(), set()),
@@ -415,13 +477,19 @@ def serve(state=None):
if not stat.S_ISSOCK(st.st_mode) or st.st_uid != os.geteuid(): if not stat.S_ISSOCK(st.st_mode) or st.st_uid != os.geteuid():
raise TerminalDenied("TTY_SOCKET_UNTRUSTED") raise TerminalDenied("TTY_SOCKET_UNTRUSTED")
path.unlink() path.unlink()
try:
tty_cleanup.require_prior_cleanup(state)
except tty_cleanup.CleanupUnverified:
raise TerminalDenied("TTY_CLEANUP_PENDING") from None
manager = PortableManager(state, limits, home) manager = PortableManager(state, limits, home)
manager.cleanup_daemon = tty_cleanup.daemon_identity(_identity(os.getpid()))
manager._record_cleanup()
def stopped(_signum, _frame): def stopped(_signum, _frame):
raise SystemExit(0) raise SystemExit(0)
signal.signal(signal.SIGTERM, stopped) signal.signal(signal.SIGTERM, stopped)
signal.signal(signal.SIGINT, stopped) signal.signal(signal.SIGINT, stopped)
pidfile = state / "tty-process.json" pidfile = state / "tty-process.json"
_write_json(pidfile, {**_identity(os.getpid()), "socket": str(path), "state_dir": str(state)}) _write_json(pidfile, {**manager.cleanup_daemon, "socket": str(path), "state_dir": str(state)})
try: try:
with PortableServer(path, manager) as server: with PortableServer(path, manager) as server:
print(json.dumps({"ok": True, "ready": True, "socket": str(path), print(json.dumps({"ok": True, "ready": True, "socket": str(path),
@@ -436,6 +504,10 @@ def start(state=None):
current = status(state) current = status(state)
if current.get("ok"): if current.get("ok"):
return current return current
try:
tty_cleanup.require_prior_cleanup(state)
except tty_cleanup.CleanupUnverified:
return {"ok": False, "error": "TTY_CLEANUP_PENDING", "cleanup_verified": False}
env = os.environ.copy() env = os.environ.copy()
root = str(Path(__file__).resolve().parents[1]) root = str(Path(__file__).resolve().parents[1])
env["PYTHONPATH"] = root + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "") env["PYTHONPATH"] = root + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
@@ -458,19 +530,48 @@ def start(state=None):
def stop(state=None): def stop(state=None):
state = state_path(state) state = state_path(state)
p = state / "tty-process.json" p = state / "tty-process.json"
if not p.is_file() or p.is_symlink(): expected = None
return {"ok": True, "state": "STOPPED", "already_stopped": True} already = True
death_known = False
try: try:
record = json.loads(p.read_text()) receipt = state / tty_cleanup.NAME
if not _alive(record): if not p.exists() and not p.is_symlink() and not receipt.exists() and not receipt.is_symlink():
return {"ok": True, "state": "STOPPED", "already_stopped": True} return {"ok": True, "state": "STOPPED", "already_stopped": True,
os.kill(record["pid"], signal.SIGTERM) "cleanup_verified": True}
deadline = time.monotonic() + 10 outcome = tty_cleanup.read(state)
while _alive(record) and time.monotonic() < deadline: if p.exists() or p.is_symlink():
time.sleep(0.1) record = tty_cleanup.read_private(p)
return {"ok": not _alive(record), "state": "STOPPED" if not _alive(record) else "STOP_TIMEOUT"} if "boot" not in record:
except (ValueError, OSError, KeyError): raise tty_cleanup.CleanupUnverified()
return {"ok": False, "error": "TTY_STOP_FAILED"} expected = tty_cleanup.daemon_identity(record)
if outcome["daemon"] != expected:
raise tty_cleanup.CleanupUnverified()
if expected["boot"] == tty_cleanup.current_boot() and _alive(record):
already = False
fd = os.pidfd_open(record["pid"])
try:
if _alive(record):
signal.pidfd_send_signal(fd, signal.SIGTERM)
finally:
os.close(fd)
deadline = time.monotonic() + 10
while _alive(record) and time.monotonic() < deadline:
time.sleep(0.1)
if _alive(record):
return {"ok": False, "state": "STOP_TIMEOUT", "cleanup_verified": False}
elif outcome["daemon"]["boot"] == tty_cleanup.current_boot() and _alive(outcome["daemon"]):
raise tty_cleanup.CleanupUnverified()
death_known = True
outcome = tty_cleanup.read(state)
if expected is not None and outcome["daemon"] != expected:
raise tty_cleanup.CleanupUnverified()
if not outcome["cleanup_verified"] and outcome["daemon"]["boot"] == tty_cleanup.current_boot():
raise tty_cleanup.CleanupUnverified()
return {"ok": True, "state": "STOPPED", "already_stopped": already,
"cleanup_verified": True}
except (tty_cleanup.CleanupUnverified, ValueError, OSError, KeyError, TypeError, AttributeError):
return {"ok": False, "state": "STOPPED" if death_known else "STOP_UNVERIFIED",
"error": "TTY_CLEANUP_UNVERIFIED", "cleanup_verified": False}
finally: finally:
child = _STARTED_CHILDREN.get(str(state)) child = _STARTED_CHILDREN.get(str(state))
if child is not None: if child is not None:
@@ -0,0 +1,143 @@
"""Private evidence for one TTY daemon's cleanup; never authorizes process kills."""
from __future__ import annotations
import json
import os
import re
from pathlib import Path
import stat
import tempfile
NAME = "tty-cleanup.json"
class CleanupUnverified(RuntimeError):
pass
def current_boot():
return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
def daemon_identity(record):
return {"pid": record["pid"], "birth": record["birth"], "uid": record["uid"],
"boot": record.get("boot", current_boot())}
def _session_valid(record):
keys = {"session_id", "pid", "birth", "server_pid", "server_birth", "unit_name",
"control_group", "pane_control_group"}
if type(record) is not dict or set(record) != keys:
return False
if (type(record["session_id"]) is not str or not re.fullmatch(r"[a-f0-9]{32}", record["session_id"])
or any(type(record[key]) is not int or record[key] <= 0 for key in ("pid", "server_pid"))
or any(type(record[key]) is not str or not record[key].isdigit() for key in ("birth", "server_birth"))
or type(record["unit_name"]) is not str
or not re.fullmatch(r"framebridge-tty-[a-f0-9]{12}-" + record["session_id"] + r"-[a-f0-9]{8}\.service", record["unit_name"])):
return False
for key in ("control_group", "pane_control_group"):
group = record[key]
if type(group) is not str or not group.startswith("/") or ".." in Path(group).parts or "\0" in group:
return False
server = Path(record["control_group"])
pane = Path(record["pane_control_group"])
return (server.name == record["unit_name"] and (pane == server or (
pane.parent == server.parent and re.fullmatch(r"tmux-spawn-[a-f0-9-]{36}\.scope", pane.name))))
def read_private(path):
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, "r") as stream:
info = os.fstat(stream.fileno())
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid()
or stat.S_IMODE(info.st_mode) != 0o600 or info.st_size > 1048576):
raise CleanupUnverified()
return json.load(stream)
def read(state):
try:
value = read_private(Path(state) / NAME)
daemon = value["daemon"]
if (type(value) is not dict or value.get("schema") != 1
or value.get("state_dir") != str(Path(state).absolute())
or type(value.get("cleanup_verified")) is not bool
or type(value.get("sessions")) is not list
or type(value.get("errors")) is not list
or type(value.get("operation_pending")) is not bool
or set(daemon) != {"pid", "birth", "uid", "boot"}
or type(daemon["pid"]) is not int or daemon["pid"] <= 0
or type(daemon["birth"]) is not str or not daemon["birth"].isdigit()
or daemon["uid"] != os.geteuid()
or type(daemon["boot"]) is not str
or not re.fullmatch(r"[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}", daemon["boot"])
or any(not _session_valid(record) for record in value["sessions"])
or (value["cleanup_verified"] and (value["sessions"] or value["errors"]
or value["operation_pending"]))):
raise CleanupUnverified()
return value
except (OSError, ValueError, KeyError, TypeError):
raise CleanupUnverified() from None
def write(state, daemon, sessions, *, verified=False, errors=(), operation_pending=False):
state = Path(state)
value = {"schema": 1, "state_dir": str(state.absolute()), "daemon": daemon,
"cleanup_verified": verified, "sessions": sessions,
"errors": list(errors), "operation_pending": operation_pending}
fd, name = tempfile.mkstemp(prefix=".tty-cleanup-", dir=state)
try:
with os.fdopen(fd, "w") as stream:
json.dump(value, stream, ensure_ascii=True)
stream.write("\n")
os.replace(name, state / NAME)
finally:
Path(name).unlink(missing_ok=True)
def _gone(pid, birth, uid):
if type(pid) is not int or pid <= 0 or type(birth) is not str or not birth.isdigit():
return False
try:
path = Path("/proc") / str(pid)
fields = (path / "stat").read_text().rsplit(")", 1)[1].split()
return fields[19] != birth or (path.stat().st_uid == uid and fields[0] in {"Z", "X"})
except FileNotFoundError:
return True
except (OSError, IndexError):
return False
def require_prior_cleanup(state):
"""Permit restart only after prior evidence is clean or identities are gone.
Persisted identities are read-only evidence, never targets for a later kill.
"""
state = Path(state)
if not (state / NAME).exists() and not (state / NAME).is_symlink():
if (state / "tty-process.json").exists() or (state / "tty-process.json").is_symlink():
raise CleanupUnverified()
return
value = read(state)
# A different kernel boot cannot retain any process or user cgroup from
# this daemon. Do not compare old PID numbers with this boot's processes.
if value["daemon"]["boot"] != current_boot() or value["cleanup_verified"]:
return
daemon = value["daemon"]
if value["operation_pending"] or not _gone(daemon["pid"], daemon["birth"], daemon["uid"]):
raise CleanupUnverified()
for record in value["sessions"]:
try:
if not _gone(record["pid"], record["birth"], daemon["uid"]):
raise CleanupUnverified()
if not _gone(record["server_pid"], record["server_birth"], daemon["uid"]):
raise CleanupUnverified()
for group in {record["control_group"], record["pane_control_group"]}:
if not group.startswith("/") or ".." in Path(group).parts:
raise CleanupUnverified()
root = Path("/sys/fs/cgroup") / group.lstrip("/")
if root.exists():
files = [root / "cgroup.procs", *root.glob("**/cgroup.procs")]
if any(path.read_text().strip() for path in files):
raise CleanupUnverified()
except (OSError, KeyError, TypeError, AttributeError):
raise CleanupUnverified() from None
@@ -11,6 +11,7 @@ import os
from pathlib import Path from pathlib import Path
import pwd import pwd
import re import re
import signal
import shutil import shutil
import shlex import shlex
import stat import stat
@@ -122,6 +123,7 @@ class SudoConsoleController:
self.timeout = float(timeout) self.timeout = float(timeout)
self.prefix = "framebridge-tty-" + hashlib.sha256(str(self.base).encode()).hexdigest()[:12] + "-" self.prefix = "framebridge-tty-" + hashlib.sha256(str(self.base).encode()).hexdigest()[:12] + "-"
self._records = {} self._records = {}
self._uncertain_launches = set()
self._lock = threading.RLock() self._lock = threading.RLock()
def _run(self, command, *, data=None, optional=False): def _run(self, command, *, data=None, optional=False):
@@ -359,8 +361,12 @@ class SudoConsoleController:
command.append("--setenv=" + key + "=" + value) command.append("--setenv=" + key + "=" + value)
command += [self.tmux, "-D", "-S", str(socket), "-f", "/dev/null"] command += [self.tmux, "-D", "-S", str(socket), "-f", "/dev/null"]
launched = False launched = False
verified_record = None
try: try:
launched = True launched = True
# A failed partial launch must never disappear from shutdown
# evidence, even if its ordinary rollback also fails.
self._uncertain_launches.add(unit_name)
self._run(command) self._run(command)
deadline = time.monotonic() + self.timeout deadline = time.monotonic() + self.timeout
while time.monotonic() < deadline: while time.monotonic() < deadline:
@@ -382,26 +388,37 @@ class SudoConsoleController:
deadline = time.monotonic() + self.timeout deadline = time.monotonic() + self.timeout
while time.monotonic() < deadline: while time.monotonic() < deadline:
current = self._inspect(record) current = self._inspect(record)
verified_record = current
if current["no_new_privs"] != expected: if current["no_new_privs"] != expected:
_fail("TTY_SUDO_PRIVILEGE_STATE_MISMATCH") _fail("TTY_SUDO_PRIVILEGE_STATE_MISMATCH")
if not current["busy"]: if not current["busy"]:
self._uncertain_launches.discard(unit_name)
return current return current
time.sleep(0.05) time.sleep(0.05)
_fail("TTY_SUDO_CONSOLE_NOT_IDLE") _fail("TTY_SUDO_CONSOLE_NOT_IDLE")
except BaseException: except BaseException:
if launched: # Only a fully inspected record can prove rollback. Incomplete
# launches retain uncertainty even if an ordinary stop was sent.
if verified_record is not None:
try:
self._close(verified_record)
self._uncertain_launches.discard(unit_name)
except SudoControlError:
pass
elif launched:
try: try:
unit = self._unit(unit_name) unit = self._unit(unit_name)
proc, _, _, _ = self._pane(record) proc, _, _, _ = self._pane(record)
scope = self._pane_scope(self._pane_group(proc["pid"]), unit.get("ControlGroup")) scope = self._pane_scope(self._pane_group(proc["pid"]), unit.get("ControlGroup"))
if scope: if scope:
self._run([self.systemctl, "--user", "stop", scope], optional=True) self._run([self.systemctl, "--user", "--no-block", "stop", scope], optional=True)
except SudoControlError: except SudoControlError:
pass pass
self._run([self.systemctl, "--user", "stop", unit_name], optional=True) self._run([self.systemctl, "--user", "--no-block", "stop", unit_name], optional=True)
socket.unlink(missing_ok=True) if unit_name not in self._uncertain_launches:
marker.unlink(missing_ok=True) socket.unlink(missing_ok=True)
rcfile.unlink(missing_ok=True) marker.unlink(missing_ok=True)
rcfile.unlink(missing_ok=True)
raise raise
def launch(self, session_id, cwd=None, rows=24, cols=80, mode="off"): def launch(self, session_id, cwd=None, rows=24, cols=80, mode="off"):
@@ -433,6 +450,34 @@ class SudoConsoleController:
_fail("TTY_SUDO_ELEVATED_PROCESS_PRESENT") _fail("TTY_SUDO_ELEVATED_PROCESS_PRESENT")
return {**record, "busy_reason": None} return {**record, "busy_reason": None}
def _close_members(self, record):
members = self._scope_members_existing(record)
result = {}
for pid in members:
process = _proc(pid)
if process is None:
# An exited member can disappear between cgroup and /proc reads.
if pid in self._scope_members_existing(record):
_fail("TTY_SUDO_PROCESS_STATE_UNVERIFIED")
continue
if any(uid != self.uid for uid in process["uids"]):
_fail("TTY_SUDO_ELEVATED_PROCESS_PRESENT")
if process["state"] not in {"Z", "X"}:
result[pid] = process
return result
def _scope_members_existing(self, record):
members = set()
for group in {record["control_group"], record["pane_control_group"]}:
path = Path("/sys/fs/cgroup") / group.lstrip("/")
if path.exists():
try:
members.update(self._scope(group))
except SudoControlError:
if path.exists():
raise
return members
def _close(self, record, require_idle=False): def _close(self, record, require_idle=False):
try: try:
current = self._idle(record) if require_idle else self._inspect(record) current = self._idle(record) if require_idle else self._inspect(record)
@@ -444,17 +489,60 @@ class SudoConsoleController:
if current["busy_reason"] in {"TTY_SUDO_ELEVATED_PROCESS_PRESENT", "TTY_SUDO_PROCESS_STATE_UNVERIFIED"}: if current["busy_reason"] in {"TTY_SUDO_ELEVATED_PROCESS_PRESENT", "TTY_SUDO_PROCESS_STATE_UNVERIFIED"}:
_fail(current["busy_reason"]) _fail(current["busy_reason"])
if record.get("unit_name"): if record.get("unit_name"):
# Close this private PTY before stopping its sibling tmux-spawn
# scope: interactive Bash ignores TERM and that scope may otherwise
# wait the user's default 90-second systemd stop timeout.
self._tmux(record, "kill-session", "-t", record["name"], optional=True)
if current.get("pane_scope_unit"): if current.get("pane_scope_unit"):
self._run([self.systemctl, "--user", "stop", current["pane_scope_unit"]], optional=True) self._run([self.systemctl, "--user", "--no-block", "stop",
self._run([self.systemctl, "--user", "stop", record["unit_name"]], optional=True) current["pane_scope_unit"]], optional=True)
self._run([self.systemctl, "--user", "--no-block", "stop",
record["unit_name"]], optional=True)
deadline = time.monotonic() + self.timeout deadline = time.monotonic() + self.timeout
escalate_at = time.monotonic() + min(1.0, self.timeout / 3)
escalated = False
while time.monotonic() < deadline: while time.monotonic() < deadline:
remaining = set() remaining = self._close_members(current)
for group in {current["control_group"], current["pane_control_group"]}: shell = _proc(current["pid"])
if (Path("/sys/fs/cgroup") / group.lstrip("/")).exists(): server = _proc(current["server_pid"])
remaining.update(self._scope(group)) shell_alive = (shell and shell["birth"] == current["birth"]
if not remaining and _proc(current["pid"]) is None: and shell["state"] not in {"Z", "X"})
server_alive = (server and server["birth"] == current["server_birth"]
and server["state"] not in {"Z", "X"})
if not remaining and not shell_alive and not server_alive:
break break
if not escalated and time.monotonic() >= escalate_at:
# Revalidate the private service identity and all recorded
# cgroup members before escalating. Never signal root jobs,
# unverified processes or a reused PID.
self._terminated(current)
for identity in remaining.values():
try:
fd = os.pidfd_open(identity["pid"])
except ProcessLookupError:
continue
except (AttributeError, OSError):
_fail("TTY_SUDO_PIDFD_UNAVAILABLE")
try:
live = _proc(identity["pid"])
if (not live or live["birth"] != identity["birth"]
or live["state"] in {"Z", "X"}):
continue
if any(uid != self.uid for uid in live["uids"]):
_fail("TTY_SUDO_ELEVATED_PROCESS_PRESENT")
group = self._pane_group(live["pid"])
if not any(group == owned or group.startswith(owned + "/")
for owned in {current["control_group"], current["pane_control_group"]}):
_fail("TTY_SUDO_CGROUP_UNEXPECTED")
try:
signal.pidfd_send_signal(fd, signal.SIGKILL)
except ProcessLookupError:
pass
except (AttributeError, OSError):
_fail("TTY_SUDO_PIDFD_SIGNAL_FAILED")
finally:
os.close(fd)
escalated = True
time.sleep(0.05) time.sleep(0.05)
else: else:
_fail("TTY_SUDO_SCOPE_STILL_RUNNING") _fail("TTY_SUDO_SCOPE_STILL_RUNNING")
@@ -505,7 +593,11 @@ class SudoConsoleController:
except BaseException as error: except BaseException as error:
previous = _proc(current["pid"]) previous = _proc(current["pid"])
if previous and previous["birth"] == current["birth"] and previous["state"] not in {"Z", "X"}: if previous and previous["birth"] == current["birth"] and previous["state"] not in {"Z", "X"}:
self._close(candidate) try:
self._close(candidate)
except BaseException:
self._uncertain_launches.add(candidate["unit_name"])
raise
raise raise
# The former Bash is gone. Keep the verified replacement and # The former Bash is gone. Keep the verified replacement and
# retain old ownership metadata until all old scopes are empty. # retain old ownership metadata until all old scopes are empty.
@@ -9,6 +9,9 @@ import stat
import subprocess import subprocess
import sys import sys
# This module also runs as an isolated standalone package-removal script.
SYSTEM_PYTHONS = frozenset({"/usr/bin/python3", "/usr/bin/python3.12",
"/usr/bin/python3.13", "/usr/bin/python3.14"})
MODULE_ROOT = "/usr/lib/framebridge-expanded/expanded" MODULE_ROOT = "/usr/lib/framebridge-expanded/expanded"
UNIT_NAME = re.compile(r"framebridge-autostart-[0-9a-f]{16}\.service\Z") UNIT_NAME = re.compile(r"framebridge-autostart-[0-9a-f]{16}\.service\Z")
@@ -35,7 +38,7 @@ def managed_unit(path, uid):
if len(starts) != 1: if len(starts) != 1:
return False return False
args = shlex.split(starts[0]) args = shlex.split(starts[0])
if len(args) != 7 or args[0] not in {"/usr/bin/python3", "/usr/bin/python3.13"}: if len(args) != 7 or args[0] not in SYSTEM_PYTHONS:
return False return False
if args[1:6] != ["-B", "-m", "framebridge_expanded.autostart", "_run", "--state"]: if args[1:6] != ["-B", "-m", "framebridge_expanded.autostart", "_run", "--state"]:
return False return False
@@ -105,6 +108,45 @@ def cleanup_user(home=None, *, run=None, bus_available=None):
return len(units) return len(units)
CREDENTIAL_HELPER = Path("/usr/lib/framebridge-expanded/bin/framebridge-credential-helper")
CREDENTIAL_HELPER_MODULE = Path(MODULE_ROOT) / "framebridge_expanded/credential_helper.py"
def _trusted_root_file(path):
"""Trust only fixed package paths with root-owned, non-writable ancestors."""
for current in (path, *path.parents):
info = current.lstat()
if info.st_uid != 0 or info.st_mode & 0o022:
return False
if current == path:
if not stat.S_ISREG(info.st_mode):
return False
elif not stat.S_ISDIR(info.st_mode):
return False
return True
def purge_credential_helpers(*, run=None):
"""Revoke only the installed helper's managed metadata during root removal."""
if os.getuid() != 0:
raise RuntimeError("Credential-helper cleanup requires package-manager root context.")
# Old packages did not ship this helper. Never select code from user state.
if not os.path.lexists(CREDENTIAL_HELPER):
if os.path.lexists(CREDENTIAL_HELPER_MODULE):
raise RuntimeError("Installed credential helper is incomplete; package removal was paused.")
return
try:
if not _trusted_root_file(CREDENTIAL_HELPER) or not _trusted_root_file(CREDENTIAL_HELPER_MODULE):
raise RuntimeError("Installed credential helper has unsafe ownership or permissions.")
result = (run or subprocess.run)([str(CREDENTIAL_HELPER), "purge-all"],
env={"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"},
capture_output=True, timeout=30)
if result.returncode:
raise RuntimeError("Credential-helper cleanup failed; package removal was paused.")
except (OSError, subprocess.TimeoutExpired) as exc:
raise RuntimeError("Credential-helper cleanup failed; package removal was paused.") from exc
def cleanup_all_users(*, run=None, accounts=None): def cleanup_all_users(*, run=None, accounts=None):
if os.getuid() != 0: if os.getuid() != 0:
raise RuntimeError("Package service cleanup requires the normal package manager's root context.") raise RuntimeError("Package service cleanup requires the normal package manager's root context.")
@@ -125,6 +167,7 @@ def cleanup_all_users(*, run=None, accounts=None):
failed = True failed = True
if failed: if failed:
raise RuntimeError("Some FrameBridge services could not be stopped; package removal was paused.") raise RuntimeError("Some FrameBridge services could not be stopped; package removal was paused.")
purge_credential_helpers(run=run)
def main(argv=None): def main(argv=None):
@@ -0,0 +1,624 @@
"""X11 text using only mappings in the validated current keyboard group.
The 2016 libxdo character search is keycode-first across all groups. Its
sender then locks/restores groups around XTEST events. Here libxdo supplies
read-only mapping data; only XTestFakeKeyEvent emits input. No clipboard,
keyboard binding, group-lock or keyboard-map setter is used.
"""
from __future__ import annotations
import ctypes as C
from dataclasses import dataclass
import hashlib
import os
from pathlib import Path
import json
import selectors
import signal
import subprocess
import sys
import time
from typing import Callable
class TextError(RuntimeError):
"""Fixed public error code, without text, clipboard or credential contents."""
@dataclass(frozen=True)
class Mapping:
code: int
group: int
symbol: int
modifiers: int
character: int
needs_binding: int = 0
@dataclass(frozen=True)
class Snapshot:
group: int
locked_group: int
base_group: int
latched_group: int
mods: int
base_mods: int
latched_mods: int
locked_mods: int
window: int
width: int
height: int
keymap: str
held: tuple[int, ...]
@dataclass(frozen=True)
class Stroke:
code: int
modifiers: tuple[int, ...]
mask: int
character: int
def require(value, code):
if not value:
raise TextError(code)
def plan_text(text: str, mappings: tuple[Mapping, ...], snapshot: Snapshot,
modifier_keys: dict[int, int], lookup: Callable[[int, int, int], int]) -> tuple[Stroke, ...]:
"""Plan the entire string before clearing modifiers or emitting any event."""
require(type(text) is str and 0 < len(text) <= 4096 and "\0" not in text,
"TYPE_TEXT_INVALID")
# Group zero is the native context validated by this revision. Do not guess
# XTEST device mappings for another group or consume one-shot accessibility
# state. Unsupported contexts fail without changing that state.
require(snapshot.group == snapshot.locked_group == 0 and snapshot.base_group == 0
and snapshot.latched_group == 0, "TYPE_KEYBOARD_GROUP_UNSUPPORTED")
require(snapshot.latched_mods == 0, "TYPE_LATCHED_MODIFIERS_UNSUPPORTED")
# Symbol lookup alone cannot prove that locked Ctrl/Alt/Super will not
# invoke an application shortcut. Only conventional Caps/Num lock bits
# participate in this text backend; held modifiers are refused natively.
require(not snapshot.locked_mods & ~(2 | 16), "TYPE_LOCKED_MODIFIERS_UNSUPPORTED")
allowed = 0
for mask in modifier_keys:
allowed |= mask
choices: dict[int, Stroke] = {}
candidates = sorted(mappings, key=lambda item: (item.modifiers.bit_count(), item.code))
for item in candidates:
if item.group != snapshot.group or item.needs_binding or item.modifiers & ~allowed:
continue
require(8 <= item.code <= 255 and 0 <= item.modifiers <= 255,
"TYPE_KEYMAP_INVALID")
character = lookup(item.code, snapshot.locked_mods | item.modifiers, snapshot.group)
if character and character not in choices:
modifiers = tuple(modifier_keys[mask] for mask in sorted(modifier_keys)
if item.modifiers & mask)
choices[character] = Stroke(item.code, modifiers, item.modifiers, character)
require(all(ord(character) in choices for character in text),
"TYPE_TEXT_UNREPRESENTABLE_IN_CURRENT_GROUP")
return tuple(choices[ord(character)] for character in text)
def _same_context(current: Snapshot, initial: Snapshot, *, cleared: bool):
require(current.window == initial.window, "TYPE_ACTIVE_WINDOW_CHANGED")
require((current.width, current.height) == (initial.width, initial.height),
"TYPE_GEOMETRY_CHANGED")
require((current.group, current.locked_group, current.base_group, current.latched_group)
== (initial.group, initial.locked_group, 0, 0), "TYPE_KEYBOARD_GROUP_CHANGED")
require(current.latched_mods == 0 and current.locked_mods == initial.locked_mods,
"TYPE_KEYBOARD_STATE_CHANGED")
require(current.keymap == initial.keymap, "TYPE_KEYMAP_CHANGED")
if cleared:
require(current.mods == current.locked_mods, "TYPE_MODIFIERS_NOT_CLEARED")
def type_text(text: str, *, expected_window: int | None = None,
expected_geometry: tuple[int, int] | None = None,
before_input: Callable[[], None], timeout: float = 20,
backend=None, clock=time.monotonic, sleep=time.sleep):
"""Emit a prevalidated plan and conservatively restore all attempted keys.
before_input is the existing native-host display/authority guard. It is
called immediately before every stroke and before the first state change.
Caller ownership of uncertainty remains unchanged: after this callback has
run, a failure must not invite replay or assert that nothing was performed.
"""
owned = backend is None
try:
backend = NativeBackend() if owned else backend
except (AttributeError, OSError) as error:
raise TextError("TYPE_NATIVE_LIBRARY_UNAVAILABLE") from error
initial = None
released_held: list[int] = []
down: list[int] = []
attempted = False
failure = None
restore_failed = False
try:
require(type(timeout) in (int, float) and 0 < timeout <= 120, "TYPE_TIMEOUT_INVALID")
deadline = clock() + timeout
initial = backend.snapshot()
require(initial.window > 0, "TYPE_ACTIVE_WINDOW_UNAVAILABLE")
require(expected_window is None or initial.window == expected_window,
"TYPE_ACTIVE_WINDOW_CHANGED")
require(expected_geometry is None or (initial.width, initial.height) == expected_geometry,
"TYPE_GEOMETRY_CHANGED")
strokes = plan_text(text, backend.mappings, initial, backend.modifier_keys, backend.lookup)
if hasattr(backend, "validate_strokes"):
backend.validate_strokes(strokes, initial.locked_mods)
require(clock() < deadline and len(strokes) * 0.01 < timeout,
"TYPE_BUDGET_TOO_SMALL")
# This validation is still before ANY release/press. In particular an
# unmapped final character cannot cause a typed prefix or modifier clear.
_same_context(backend.snapshot(), initial, cleared=False)
if initial.held:
before_input()
attempted = True
_same_context(backend.snapshot(), initial, cleared=False)
for code in initial.held:
released_held.append(code) # A failed call may still have acted.
backend.event(code, False)
backend.sync()
_same_context(backend.snapshot(), initial, cleared=True)
for stroke in strokes:
require(clock() < deadline, "TYPE_DEADLINE_EXCEEDED")
_same_context(backend.snapshot(), initial, cleared=True)
require(backend.lookup(stroke.code, initial.locked_mods | stroke.mask,
initial.group) == stroke.character, "TYPE_KEYMAP_CHANGED")
before_input()
attempted = True
# Permission can wait on the parent display guard. Revalidate
# after that wait, at the actual event boundary as well.
_same_context(backend.snapshot(), initial, cleared=True)
require(backend.lookup(stroke.code, initial.locked_mods | stroke.mask,
initial.group) == stroke.character, "TYPE_KEYMAP_CHANGED")
for code in stroke.modifiers:
down.append(code)
backend.event(code, True)
down.append(stroke.code)
backend.event(stroke.code, True)
backend.event(stroke.code, False)
down.remove(stroke.code)
for code in reversed(stroke.modifiers):
backend.event(code, False)
down.remove(code)
backend.sync()
sleep(0.01)
_same_context(backend.snapshot(), initial, cleared=True)
except BaseException as error:
failure = error
finally:
# Releases and restoration are cleanup, not text replay. Attempt all of
# them even if a prior event, guard or restore call failed.
for code in reversed(down):
try:
backend.event(code, False)
except BaseException:
restore_failed = True
for code in released_held:
try:
backend.event(code, True)
except BaseException:
restore_failed = True
if attempted:
try:
backend.sync()
final = backend.snapshot()
require(initial is not None and final.held == initial.held
and final.group == initial.group and final.locked_mods == initial.locked_mods,
"TYPE_MODIFIER_RESTORE_UNCERTAIN")
except BaseException:
restore_failed = True
if owned:
try:
backend.close()
except BaseException:
if attempted:
restore_failed = True
elif failure is None:
failure = TextError("TYPE_NATIVE_CLOSE_FAILED")
if restore_failed:
raise TextError("TYPE_MODIFIER_RESTORE_UNCERTAIN") from failure
if failure is not None:
raise failure
def bounded_type_text(text: str, *, expected_window: int,
expected_geometry: tuple[int, int], before_input,
timeout: float = 20):
"""Bound native Xlib work by a child process and gate every input batch.
The child never receives permission to emit until whole-text preflight has
passed. An ACK is conservative evidence of possible input. A stalled X
server can prevent cleanup; any such termination therefore remains uncertain
after an ACK and must never invite replay.
"""
require(type(timeout) in (int, float) and 0 < timeout <= 120, "TYPE_TIMEOUT_INVALID")
require(type(text) is str and 0 < len(text) <= 4096 and "\0" not in text,
"TYPE_TEXT_INVALID")
deadline = time.monotonic() + timeout
selector = selectors.DefaultSelector()
try:
process = subprocess.Popen([sys.executable, "-I", "-S", "-B",
str(Path(__file__).resolve()), "--worker"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, bufsize=0)
except BaseException:
selector.close()
raise
buffer = b""
complete = False
try:
request = json.dumps({"text": text, "window": expected_window,
"geometry": expected_geometry, "timeout": timeout},
ensure_ascii=True).encode("ascii") + b"\n"
# The request is bounded below PIPE_BUF by ASCII length for normal
# strings, but Unicode escaping can be larger: use a writable selector
# rather than allowing pipe backpressure to block the native host.
selector.register(process.stdin, selectors.EVENT_WRITE)
offset = 0
while offset < len(request):
remaining = deadline - time.monotonic()
require(remaining > 0 and selector.select(remaining), "TYPE_WORKER_TIMEOUT")
offset += os.write(process.stdin.fileno(), request[offset:offset + 4096])
selector.unregister(process.stdin)
selector.register(process.stdout, selectors.EVENT_READ)
while True:
remaining = deadline - time.monotonic()
require(remaining > 0 and selector.select(remaining), "TYPE_WORKER_TIMEOUT")
chunk = os.read(process.stdout.fileno(), 4096)
require(bool(chunk), "TYPE_WORKER_EXIT_UNCERTAIN")
buffer += chunk
require(len(buffer) <= 4096, "TYPE_WORKER_PROTOCOL_INVALID")
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
if line == b"INPUT":
before_input()
require(os.write(process.stdin.fileno(), b"GO\n") == 3,
"TYPE_WORKER_PROTOCOL_INVALID")
elif line == b"OK":
require(not buffer, "TYPE_WORKER_PROTOCOL_INVALID")
complete = True
return
elif line.startswith(b"ERROR TYPE_"):
code = line[6:].decode("ascii", errors="strict")
require(len(code) <= 96 and all(c.isupper() or c == "_" for c in code),
"TYPE_WORKER_PROTOCOL_INVALID")
raise TextError(code)
else:
raise TextError("TYPE_WORKER_PROTOCOL_INVALID")
except (OSError, ValueError, UnicodeError) as error:
raise TextError("TYPE_WORKER_IO_UNCERTAIN") from error
finally:
selector.close()
if process.poll() is None:
if not complete:
process.terminate() # child handler runs restoration finally
try:
process.wait(timeout=0.5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=0.5)
process.stdin.close()
process.stdout.close()
def _worker():
# SIGTERM must unwind normal Python paths, including a child waiting for
# permission. If Xlib is stuck, the parent bounds cleanup and kills it.
def stopped(_number, _frame):
raise TextError("TYPE_WORKER_CANCELLED")
signal.signal(signal.SIGTERM, stopped)
def permission():
sys.stdout.write("INPUT\n"); sys.stdout.flush()
require(sys.stdin.buffer.readline(16) == b"GO\n", "TYPE_WORKER_CANCELLED")
try:
raw = sys.stdin.buffer.readline(65537)
require(len(raw) <= 65536 and raw.endswith(b"\n"), "TYPE_WORKER_REQUEST_INVALID")
request = json.loads(raw)
type_text(request["text"], expected_window=request["window"],
expected_geometry=tuple(request["geometry"]),
before_input=permission, timeout=request["timeout"])
print("OK", flush=True)
except BaseException as error:
code = str(error) if isinstance(error, TextError) else "TYPE_NATIVE_FAILURE"
print("ERROR " + code, flush=True)
# Public 2016 libxdo ABI. Debian preserves extra fields AFTER this prefix;
# deliberately never describe, write or depend on the rest of xdo_t.
class CharCode(C.Structure):
_fields_ = [("key", C.c_uint32), ("code", C.c_ubyte), ("symbol", C.c_ulong),
("group", C.c_int), ("modmask", C.c_int), ("needs_binding", C.c_int)]
class XdoPrefix(C.Structure):
_fields_ = [("xdpy", C.c_void_p), ("display_name", C.c_void_p),
("charcodes", C.POINTER(CharCode)), ("charcodes_len", C.c_int)]
class XkbState(C.Structure):
# Authoritative XKBstr.h, not the obsolete HTML manual's byte fields.
_fields_ = [("group", C.c_ubyte), ("locked_group", C.c_ubyte),
("base_group", C.c_ushort), ("latched_group", C.c_ushort),
("mods", C.c_ubyte), ("base_mods", C.c_ubyte),
("latched_mods", C.c_ubyte), ("locked_mods", C.c_ubyte),
("compat_state", C.c_ubyte), ("grab_mods", C.c_ubyte),
("compat_grab_mods", C.c_ubyte), ("lookup_mods", C.c_ubyte),
("compat_lookup_mods", C.c_ubyte), ("ptr_buttons", C.c_ushort)]
class ModifierMap(C.Structure):
_fields_ = [("max_keypermod", C.c_int), ("modifiermap", C.POINTER(C.c_ubyte))]
class SymMap(C.Structure):
_fields_ = [("kt_index", C.c_ubyte * 4), ("group_info", C.c_ubyte),
("width", C.c_ubyte), ("offset", C.c_ushort)]
class ClientMapPrefix(C.Structure):
_fields_ = [("size_types", C.c_ubyte), ("num_types", C.c_ubyte), ("types", C.c_void_p),
("size_syms", C.c_ushort), ("num_syms", C.c_ushort), ("syms", C.c_void_p),
("key_sym_map", C.POINTER(SymMap))]
class ServerMapPrefix(C.Structure):
_fields_ = [("num_acts", C.c_ushort), ("size_acts", C.c_ushort),
("acts", C.POINTER(C.c_ubyte * 8)), ("behaviors", C.c_void_p),
("key_acts", C.POINTER(C.c_ushort))]
class KeyboardPrefix(C.Structure):
_fields_ = [("display", C.c_void_p), ("flags", C.c_ushort), ("device", C.c_ushort),
("min_key_code", C.c_ubyte), ("max_key_code", C.c_ubyte), ("ctrls", C.c_void_p),
("server", C.POINTER(ServerMapPrefix)), ("map", C.POINTER(ClientMapPrefix))]
def validate_actions(actions, strokes, modifier_keys, locked_mods=0):
"""No group/latch/lock/redirect/pointer actions on any generated key."""
modifier_masks = {code: mask for mask, code in modifier_keys.items()}
character_codes = {stroke.code for stroke in strokes}
codes = character_codes | {code for stroke in strokes for code in stroke.modifiers}
for code in codes:
require(code in actions, "TYPE_KEY_ACTIONS_UNAVAILABLE")
for action in actions[code]:
require(len(action) == 8, "TYPE_KEY_ACTIONS_INVALID")
if action[0] == 0: # NoAction
require(code in character_codes, "TYPE_KEY_ACTION_UNSUPPORTED")
continue
require(code not in character_codes and code in modifier_masks,
"TYPE_KEY_ACTION_UNSUPPORTED")
mask = modifier_masks[code]
# XKB ClearLocks only unlocks action modifiers (X.Org table16.2).
# Permit it only when those bits have no locked state to alter.
# No latch/lock/group/virtual modifier side effects are allowed.
require(action[0] == 1 and action[1] in (0, 1, 4, 5) and action[2] == mask
and action[3] == mask and not any(action[4:]),
"TYPE_KEY_ACTION_UNSUPPORTED")
require(not (action[1] & 1 and locked_mods & mask),
"TYPE_KEY_ACTION_WOULD_CLEAR_LOCK")
def trusted_library(name: str):
path = Path("/usr/lib/x86_64-linux-gnu") / name
try:
resolved = path.resolve(strict=True)
for part in [resolved, *resolved.parents]:
info = part.lstat()
require(info.st_uid == 0 and not info.st_mode & 0o022,
"TYPE_NATIVE_LIBRARY_UNTRUSTED")
require(resolved.is_file(), "TYPE_NATIVE_LIBRARY_UNTRUSTED")
return C.CDLL(str(resolved))
except (OSError, ValueError) as error:
raise TextError("TYPE_NATIVE_LIBRARY_UNAVAILABLE") from error
class NativeBackend:
"""Read-only XKB/libxdo inspection plus direct XTEST key events only."""
def __init__(self):
self.context = None
self.xdo = trusted_library("libxdo.so.3")
self.x11 = trusted_library("libX11.so.6")
self.xtst = trusted_library("libXtst.so.6")
require(C.sizeof(C.c_void_p) == 8 and C.sizeof(C.c_ulong) == 8
and C.sizeof(CharCode) == 32 and C.sizeof(XkbState) == 18
and C.sizeof(SymMap) == 8 and KeyboardPrefix.server.offset == 24
and KeyboardPrefix.map.offset == 32 and ServerMapPrefix.key_acts.offset == 24
and ClientMapPrefix.key_sym_map.offset == 32,
"TYPE_NATIVE_ABI_UNSUPPORTED")
self._bind()
require(self.xdo.xdo_version() == b"3.20160805.1", "TYPE_NATIVE_ABI_UNSUPPORTED")
self.context = self.xdo.xdo_new(None)
require(bool(self.context), "TYPE_DISPLAY_UNAVAILABLE")
try:
prefix = XdoPrefix.from_address(self.context)
require(prefix.xdpy and prefix.charcodes and 0 < prefix.charcodes_len <= 65536,
"TYPE_NATIVE_KEYMAP_INVALID")
self.display = prefix.xdpy
self.mappings = tuple(Mapping(item.code, item.group, item.symbol, item.modmask,
item.key, item.needs_binding)
for item in prefix.charcodes[:prefix.charcodes_len])
require(all(8 <= item.code <= 255 and 0 <= item.group <= 3
and 0 <= item.modifiers <= 255 and item.needs_binding in (0, 1)
for item in self.mappings), "TYPE_NATIVE_KEYMAP_INVALID")
self.characters = {item.symbol: item.character for item in self.mappings}
require(self.xtst.XTestQueryExtension(self.display, C.byref(C.c_int()),
C.byref(C.c_int()), C.byref(C.c_int()),
C.byref(C.c_int())) != 0, "TYPE_XTEST_UNAVAILABLE")
self.modifier_keys, self.modifier_codes, self.locking_codes, self.group_codes = self._modifiers()
except BaseException:
self.close()
raise
def _bind(self):
def bind(lib, name, result, *args):
value = getattr(lib, name); value.restype = result; value.argtypes = list(args)
bind(self.xdo, "xdo_version", C.c_char_p)
bind(self.xdo, "xdo_new", C.c_void_p, C.c_char_p)
bind(self.xdo, "xdo_free", None, C.c_void_p)
bind(self.xdo, "xdo_get_active_window", C.c_int, C.c_void_p, C.POINTER(C.c_ulong))
bind(self.x11, "XkbGetState", C.c_int, C.c_void_p, C.c_uint, C.POINTER(XkbState))
bind(self.x11, "XkbLookupKeySym", C.c_int, C.c_void_p, C.c_ubyte, C.c_uint,
C.POINTER(C.c_uint), C.POINTER(C.c_ulong))
bind(self.x11, "XkbKeycodeToKeysym", C.c_ulong, C.c_void_p, C.c_ubyte, C.c_int, C.c_int)
bind(self.x11, "XkbGetMap", C.POINTER(KeyboardPrefix), C.c_void_p, C.c_uint, C.c_uint)
bind(self.x11, "XkbFreeKeyboard", None, C.POINTER(KeyboardPrefix), C.c_uint, C.c_int)
bind(self.x11, "XQueryKeymap", C.c_int, C.c_void_p, C.c_void_p)
bind(self.x11, "XGetModifierMapping", C.POINTER(ModifierMap), C.c_void_p)
bind(self.x11, "XFreeModifiermap", C.c_int, C.POINTER(ModifierMap))
bind(self.x11, "XDisplayKeycodes", C.c_int, C.c_void_p, C.POINTER(C.c_int), C.POINTER(C.c_int))
bind(self.x11, "XGetKeyboardMapping", C.POINTER(C.c_ulong), C.c_void_p, C.c_ubyte,
C.c_int, C.POINTER(C.c_int))
bind(self.x11, "XFree", C.c_int, C.c_void_p)
bind(self.x11, "XDefaultScreen", C.c_int, C.c_void_p)
bind(self.x11, "XDisplayWidth", C.c_int, C.c_void_p, C.c_int)
bind(self.x11, "XDisplayHeight", C.c_int, C.c_void_p, C.c_int)
bind(self.x11, "XSync", C.c_int, C.c_void_p, C.c_int)
bind(self.xtst, "XTestQueryExtension", C.c_int, C.c_void_p,
C.POINTER(C.c_int), C.POINTER(C.c_int), C.POINTER(C.c_int), C.POINTER(C.c_int))
bind(self.xtst, "XTestFakeKeyEvent", C.c_int, C.c_void_p, C.c_uint, C.c_int, C.c_ulong)
def _modifier_data(self):
pointer = self.x11.XGetModifierMapping(self.display)
require(bool(pointer), "TYPE_MODIFIER_MAP_UNAVAILABLE")
try:
width = pointer.contents.max_keypermod
require(0 < width <= 32, "TYPE_MODIFIER_MAP_INVALID")
values = tuple(pointer.contents.modifiermap[:8 * width])
require(all(code == 0 or 8 <= code <= 255 for code in values),
"TYPE_MODIFIER_MAP_INVALID")
return width, values
finally:
self.x11.XFreeModifiermap(pointer)
def _modifiers(self):
width, values = self._modifier_data()
safe, codes, locking, groups = {}, set(), set(), set()
for index in range(8):
for code in values[index * width:(index + 1) * width]:
if not code:
continue
codes.add(code)
symbol = self.x11.XkbKeycodeToKeysym(self.display, code, 0, 0)
if index == 0 and symbol in (0xFFE1, 0xFFE2):
safe.setdefault(1, code)
elif symbol == 0xFE03: # ISO_Level3_Shift, not a group switch.
safe.setdefault(1 << index, code)
if symbol in (0xFFE5, 0xFFE6, 0xFF7F, 0xFF14):
locking.add(code)
if symbol == 0xFF7E or 0xFE06 <= symbol <= 0xFE0F:
groups.add(code)
code_masks = {code: sum(1 << index for index in range(8)
if code in values[index * width:(index + 1) * width])
for code in codes}
safe = {mask: code for mask, code in safe.items() if code_masks[code] == mask}
return safe, codes, locking, groups
def _map_token(self):
low, high, width = C.c_int(), C.c_int(), C.c_int()
self.x11.XDisplayKeycodes(self.display, C.byref(low), C.byref(high))
require(8 <= low.value <= high.value <= 255, "TYPE_NATIVE_KEYMAP_INVALID")
pointer = self.x11.XGetKeyboardMapping(self.display, low.value,
high.value - low.value + 1, C.byref(width))
require(bool(pointer), "TYPE_NATIVE_KEYMAP_INVALID")
try:
require(0 < width.value <= 64, "TYPE_NATIVE_KEYMAP_INVALID")
data = C.string_at(pointer, (high.value - low.value + 1) * width.value * C.sizeof(C.c_ulong))
finally:
self.x11.XFree(pointer)
action_data = b"".join(bytes((code, len(actions))) + self.behaviors[code] + b"".join(actions)
for code, actions in self._actions().items())
return hashlib.sha256(data + bytes(self._modifier_data()[1]) + action_data).hexdigest()
def _actions(self):
# KeySyms | KeyActions | KeyBehaviors, using the authoritative public
# XKBstr.h LP64 prefixes only. All indices are bounded before slicing.
pointer = self.x11.XkbGetMap(self.display, 2 | 16 | 32, 256)
require(bool(pointer), "TYPE_KEY_ACTIONS_UNAVAILABLE")
try:
keyboard = pointer.contents
require(8 <= keyboard.min_key_code <= keyboard.max_key_code <= 255
and keyboard.server and keyboard.map, "TYPE_KEY_ACTIONS_INVALID")
server, client = keyboard.server.contents, keyboard.map.contents
require(client.key_sym_map and 0 <= server.num_acts <= server.size_acts <= 65535
and (server.num_acts == 0 or server.acts), "TYPE_KEY_ACTIONS_INVALID")
result, behaviors = {}, {}
for code in range(keyboard.min_key_code, keyboard.max_key_code + 1):
entry = client.key_sym_map[code]
groups = entry.group_info & 0x0F
require(groups <= 4 and entry.width <= 32, "TYPE_KEY_ACTIONS_INVALID")
count = groups * entry.width
offset = server.key_acts[code] if server.key_acts else 0
require(not offset or (0 < count and offset + count <= server.num_acts),
"TYPE_KEY_ACTIONS_INVALID")
result[code] = (tuple(bytes(server.acts[index]) for index in range(offset, offset + count))
if offset else (bytes(8),))
behaviors[code] = (C.string_at(server.behaviors + 2 * code, 2)
if server.behaviors else bytes(2))
self.behaviors = behaviors
return result
finally:
self.x11.XkbFreeKeyboard(pointer, 0, True)
def validate_strokes(self, strokes, locked_mods=0):
actions = self._actions()
codes = {stroke.code for stroke in strokes} | {code for stroke in strokes for code in stroke.modifiers}
require(all(self.behaviors.get(code) == bytes(2) for code in codes),
"TYPE_KEY_BEHAVIOR_UNSUPPORTED")
validate_actions(actions, strokes, self.modifier_keys, locked_mods)
def snapshot(self):
state, window = XkbState(), C.c_ulong()
require(self.x11.XkbGetState(self.display, 256, C.byref(state)) == 0,
"TYPE_KEYBOARD_STATE_UNAVAILABLE")
require(self.xdo.xdo_get_active_window(self.context, C.byref(window)) == 0,
"TYPE_ACTIVE_WINDOW_UNAVAILABLE")
keys = C.create_string_buffer(32)
self.x11.XQueryKeymap(self.display, keys)
held = tuple(sorted(code for code in self.modifier_codes
if keys.raw[code // 8] & (1 << (code % 8))))
# XKB actions (e.g. grp:alt_shift_toggle) can be attached even to
# ordinary-looking modifier keysyms. No modifier release is safe to
# infer from keysym alone. This revision therefore requires none held.
require(not held, "TYPE_HELD_MODIFIERS_UNSUPPORTED")
require(not state.base_mods or held, "TYPE_MODIFIER_STATE_UNSUPPORTED")
screen = self.x11.XDefaultScreen(self.display)
return Snapshot(state.group, state.locked_group, state.base_group, state.latched_group,
state.mods, state.base_mods, state.latched_mods, state.locked_mods,
window.value, self.x11.XDisplayWidth(self.display, screen),
self.x11.XDisplayHeight(self.display, screen), self._map_token(), held)
def lookup(self, code, modifiers, group):
used, symbol = C.c_uint(), C.c_ulong()
if not self.x11.XkbLookupKeySym(self.display, code, modifiers | (group << 13),
C.byref(used), C.byref(symbol)):
return 0
value = symbol.value
if value in self.characters:
return self.characters[value]
if 32 <= value <= 126 or 160 <= value <= 255:
return value
if 0x01000000 <= value <= 0x0110FFFF:
return value & 0xFFFFFF
return 0
def event(self, code, pressed):
require(8 <= code <= 255, "TYPE_KEYCODE_INVALID")
require(self.xtst.XTestFakeKeyEvent(self.display, code, bool(pressed), 0) != 0,
"TYPE_XTEST_EVENT_UNCERTAIN")
def sync(self):
self.x11.XSync(self.display, 0)
def close(self):
if self.context:
self.xdo.xdo_free(self.context)
self.context = None
if __name__ == "__main__":
if sys.argv[1:] == ["--worker"]:
_worker()
+102 -19
View File
@@ -1,18 +1,24 @@
#!/usr/bin/python3 #!/usr/bin/python3
"""Archive the reviewed community source layout without runtime profiles or QA logs.""" """Archive the reviewed community source layout without runtime profiles or QA logs."""
import argparse
import calendar import calendar
import gzip import gzip
import hashlib import hashlib
import io import io
import json import json
import re
from pathlib import Path from pathlib import Path
import tarfile import tarfile
import sys
from build_profiles import CANDIDATE_VERSION, SOURCE_VENDOR, target_profile, validate_vendor, vendor_versions
from release_metadata import PUBLIC_RELEASE_PROFILES
BASE = Path(__file__).resolve().parents[1] BASE = Path(__file__).resolve().parents[1]
ROOT = BASE.parent ROOT = BASE.parent
EPOCH = calendar.timegm((2026, 10, 26, 0, 0, 0)) EPOCH = calendar.timegm((2026, 10, 27, 0, 0, 0))
ARCHIVE_ROOT = "FrameBridge-0.3.1" ARCHIVE_ROOT = "FrameBridge-" + CANDIDATE_VERSION
ARCHIVE_NAME = "FrameBridge-0.3.1-source.tar.gz" ARCHIVE_NAME = ARCHIVE_ROOT + "-source.tar.gz"
SOURCE_FOLDERS = ("sources", "expanded", "portable", "packaging", "docs", "tests") SOURCE_FOLDERS = ("sources", "expanded", "portable", "packaging", "docs", "tests")
ROOT_FILES = ("README.md", "LICENSE", "LICENSING.md", "COPYRIGHT.md", ROOT_FILES = ("README.md", "LICENSE", "LICENSING.md", "COPYRIGHT.md",
"LICENSE-STATUS.md", "OFFICIAL-RELEASES.md", "CONTRIBUTING.md", "SECURITY.md", "RELEASE-NOTES.md") "LICENSE-STATUS.md", "OFFICIAL-RELEASES.md", "CONTRIBUTING.md", "SECURITY.md", "RELEASE-NOTES.md")
@@ -20,7 +26,46 @@ ROOT_FOLDERS = ("docs", "provenance", "licensing")
BLOG_POLICY_FILES = ("README.md", "LICENSING.md", "POST-TEMPLATE.md", "LICENSE-CC-BY-SA-4.0.txt") BLOG_POLICY_FILES = ("README.md", "LICENSING.md", "POST-TEMPLATE.md", "LICENSE-CC-BY-SA-4.0.txt")
ARCHIVE_README_NAME = "README-SOURCE-ARCHIVE.md" ARCHIVE_README_NAME = "README-SOURCE-ARCHIVE.md"
DEPENDENCY_SOURCE_ASSETS = [{'filename': 'FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz', 'bytes': 82606998, 'sha256': '64540a85bd65eac3f394d8c0a967cacbc00e112676d57d644dcd1bb691c1926a'}, {'filename': 'FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz', 'bytes': 26941745, 'sha256': '6445d4f0e773aba833fe5c610fbae54102b92de2ce636438f769f21e890c2bd9'}] DEPENDENCY_SOURCE_ASSETS = [{'filename': 'FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz', 'bytes': 82606998, 'sha256': '64540a85bd65eac3f394d8c0a967cacbc00e112676d57d644dcd1bb691c1926a'}, {'filename': 'FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz', 'bytes': 26941745, 'sha256': '6445d4f0e773aba833fe5c610fbae54102b92de2ce636438f769f21e890c2bd9'}]
ARCHIVE_README = "# FrameBridge 0.3.1 source archive\n\nThis archive preserves the repository layout needed by the offline builder.\nFrom this directory, run python3 source/packaging/build_expanded.py to build\nFrameBridge-0.3.1.deb. The builder writes source/build and source/release;\nit does not install or start FrameBridge. Debian's dpkg-deb and the documented\nbuild prerequisites must already be available.\n\nOriginal FrameBridge material is GNU GPL version 3 only. Read LICENSE,\nLICENSING.md, COPYRIGHT.md, LICENSE-STATUS.md and OFFICIAL-RELEASES.md for\nscope, attribution, third-party terms and the official-release trust model.\n\nThe project source asset is accompanied by these separate dependency assets:\n\n- FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz\n 82,606,998 bytes\n SHA-256 64540a85bd65eac3f394d8c0a967cacbc00e112676d57d644dcd1bb691c1926a\n- FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz\n 26,941,745 bytes\n SHA-256 6445d4f0e773aba833fe5c610fbae54102b92de2ce636438f769f21e890c2bd9\n\nThe Preview1 filename deliberately preserves the identity of an immutable\nshared asset. Retain licensing/third-party/R2-SUPPLEMENTS.json and its referenced\nsource notices with the dependency sources. The source indices and\nSOURCE-AVAILABILITY.md there record scope and provenance. Do not substitute\nunrelated archives. The retained tunnel executable is not claimed to have been\nindependently rebuilt from an identified own-source revision.\n\nThe blog directory contains licensing policies and the post template.\nEditorial article drafts are omitted because they are not source build inputs.\n\nOnly source/evidence/BASELINE.json is included from technical evidence. Runtime\nprofiles, private QA logs, generated build/release files and Git history are\noutside the source archive allowlist.\n\nA completed archive or builder run is not a declaration of public readiness.\nRelease checksums, signatures and publication status are recorded separately.\n".encode("utf-8") ARCHIVE_README = f"# FrameBridge {CANDIDATE_VERSION} compatibility candidate source\n\nThis source archive is a QA candidate, not a signed public release or evidence\nthat an OS/desktop lifecycle has been tested. Original signed 0.3.1 assets retain\ntheir separate immutable identity. Check TARGET-VENDOR-PROVENANCE.json for the\nnative ABI inputs actually included in this archive.\n\nThe offline builder requires Python 3, Debian dpkg/dpkg-deb and documented host\nbuild prerequisites. It writes source/build/<profile> and source/release/<profile>;\nit never installs a package, fetches dependencies, changes system Python or starts\nFrameBridge services. Run from this archive's top-level directory:\n\n python3 source/packaging/build_expanded.py --target debian13-amd64-py313\n python3 source/packaging/build_expanded.py --target lmde7-amd64-py313\n\nIf the cp312 input is recorded as included:\n\n python3 source/packaging/build_expanded.py --target ubuntu24.04-amd64-py312 --vendor-dir source/target-vendors/cp312\n python3 source/packaging/build_expanded.py --target mint22-amd64-py312 --vendor-dir source/target-vendors/cp312\n\nIf the cp314 input is recorded as included:\n\n python3 source/packaging/build_expanded.py --target ubuntu26.04-amd64-py314 --vendor-dir source/target-vendors/cp314\n\nThe default archive includes only the reviewed original cp313 vendor tree.\nThe archive producer can include reviewed alternate ABI trees using:\n\n python3 source/packaging/archive_sources.py --vendor-cp312 /reviewed/vendor-cp312 --vendor-cp314 /reviewed/vendor-cp314\n\nThose arguments must identify complete reviewed trees with the same 29 pinned\ndistribution names/versions as the original. The producer validates WHEEL/RECORD,\nCPython ABI, amd64 ELF and glibc baseline before including them. Local preparation\nmarkers and executable entry-point scripts are omitted. All included files and\ntheir hashes appear in SOURCE-ARCHIVE-MANIFEST.json alongside the finished archive;\nthe embedded provenance records input ABI identity without workstation paths.\n--check-vendor on build_expanded.py validates inputs without building a package.\nAn absent alternate tree must be supplied separately with equivalent provenance;\nchanging Depends cannot convert the original cp313 extensions into cp312/cp314.\n\nPackage output names contain the candidate version and a separate profile\nfolder. release-profile.json inside the installed prefix declares the exact\ncandidate profile. Build success and profile eligibility do not establish a\nvalidated installation, graphical control, real TTY or reboot lifecycle.\n\nOriginal FrameBridge material is GNU GPL version 3 only. Read LICENSE,\nLICENSING.md, COPYRIGHT.md, LICENSE-STATUS.md and OFFICIAL-RELEASES.md for scope,\nattribution, third-party terms and the official-release trust model.\n\nThe project source asset is accompanied by these separate unchanged dependency\nsource assets; they are not embedded or renamed by this helper:\n\n- FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz\n 82,606,998 bytes\n SHA-256 64540a85bd65eac3f394d8c0a967cacbc00e112676d57d644dcd1bb691c1926a\n- FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz\n 26,941,745 bytes\n SHA-256 6445d4f0e773aba833fe5c610fbae54102b92de2ce636438f769f21e890c2bd9\n\nThe Preview1 name preserves an immutable shared asset's identity. Retain the R2\nsource-notice supplements and both dependency indices under licensing/third-party.\nMatching package versions do not constitute an independent source rebuild of the\nretained tunnel executable; its exact own-source revision is not asserted.\n\nThe blog directory contains licensing policies and the post template. Draft\narticles, private runtime profiles, QA logs, build outputs and Git history are\noutside the source allowlist. Only source/evidence/BASELINE.json is included from\ntechnical evidence. Release signatures and publication status are separate\nrecords; this candidate archiver does not sign or publish anything.\n".encode("utf-8")
PUBLIC_ARCHIVE_README = f"""# FrameBridge {CANDIDATE_VERSION} corresponding source
This archive supplies the project source and the reviewed cp313 and cp312 vendor
inputs for Debian 13, Ubuntu 24.04 and Linux Mint 22.x on amd64. Keep its matching
SOURCE-ARCHIVE-MANIFEST.json sidecar beside the archive. Signatures, publication
and actual OS lifecycle acceptance are recorded separately; assembly alone does
not certify them. Historical signed assets retain their immutable identities.
The offline builder needs Python 3, dpkg/dpkg-deb and the documented host build
prerequisites. It never fetches dependencies, installs packages or starts services.
Extract the archive beside its matching sidecar and run from the extracted root:
python3 source/packaging/build_expanded.py --target debian13-amd64-py313 --public-release --source-manifest ../SOURCE-ARCHIVE-MANIFEST.json
python3 source/packaging/build_expanded.py --target ubuntu24.04-amd64-py312 --vendor-dir source/target-vendors/cp312 --public-release --source-manifest ../SOURCE-ARCHIVE-MANIFEST.json
python3 source/packaging/build_expanded.py --target mint22-amd64-py312 --vendor-dir source/target-vendors/cp312 --public-release --source-manifest ../SOURCE-ARCHIVE-MANIFEST.json
Public-release mode verifies the archive hash, every current project source file
and the selected reviewed vendor identity before assembling a package. It does
not assert an independent rebuild of third-party native wheels or tunnel-client.
--check-vendor validates inputs without staging a package.
Original FrameBridge material is GNU GPL version 3 only. Third-party material
retains its applicable terms. Read LICENSE, LICENSING.md, COPYRIGHT.md and the
source-delivery records. Distribute these separate, unchanged dependency assets
with the release; their historical names do not identify the installer version:
- FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz
Bytes: 82606998
SHA-256: 64540a85bd65eac3f394d8c0a967cacbc00e112676d57d644dcd1bb691c1926a
- FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz
Bytes: 26941745
SHA-256: 6445d4f0e773aba833fe5c610fbae54102b92de2ce636438f769f21e890c2bd9
The exact own-source revision of the retained tunnel executable is not asserted.
Retain the R2 source-notice supplements, both dependency indices and original
notices. Private profiles, credentials, QA logs, build outputs, Git history and
unpublished blog drafts are excluded from the archive allowlist.
""".encode("utf-8")
GPL_SHA256 = "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986" GPL_SHA256 = "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986"
def sha(path): def sha(path):
@@ -33,7 +78,7 @@ def source_files():
relative = path.relative_to(ROOT) relative = path.relative_to(ROOT)
if path.is_symlink(): if path.is_symlink():
raise RuntimeError("Source archives must not include symbolic links: " + str(relative)) raise RuntimeError("Source archives must not include symbolic links: " + str(relative))
if "__pycache__" in relative.parts or path.suffix == ".pyc": if any(part in {"__pycache__", ".git", "build", "release", "qa"} for part in relative.parts) or path.suffix in {".pyc", ".log"}:
return return
if path.is_file(): if path.is_file():
selected[relative.as_posix()] = path selected[relative.as_posix()] = path
@@ -75,22 +120,60 @@ def source_files():
raise RuntimeError("Required third-party source/notice input missing: " + name) raise RuntimeError("Required third-party source/notice input missing: " + name)
return selected return selected
def main(): def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--public-release", action="store_true", help="Assemble corresponding source for all three official release profiles")
parser.add_argument("--vendor-cp312", type=Path, help="Reviewed cp312 vendor input to include")
parser.add_argument("--vendor-cp314", type=Path, help="Reviewed cp314 vendor input to include")
args = parser.parse_args(argv)
if args.public_release and not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", CANDIDATE_VERSION):
parser.error("--public-release requires a stable project version")
if args.public_release and (args.vendor_cp312 is None or args.vendor_cp314 is not None):
parser.error("--public-release requires --vendor-cp312 and excludes experimental cp314 input")
names = source_files() names = source_files()
release = BASE / "release" expected_versions = vendor_versions(BASE / SOURCE_VENDOR)
release.mkdir(exist_ok=True) provenance = {"schema": 1, "version": CANDIDATE_VERSION, "validation_status": "release_source" if args.public_release else "candidate",
"release_profiles": list(PUBLIC_RELEASE_PROFILES) if args.public_release else [],
"vendors": {"cp313": {"path": "source/" + SOURCE_VENDOR.as_posix(),
**validate_vendor(BASE / SOURCE_VENDOR, target_profile("debian13-amd64-py313"), expected_versions)}}}
for abi, directory, profile_id in (("cp312", args.vendor_cp312, "ubuntu24.04-amd64-py312"),
("cp314", args.vendor_cp314, "ubuntu26.04-amd64-py314")):
if directory is None:
continue
evidence = validate_vendor(directory, target_profile(profile_id), expected_versions)
archive_path = "source/target-vendors/" + abi
provenance["vendors"][abi] = {"path": archive_path, **evidence}
for path in sorted(directory.rglob("*")):
relative = path.relative_to(directory)
if path.is_symlink():
raise RuntimeError("Alternate vendor source symlink rejected: " + str(relative))
if (not path.is_file() or any(part in {"__pycache__", ".git", "build", "release", "qa", "bin"} for part in relative.parts)
or path.suffix in {".pyc", ".log"} or path.name == "FB-ABI-PREPARED.json"):
continue
name = archive_path + "/" + relative.as_posix()
if name in names:
raise RuntimeError("Archive input path collision: " + name)
names[name] = path
provenance["omitted_vendor_abis"] = sorted({"cp312", "cp314"} - set(provenance["vendors"]))
if provenance["omitted_vendor_abis"] and not args.public_release:
print("Candidate source excludes alternate vendor ABIs: " + ", ".join(provenance["omitted_vendor_abis"]), file=sys.stderr)
generated = {ARCHIVE_README_NAME: PUBLIC_ARCHIVE_README if args.public_release else ARCHIVE_README,
"TARGET-VENDOR-PROVENANCE.json": (json.dumps(provenance, indent=2, sort_keys=True) + "\n").encode()}
release = BASE / "release" / ("public-source" if args.public_release else "candidate-source")
release.mkdir(parents=True, exist_ok=True)
output = release / ARCHIVE_NAME output = release / ARCHIVE_NAME
with output.open("wb") as raw: with output.open("wb") as raw:
with gzip.GzipFile(filename="", mode="wb", fileobj=raw, with gzip.GzipFile(filename="", mode="wb", fileobj=raw,
mtime=EPOCH, compresslevel=6) as zipped: mtime=EPOCH, compresslevel=6) as zipped:
with tarfile.open(fileobj=zipped, mode="w", format=tarfile.GNU_FORMAT) as archive: with tarfile.open(fileobj=zipped, mode="w", format=tarfile.GNU_FORMAT) as archive:
info = tarfile.TarInfo(ARCHIVE_ROOT + "/" + ARCHIVE_README_NAME) for relative, data in sorted(generated.items()):
info.uid = info.gid = 0 info = tarfile.TarInfo(ARCHIVE_ROOT + "/" + relative)
info.uname = info.gname = "" info.uid = info.gid = 0
info.mtime = EPOCH info.uname = info.gname = ""
info.mode = 0o644 info.mtime = EPOCH
info.size = len(ARCHIVE_README) info.mode = 0o644
archive.addfile(info, io.BytesIO(ARCHIVE_README)) info.size = len(data)
archive.addfile(info, io.BytesIO(data))
for relative, path in sorted(names.items()): for relative, path in sorted(names.items()):
info = archive.gettarinfo(str(path), arcname=ARCHIVE_ROOT + "/" + relative) info = archive.gettarinfo(str(path), arcname=ARCHIVE_ROOT + "/" + relative)
info.uid = info.gid = 0 info.uid = info.gid = 0
@@ -99,10 +182,10 @@ def main():
info.mode = 0o755 if path.stat().st_mode & 0o111 else 0o644 info.mode = 0o755 if path.stat().st_mode & 0o111 else 0o644
with path.open("rb") as stream: with path.open("rb") as stream:
archive.addfile(info, stream) archive.addfile(info, stream)
manifest = {"status": "COMMUNITY_RELEASE_BUILD", "archive": output.name, manifest = {"status": "RELEASE_SOURCE_ASSEMBLED" if args.public_release else "COMPATIBILITY_CANDIDATE_BUILD", "version": CANDIDATE_VERSION, "vendor_inputs": provenance, "archive": output.name,
"bytes": output.stat().st_size, "sha256": sha(output), "bytes": output.stat().st_size, "sha256": sha(output),
"files": [{"path": ARCHIVE_README_NAME, "files": [{"path": name, "sha256": hashlib.sha256(data).hexdigest(), "generated": True}
"sha256": hashlib.sha256(ARCHIVE_README).hexdigest(), "generated": True}] for name, data in sorted(generated.items())]
+ [{"path": relative, "sha256": sha(path)} + [{"path": relative, "sha256": sha(path)}
for relative, path in sorted(names.items())], for relative, path in sorted(names.items())],
"draft_blog_articles_included": False, "draft_blog_articles_included": False,
@@ -111,7 +194,7 @@ def main():
"dependency_source_archives": DEPENDENCY_SOURCE_ASSETS} "dependency_source_archives": DEPENDENCY_SOURCE_ASSETS}
(release / "SOURCE-ARCHIVE-MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n") (release / "SOURCE-ARCHIVE-MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n")
print(json.dumps({"archive": str(output), "bytes": output.stat().st_size, print(json.dumps({"archive": str(output), "bytes": output.stat().st_size,
"sha256": manifest["sha256"], "files": len(names) + 1, "sha256": manifest["sha256"], "files": len(names) + len(generated),
"status": manifest["status"]})) "status": manifest["status"]}))
if __name__ == "__main__": if __name__ == "__main__":
+168 -25
View File
@@ -1,20 +1,32 @@
#!/usr/bin/python3 #!/usr/bin/python3
"""Reproducible Debian package builder; never installs or starts the payload.""" """Reproducible Debian package builder; never installs or starts the payload."""
import argparse
import ast
import calendar import calendar
import gzip import gzip
import hashlib import hashlib
import json import json
import os import os
import re
from pathlib import Path from pathlib import Path
import shutil import shutil
import shlex
import subprocess import subprocess
from build_profiles import CANDIDATE_VERSION, PROFILES, SOURCE_VENDOR, VENDOR_RELATIVE, target_profile, validate_vendor, vendor_versions
from release_metadata import release_source
BASE = Path(__file__).resolve().parents[1] BASE = Path(__file__).resolve().parents[1]
ROOT = BASE.parent ROOT = BASE.parent
VERSION = "0.3.1" VERSION = CANDIDATE_VERSION
PUBLIC_RELEASE = False
SOURCE_ARCHIVE = None
# Oldest package retaining the privileged helper lifecycle. Candidate/release
# version bumps must not move this downgrade revocation boundary.
PRIVILEGED_HELPER_FLOOR = "0.3.2"
PUBLIC_FILENAME = "FrameBridge-0.3.1.deb" PUBLIC_FILENAME = "FrameBridge-0.3.1.deb"
GPL_SHA256 = "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986" GPL_SHA256 = "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986"
EPOCH = calendar.timegm((2026, 10, 26, 0, 0, 0)) EPOCH = calendar.timegm((2026, 10, 27, 0, 0, 0))
PACKAGE = "framebridge-original-expanded" PACKAGE = "framebridge-original-expanded"
RELEASE = BASE / "release" RELEASE = BASE / "release"
STAGE = BASE / "build/package-root" STAGE = BASE / "build/package-root"
@@ -26,6 +38,14 @@ def sha(path):
return hashlib.sha256(path.read_bytes()).hexdigest() return hashlib.sha256(path.read_bytes()).hexdigest()
def contains_private_path(data, private_root):
"""Match an actual path component, not e.g. /root in root_model.py."""
prefix = str(private_root).rstrip("/").encode()
if not prefix:
return False
return re.search(re.escape(prefix) + rb"(?=$|[/\s\"'<>`])", data) is not None
# These are release inputs, not files inferred from installed runtime profiles. # These are release inputs, not files inferred from installed runtime profiles.
LEGAL_FILES = ("LICENSE", "LICENSING.md", "COPYRIGHT.md", "LICENSE-STATUS.md", "OFFICIAL-RELEASES.md") LEGAL_FILES = ("LICENSE", "LICENSING.md", "COPYRIGHT.md", "LICENSE-STATUS.md", "OFFICIAL-RELEASES.md")
THIRD_PARTY_REQUIRED = ("THIRD-PARTY-NOTICES.md", "DEPENDENCY-SOURCES.json", "SOURCE-AVAILABILITY.md", "R2-SUPPLEMENTS.json", "NATIVE-DEPENDENCY-TERMS.json", "NATIVE-DEPENDENCY-TERMS.md", "TUNNEL-DEPENDENCY-SOURCES.json") THIRD_PARTY_REQUIRED = ("THIRD-PARTY-NOTICES.md", "DEPENDENCY-SOURCES.json", "SOURCE-AVAILABILITY.md", "R2-SUPPLEMENTS.json", "NATIVE-DEPENDENCY-TERMS.json", "NATIVE-DEPENDENCY-TERMS.md", "TUNNEL-DEPENDENCY-SOURCES.json")
@@ -74,7 +94,7 @@ def legal_preflight():
for path in inputs: for path in inputs:
data = path.read_bytes() data = path.read_bytes()
for private_root in private_roots: for private_root in private_roots:
if private_root.encode() in data: if contains_private_path(data, private_root):
raise RuntimeError("Local path found in legal input: " + str(path.relative_to(ROOT))) raise RuntimeError("Local path found in legal input: " + str(path.relative_to(ROOT)))
return index return index
@@ -92,7 +112,7 @@ def install_legal_material(docs):
shutil.copy2(ROOT / "blog" / name, docs / "blog" / name) shutil.copy2(ROOT / "blog" / name, docs / "blog" / name)
shutil.copy2(ROOT / "RELEASE-NOTES.md", docs / "RELEASE-NOTES.md") shutil.copy2(ROOT / "RELEASE-NOTES.md", docs / "RELEASE-NOTES.md")
(docs / "copyright").write_text( (docs / "copyright").write_text(
"FrameBridge Original Expanded — Community Release 0.3.1\n\n" "FrameBridge Original Expanded — " + ("release " if PUBLIC_RELEASE else "compatibility candidate ") + VERSION + "\n\n"
"Original FrameBridge material: GNU GPL version 3 only (GPL-3.0-only).\n" "Original FrameBridge material: GNU GPL version 3 only (GPL-3.0-only).\n"
"The complete license is included in LICENSE. COPYRIGHT.md and LICENSING.md\n" "The complete license is included in LICENSE. COPYRIGHT.md and LICENSING.md\n"
"identify attribution, scope and third-party exceptions.\n\n" "identify attribution, scope and third-party exceptions.\n\n"
@@ -105,8 +125,9 @@ def install_legal_material(docs):
"The assembled notices and license texts are included under\n" "The assembled notices and license texts are included under\n"
"[licensing/third-party](licensing/third-party/THIRD-PARTY-NOTICES.md).\n" "[licensing/third-party](licensing/third-party/THIRD-PARTY-NOTICES.md).\n"
"Original vendored notices are preserved with their components.\n") "Original vendored notices are preserved with their components.\n")
lines = ["# Source availability for FrameBridge 0.3.1", "", lines = ["# Source availability for FrameBridge " + VERSION, "",
"Project source archive: FrameBridge-0.3.1-source.tar.gz.", "", ("Matching corresponding source: " + SOURCE_ARCHIVE["filename"] + " (SHA-256 " + SOURCE_ARCHIVE["sha256"] + ")."
if PUBLIC_RELEASE else "Candidate source must be retained with its build identity; no public archive is asserted."), "",
"The release source set also includes these separate assets:", ""] "The release source set also includes these separate assets:", ""]
for item in DEPENDENCY_SOURCE_ASSETS: for item in DEPENDENCY_SOURCE_ASSETS:
lines.extend(["- " + item["filename"], lines.extend(["- " + item["filename"],
@@ -122,32 +143,147 @@ def install_legal_material(docs):
"signature status are external release records, not builder claims.", ""]) "signature status are external release records, not builder claims.", ""])
(docs / "SOURCE-AVAILABILITY.md").write_text("\n".join(lines)) (docs / "SOURCE-AVAILABILITY.md").write_text("\n".join(lines))
(docs / "COMMUNITY-RELEASE-STATUS.json").write_text(json.dumps({ (docs / "COMMUNITY-RELEASE-STATUS.json").write_text(json.dumps({
"schema_version": 1, "version": VERSION, "status": "COMMUNITY_RELEASE_BUILD", "schema_version": 1, "version": VERSION,
"status": "PUBLIC_RELEASE_BUILD_ASSEMBLED" if PUBLIC_RELEASE else "COMPATIBILITY_CANDIDATE_BUILD",
"certified_as_RC7": False, "certified_as_RC7": False,
"public_readiness": "EXTERNAL_RELEASE_RECORD", "public_readiness": "EXTERNAL_RELEASE_RECORD",
"signature_status": "EXTERNAL_RELEASE_RECORD", "signature_status": "EXTERNAL_RELEASE_RECORD",
"vm_lifecycle_validation": "NOT_ASSERTED_BY_BUILDER", "vm_lifecycle_validation": "NOT_ASSERTED_BY_BUILDER",
"source_archive": "FrameBridge-0.3.1-source.tar.gz", "source_archive": SOURCE_ARCHIVE if PUBLIC_RELEASE else "CANDIDATE_SOURCE_REQUIRED_BEFORE_PUBLICATION",
"dependency_source_archives": DEPENDENCY_SOURCE_ASSETS "dependency_source_archives": DEPENDENCY_SOURCE_ASSETS
}, indent=2) + "\n") }, indent=2) + "\n")
def main(): def maintainer_prerm(version):
"""Revoke helper grants before a downgrade removes the helper's code.
The argument is the fixed helper compatibility floor, not the package version.
Targets retaining the helper lifecycle preserve managed credentials.
The fixed installed cleanup module validates the helper's root ownership,
permissions and ancestors before running its bounded purge-all command.
"""
if not re.fullmatch(r"[0-9][0-9A-Za-z.+:~\-]*", version):
raise ValueError("Invalid Debian package version for maintainer script")
helper_cleanup = (
'import runpy; runpy.run_path('
'"/usr/lib/framebridge-expanded/expanded/framebridge_expanded/uninstall.py")'
'["purge_credential_helpers"]()'
)
return (
"#!/bin/sh\nset -eu\n"
'case "$1" in\n'
" remove|deconfigure)\n"
" /usr/bin/python3 -I -S /usr/lib/framebridge-expanded/expanded/framebridge_expanded/uninstall.py --all-users\n"
" if [ -e /etc/framebridge-expanded/screen-access.json ] || [ -L /etc/framebridge-expanded/screen-access.json ]; then\n"
" /usr/lib/framebridge-expanded/bin/framebridge-screen-access disable\n"
" fi\n"
" ;;\n"
" upgrade)\n"
' /usr/bin/dpkg --validate-version "$2"\n'
' if /usr/bin/dpkg --compare-versions "$2" lt ' + shlex.quote(version) + "; then\n"
" /usr/bin/python3 -I -S -c " + shlex.quote(helper_cleanup) + "\n"
" fi\n"
" ;;\nesac\n"
)
def maintainer_preinst(version=None):
"""Snapshot running profiles for upgrades and same-version reinstalls."""
version = version or VERSION
if not re.fullmatch(r"[0-9][0-9A-Za-z.+:~\\-]*", version):
raise ValueError("Invalid package upgrade version")
module = (BASE / "expanded/framebridge_expanded/package_upgrade.py").read_text()
module = module.rsplit("if __name__ == '__main__':", 1)[0]
return ("#!/bin/sh\nset -eu\ncase \"$1\" in\n upgrade)\n"
'/usr/bin/dpkg --validate-version "$2"\n'
'if /usr/bin/dpkg --compare-versions "$2" le ' + shlex.quote(version) + "; then\n"
"/usr/bin/python3 -I -S - <<'FRAMEBRIDGE_UPGRADE_SNAPSHOT'\n"
+ module + "\nraise SystemExit(main(['snapshot']))\n"
+ "FRAMEBRIDGE_UPGRADE_SNAPSHOT\nfi\n ;;\nesac\n")
def maintainer_postinst():
return ("#!/bin/sh\nset -eu\ncase \"$1\" in\n configure)\n"
"/usr/bin/python3 -I -S /usr/lib/framebridge-expanded/expanded/framebridge_expanded/package_upgrade.py schedule\n"
" ;;\nesac\n")
def package_relationships(profile):
"""Keep the opt-in virtual window manager out of automatic APT installs."""
return {
"Depends": "python3 (>= " + profile["python_minor"] + "), python3 (<< 3." + str(int(profile["python_minor"].split(".")[1]) + 1) + "), libc6 (>= 2.34), libgcc-s1, python3-cryptography, sudo, libpam-systemd, dbus-user-session, udev, libx11-6, ca-certificates, iproute2, procps, systemd, bash, tmux, coreutils, util-linux, mawk | gawk, gnome-terminal, wmctrl, xdotool, x11-utils, xclip, imagemagick, xterm, dbus-x11, xauth, x11-xkb-utils, fonts-dejavu-core",
"Suggests": "xvfb, openbox",
}
def main(argv=None):
global VERSION, PUBLIC_FILENAME, RELEASE, STAGE, PREFIX, PUBLIC_RELEASE, SOURCE_ARCHIVE
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target", required=True, choices=sorted(PROFILES))
parser.add_argument("--version", default=CANDIDATE_VERSION)
parser.add_argument("--vendor-dir", type=Path, help="Reviewed target-ABI Fleet Hub vendor tree; never installed globally")
parser.add_argument("--public-release", action="store_true", help="Bind an official release profile to its assembled corresponding-source archive")
parser.add_argument("--source-manifest", type=Path, help="Matching SOURCE-ARCHIVE-MANIFEST.json kept beside its source archive")
parser.add_argument("--check-vendor", action="store_true", help="Validate inputs without staging or building a package")
args = parser.parse_args(argv)
if not re.fullmatch(r"[0-9][0-9A-Za-z.+:~\-]*", args.version) or args.version == "0.3.1":
parser.error("A distinct valid Debian candidate version is required")
subprocess.run(["dpkg", "--validate-version", args.version], check=True)
VERSION = args.version
PUBLIC_RELEASE = args.public_release
SOURCE_ARCHIVE = None
if args.public_release and args.source_manifest is None:
parser.error("--public-release requires --source-manifest")
if args.source_manifest and not args.public_release:
parser.error("--source-manifest requires --public-release")
version_assignments = [node for node in ast.parse((BASE / "expanded/framebridge_expanded/__init__.py").read_text()).body
if isinstance(node, ast.Assign) and any(isinstance(item, ast.Name) and item.id == "__version__" for item in node.targets)]
if len(version_assignments) != 1 or ast.literal_eval(version_assignments[0].value) != VERSION:
raise RuntimeError("Candidate package version differs from application __version__")
profile = target_profile(args.target)
profile["package_version"] = VERSION
original_vendor = BASE / SOURCE_VENDOR
vendor = args.vendor_dir.resolve() if args.vendor_dir else original_vendor
if args.vendor_dir is None and profile["python_abi"] != "cp313":
parser.error("This target requires --vendor-dir with reviewed " + profile["python_abi"] + " wheels")
vendor_evidence = validate_vendor(vendor, profile, vendor_versions(original_vendor))
profile["vendor"] = vendor_evidence
if args.public_release:
from archive_sources import source_files
SOURCE_ARCHIVE = release_source(args.source_manifest, VERSION, args.target, source_files(), vendor_evidence)
profile["validation_status"] = "release_build"
profile["source_archive"] = SOURCE_ARCHIVE
PUBLIC_FILENAME = "FrameBridge-" + VERSION + "-" + args.target + ".deb"
RELEASE = BASE / "release" / args.target
STAGE = BASE / "build" / args.target / "package-root"
PREFIX = STAGE / "usr/lib/framebridge-expanded"
legal_preflight() legal_preflight()
baseline = json.loads((BASE / "evidence/BASELINE.json").read_text()) baseline = json.loads((BASE / "evidence/BASELINE.json").read_text())
for relative, expected in baseline["source_hashes"].items(): for relative, expected in baseline["source_hashes"].items():
assert sha(BASE / "sources" / relative) == expected, "Original source changed: " + relative if sha(BASE / "sources" / relative) != expected:
raise RuntimeError("Original source changed: " + relative)
if args.check_vendor:
print(json.dumps(profile, sort_keys=True))
return
if STAGE.exists(): if STAGE.exists():
shutil.rmtree(STAGE) shutil.rmtree(STAGE)
PREFIX.mkdir(parents=True) PREFIX.mkdir(parents=True)
RELEASE.mkdir(exist_ok=True) RELEASE.mkdir(parents=True, exist_ok=True)
for name, dest in [("original-asus", "asus"), ("original-geranas", "geranas"), ("original-gerameg", "gerameg")]: for name, dest in [("original-asus", "asus"), ("original-geranas", "geranas"), ("original-gerameg", "gerameg")]:
source = BASE / "sources" / name source = BASE / "sources" / name
assert source.is_dir(), "Required public source snapshot missing: " + name if not source.is_dir():
raise RuntimeError("Required public source snapshot missing: " + name)
shutil.copytree(source, PREFIX / "original" / dest, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) shutil.copytree(source, PREFIX / "original" / dest, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
staged_vendor = PREFIX / VENDOR_RELATIVE
shutil.rmtree(staged_vendor)
shutil.copytree(vendor, staged_vendor, ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "bin", "FB-ABI-PREPARED.json"))
if validate_vendor(staged_vendor, profile, vendor_versions(original_vendor)) != vendor_evidence:
raise RuntimeError("Staged vendor tree differs from reviewed input")
(PREFIX / "release-profile.json").write_text(json.dumps(profile, indent=2, sort_keys=True) + "\n")
for source in ("portable", "expanded", "docs"): for source in ("portable", "expanded", "docs"):
shutil.copytree(BASE / source, PREFIX / source, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) shutil.copytree(BASE / source, PREFIX / source, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
(PREFIX / "bin").mkdir() (PREFIX / "bin").mkdir()
for name in ("framebridge", "framebridge-original", "framebridge-native-expanded", "framebridge-screen-access"): for name in ("framebridge", "framebridge-original", "framebridge-native-expanded", "framebridge-screen-access", "framebridge-credential-helper"):
shutil.copy2(BASE / "packaging" / name, PREFIX / "bin" / name) shutil.copy2(BASE / "packaging" / name, PREFIX / "bin" / name)
(PREFIX / "bin" / name).chmod(0o755) (PREFIX / "bin" / name).chmod(0o755)
rules = STAGE / "usr/lib/udev/rules.d" rules = STAGE / "usr/lib/udev/rules.d"
@@ -177,6 +313,15 @@ def main():
for file in sorted((BASE / "docs").glob("*")): for file in sorted((BASE / "docs").glob("*")):
if file.is_file(): if file.is_file():
shutil.copy2(file, docs / file.name) shutil.copy2(file, docs / file.name)
# Ship the same owner/agent entry points as the reviewed source repository.
user_guides = {"README.md": ROOT / "README.md",
"FRAMEBRIDGE-AGENT-START.md": ROOT / "docs/FRAMEBRIDGE-AGENT-START.md"}
for name, source in user_guides.items():
if not source.is_file() or source.is_symlink():
raise RuntimeError("Required user guide is missing or not a regular file: " + str(source))
for destination in (PREFIX / "docs", docs):
destination.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination / name)
readme = (BASE / "docs/DEBIAN-TUTORIAL.md").read_bytes() readme = (BASE / "docs/DEBIAN-TUTORIAL.md").read_bytes()
(docs / "README.Debian.gz").write_bytes(gzip.compress(readme, mtime=EPOCH)) (docs / "README.Debian.gz").write_bytes(gzip.compress(readme, mtime=EPOCH))
install_legal_material(docs) install_legal_material(docs)
@@ -188,12 +333,14 @@ def main():
control = STAGE / "DEBIAN" control = STAGE / "DEBIAN"
control.mkdir() control.mkdir()
installed = sum(p.stat().st_size for p in STAGE.rglob("*") if p.is_file() and not p.is_symlink()) installed = sum(p.stat().st_size for p in STAGE.rglob("*") if p.is_file() and not p.is_symlink())
relationships = package_relationships(profile)
(control / "control").write_text( (control / "control").write_text(
"Package: " + PACKAGE + "\nVersion: " + VERSION + "\nSection: utils\nPriority: optional\nArchitecture: amd64\n" "Package: " + PACKAGE + "\nVersion: " + VERSION + "\nSection: utils\nPriority: optional\nArchitecture: amd64\n"
"Maintainer: FrameBridge <framebridge@localhost>\n" "Maintainer: FrameBridge <gerardobh1128@gmail.com>\n"
"Homepage: https://framebridge.betancourt.one/\n" "Homepage: https://framebridge.betancourt.one/\n"
"Installed-Size: " + str((installed + 1023) // 1024) + "\n" "Installed-Size: " + str((installed + 1023) // 1024) + "\n"
"Depends: python3 (>= 3.13), python3 (<< 3.14), libc6 (>= 2.34), libgcc-s1, python3-cryptography, sudo, libpam-systemd, dbus-user-session, udev, libx11-6, ca-certificates, iproute2, procps, systemd, bash, tmux, coreutils, util-linux, mawk | gawk, gnome-terminal, wmctrl, xdotool, x11-utils, xclip, imagemagick, xterm, xvfb, openbox, dbus-x11, xauth, x11-xkb-utils, fonts-dejavu-core\n" "Depends: " + relationships["Depends"] + "\n"
"Suggests: " + relationships["Suggests"] + "\n"
"Description: FrameBridge with standalone, central and node modes\n" "Description: FrameBridge with standalone, central and node modes\n"
" Original execution and desktop programs, a real persistent terminal,\n" " Original execution and desktop programs, a real persistent terminal,\n"
" authenticated node enrollment, and a manual ChatGPT tunnel wizard.\n" " authenticated node enrollment, and a manual ChatGPT tunnel wizard.\n"
@@ -203,16 +350,11 @@ def main():
" Optional virtual displays are explicitly identified as virtual.\n" " Optional virtual displays are explicitly identified as virtual.\n"
" No phone or Owner Device pairing. Services start after connection setup.\n" " No phone or Owner Device pairing. Services start after connection setup.\n"
" Installing the package alone does not activate a connection.\n") " Installing the package alone does not activate a connection.\n")
(control / "prerm").write_text( (control / "prerm").write_text(maintainer_prerm(PRIVILEGED_HELPER_FLOOR))
"#!/bin/sh\nset -eu\n"
"case \"$1\" in\n"
" remove|deconfigure)\n"
" /usr/bin/python3 -I -S /usr/lib/framebridge-expanded/expanded/framebridge_expanded/uninstall.py --all-users\n"
" if [ -e /etc/framebridge-expanded/screen-access.json ] || [ -L /etc/framebridge-expanded/screen-access.json ]; then\n"
" /usr/lib/framebridge-expanded/bin/framebridge-screen-access disable\n"
" fi\n"
" ;;\nesac\n")
(control / "prerm").chmod(0o755) (control / "prerm").chmod(0o755)
for hook, contents in (("preinst", maintainer_preinst()), ("postinst", maintainer_postinst())):
(control / hook).write_text(contents)
(control / hook).chmod(0o755)
rows = [] rows = []
for path in sorted(STAGE.rglob("*")): for path in sorted(STAGE.rglob("*")):
if path.is_file() and not path.is_symlink() and control not in path.parents: if path.is_file() and not path.is_symlink() and control not in path.parents:
@@ -230,12 +372,13 @@ def main():
os.utime(path, (EPOCH, EPOCH), follow_symlinks=False) os.utime(path, (EPOCH, EPOCH), follow_symlinks=False)
manifest = [{"path": str(p.relative_to(STAGE)), "sha256": sha(p), "bytes": p.stat().st_size} manifest = [{"path": str(p.relative_to(STAGE)), "sha256": sha(p), "bytes": p.stat().st_size}
for p in sorted(STAGE.rglob("*")) if p.is_file() and not p.is_symlink()] for p in sorted(STAGE.rglob("*")) if p.is_file() and not p.is_symlink()]
(RELEASE / "RELEASE-PROFILE.json").write_text(json.dumps(profile, indent=2, sort_keys=True) + "\n")
(RELEASE / "PACKAGE-MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n") (RELEASE / "PACKAGE-MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n")
output = RELEASE / ("framebridge-expanded_" + VERSION + "_amd64.deb") output = RELEASE / ("framebridge-expanded_" + VERSION + "_amd64.deb")
env = dict(os.environ, SOURCE_DATE_EPOCH=str(EPOCH)) env = dict(os.environ, SOURCE_DATE_EPOCH=str(EPOCH))
subprocess.run(["dpkg-deb", "--root-owner-group", "-Zxz", "-z6", "--build", str(STAGE), str(output)], check=True, env=env) subprocess.run(["dpkg-deb", "--root-owner-group", "-Zxz", "-z6", "--build", str(STAGE), str(output)], check=True, env=env)
shutil.copy2(output, RELEASE / PUBLIC_FILENAME) shutil.copy2(output, RELEASE / PUBLIC_FILENAME)
print(json.dumps({"package": str(output), "bytes": output.stat().st_size, "sha256": sha(output), "payload_entries": len(manifest), "public_filename": PUBLIC_FILENAME, "status": "COMMUNITY_RELEASE_BUILD"})) print(json.dumps({"package": str(output), "bytes": output.stat().st_size, "sha256": sha(output), "payload_entries": len(manifest), "profile_id": args.target, "validation_status": "release_build" if PUBLIC_RELEASE else "candidate", "public_filename": PUBLIC_FILENAME, "status": "PUBLIC_RELEASE_BUILD_ASSEMBLED" if PUBLIC_RELEASE else "COMPATIBILITY_CANDIDATE_BUILD"}))
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+193
View File
@@ -0,0 +1,193 @@
"""Explicit Debian-family candidate targets and offline vendor validation.
This module never installs dependencies or imports the target's native modules.
Profiles describe eligible build inputs, not completed OS/desktop validation.
"""
import base64
import csv
from email.parser import Parser
import hashlib
from pathlib import Path
import re
CANDIDATE_VERSION = "0.3.4"
VENDOR_RELATIVE = Path("original/geranas/FrameBridge-Fleet-Hub/vendor")
SOURCE_VENDOR = Path("sources/original-geranas/FrameBridge-Fleet-Hub/vendor")
GLIBC_BASELINE = (2, 34)
PROFILES = {
"debian13-amd64-py313": {"os_id": "debian", "os_version": "13", "python_minor": "3.13"},
"ubuntu24.04-amd64-py312": {"os_id": "ubuntu", "os_version": "24.04", "python_minor": "3.12"},
"mint22-amd64-py312": {"os_id": "linuxmint", "os_version": "22", "python_minor": "3.12"},
"ubuntu26.04-amd64-py314": {"os_id": "ubuntu", "os_version": "26.04", "python_minor": "3.14"},
"lmde7-amd64-py313": {"os_id": "linuxmint", "os_version": "7", "python_minor": "3.13"},
}
def target_profile(profile_id):
profile = dict(PROFILES[profile_id])
profile.update(schema=1, profile_id=profile_id, architecture="amd64",
python_abi="cp" + profile["python_minor"].replace(".", ""),
validation_status="candidate", glibc_minimum="2.34")
return profile
def _normalized(name):
return re.sub(r"[-_.]+", "-", name).lower()
def distribution_metadata(directory):
directory = Path(directory)
if not directory.is_dir() or directory.is_symlink():
raise RuntimeError("Vendor input must be a real directory: " + str(directory))
for path in directory.rglob("*"):
if path.is_symlink():
raise RuntimeError("Vendor symlinks are not supported: " + str(path))
result = {}
for info in sorted(directory.glob("*.dist-info")):
metadata = info / "METADATA"
if not metadata.is_file():
raise RuntimeError("Missing vendor METADATA: " + str(info))
msg = Parser().parsestr(metadata.read_text(encoding="utf-8"))
name, version = msg.get("Name"), msg.get("Version")
if not name or not version or _normalized(name) in result:
raise RuntimeError("Missing or duplicate vendor distribution identity: " + str(info))
result[_normalized(name)] = (version, info, msg)
if not result:
raise RuntimeError("Vendor input contains no distribution metadata")
return result
def vendor_versions(directory):
return {name: value[0] for name, value in distribution_metadata(directory).items()}
def _python_allowed(requirement, minor):
# Fail closed on unsupported specifier syntax rather than silently omitting it.
version = tuple(map(int, minor.split("."))) + (0,)
for part in requirement.split(","):
match = re.fullmatch(r"\s*(>=|<=|!=|==|>|<)\s*(\d+(?:\.\d+){0,2})(\.\*)?\s*", part)
if not match:
raise RuntimeError("Unsupported Requires-Python specifier: " + requirement)
op, number, wildcard = match.groups()
components = tuple(map(int, number.split(".")))
wanted = components + (0,) * (3 - len(components))
if wildcard:
if op not in ("==", "!="):
raise RuntimeError("Invalid Requires-Python wildcard: " + requirement)
equal = version[:len(components)] == components
allowed = equal if op == "==" else not equal
else:
allowed = {">=": version >= wanted, "<=": version <= wanted,
">": version > wanted, "<": version < wanted,
"==": version == wanted, "!=": version != wanted}[op]
if not allowed:
return False
return True
def _wheel_tag_allowed(tag, profile):
parts = tag.split("-")
if len(parts) != 3:
return False
interpreters, abis, platforms = [part.split(".") for part in parts]
target = profile["python_abi"]
target_number = int(target[2:])
for interpreter in interpreters:
for abi in abis:
if abi == "none" and interpreter in ("py3", target):
python_ok = True
elif abi == target and interpreter == target:
python_ok = True
elif abi == "abi3" and re.fullmatch(r"cp3\d+", interpreter):
python_ok = int(interpreter[2:]) <= target_number
else:
python_ok = False
if not python_ok:
continue
for platform in platforms:
if platform == "any" and abi == "none":
return True
match = re.fullmatch(r"manylinux_(\d+)_(\d+)_x86_64", platform)
legacy = {"manylinux1_x86_64": (2, 5), "manylinux2010_x86_64": (2, 12),
"manylinux2014_x86_64": (2, 17)}
baseline = tuple(map(int, match.groups())) if match else legacy.get(platform)
if baseline and baseline <= GLIBC_BASELINE and abi != "none":
return True
return False
def validate_vendor(directory, profile, expected_versions=None):
directory = Path(directory)
distributions = distribution_metadata(directory)
versions = {name: value[0] for name, value in distributions.items()}
if expected_versions is not None and versions != expected_versions:
raise RuntimeError("Vendor distribution names/versions differ from reviewed source inputs")
owners = {}
native_owners = set()
packages = []
for name, (version, info, metadata) in distributions.items():
requires_python = metadata.get("Requires-Python")
if requires_python and not _python_allowed(requires_python, profile["python_minor"]):
raise RuntimeError("Vendor Requires-Python rejects target: " + name)
wheel, record = info / "WHEEL", info / "RECORD"
if not wheel.is_file() or not record.is_file():
raise RuntimeError("Missing vendor WHEEL/RECORD: " + name)
wheel_metadata = Parser().parsestr(wheel.read_text(encoding="utf-8"))
tags = wheel_metadata.get_all("Tag", [])
if not tags or not any(_wheel_tag_allowed(tag, profile) for tag in tags):
raise RuntimeError("Vendor wheel ABI/platform rejects target: " + name + " " + repr(tags))
if (wheel_metadata.get("Root-Is-Purelib", "").lower() == "false"
and any(_wheel_tag_allowed(tag, profile) and not tag.endswith("-any") for tag in tags)):
native_owners.add(name)
for row in csv.reader(record.read_text(encoding="utf-8").splitlines()):
if row:
owners.setdefault(row[0], []).append(name)
relative = Path(row[0])
if relative.is_absolute():
raise RuntimeError("Absolute vendor RECORD path: " + row[0])
# Wheel entry-point scripts are not shipped in this module tree.
if ".." in relative.parts or "bin" in relative.parts or "__pycache__" in relative.parts:
continue
file = directory / relative
if len(row) < 3 or not file.is_file():
raise RuntimeError("Missing or malformed vendor RECORD file: " + row[0])
if not row[1] and file != record:
raise RuntimeError("Vendor RECORD digest missing: " + row[0])
if row[1]:
algorithm, separator, encoded = row[1].partition("=")
wanted = base64.urlsafe_b64encode(hashlib.sha256(file.read_bytes()).digest()).decode().rstrip("=")
if algorithm != "sha256" or not separator or wanted != encoded:
raise RuntimeError("Vendor RECORD digest mismatch: " + row[0])
if row[2] and int(row[2]) != file.stat().st_size:
raise RuntimeError("Vendor RECORD size mismatch: " + row[0])
packages.append({"name": name, "version": version, "wheel_tags": tags})
native = []
for path in sorted(directory.rglob("*.so")):
relative = path.relative_to(directory).as_posix()
if len(owners.get(relative, [])) != 1:
raise RuntimeError("Native vendor file is not owned by one RECORD: " + relative)
if owners[relative][0] not in native_owners:
raise RuntimeError("Native vendor file lacks matching native WHEEL metadata: " + relative)
match = re.search(r"\.cpython-(\d+)-x86_64-linux-gnu\.so$", path.name)
if match:
if "cp" + match.group(1) != profile["python_abi"]:
raise RuntimeError("Native vendor extension ABI rejects target: " + relative)
elif not path.name.endswith(".abi3.so"):
raise RuntimeError("Native vendor extension has unrecognized ABI suffix: " + relative)
data = path.read_bytes()
if len(data) < 20 or data[:6] != b"\x7fELF\x02\x01" or int.from_bytes(data[18:20], "little") != 62:
raise RuntimeError("Native vendor extension is not ELF amd64: " + relative)
needed = [tuple(map(int, values)) for values in re.findall(rb"GLIBC_(\d+)\.(\d+)", data)]
if needed and max(needed) > GLIBC_BASELINE:
raise RuntimeError("Native vendor extension exceeds glibc 2.34 baseline: " + relative)
native.append(relative)
required = ("_cffi_backend.cpython-", "pydantic_core/_pydantic_core.cpython-", "rpds/rpds.cpython-")
if any(not any(path.startswith(prefix) for path in native) for prefix in required):
raise RuntimeError("Required cffi/pydantic_core/rpds native vendor extensions missing")
digest = hashlib.sha256()
for path in sorted(directory.rglob("*")):
if path.is_file() and "__pycache__" not in path.parts and path.suffix != ".pyc" and "bin" not in path.relative_to(directory).parts and path.name != "FB-ABI-PREPARED.json":
digest.update(path.relative_to(directory).as_posix().encode() + b"\0")
digest.update(hashlib.sha256(path.read_bytes()).digest())
return {"vendor_sha256": digest.hexdigest(), "packages": packages, "native_extensions": native}
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
# Installed root-owned; never run a privileged helper from an owner checkout.
exec /usr/bin/python3 -I -S /usr/lib/framebridge-expanded/expanded/framebridge_expanded/credential_helper.py "$@"
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/python3
"""Generate installed, MCP-export and web-guide inputs from the shared contract."""
from pathlib import Path
import json
import sys
SOURCE=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(SOURCE/'expanded'))
from framebridge_expanded.guide import export_guide, GUIDE_SCHEMA
from framebridge_expanded import __version__
def markdown(data, title):
lines=['# '+title,'','Version: '+data['version']+' | Guide schema: '+str(GUIDE_SCHEMA),
'','This reference is generated from the shared CLI/MCP guide. Use the matching installed version.',
'','## Contents','']
for i,(name,entry) in enumerate(data['topics'].items(),1):
lines.append(str(i)+'. ['+entry['title']+'](#'+str(i)+'-'+name+')')
for i,(name,entry) in enumerate(data['topics'].items(),1):
lines.extend(['','<a id="'+str(i)+'-'+name+'"></a>','','## '+str(i)+'. '+entry['title'],''])
for section in entry['sections']:
lines.extend(['### '+section['heading'],'',section['body'],''])
if section['commands']:
lines.extend(['~~~sh',*section['commands'],'~~~',''])
return '\n'.join(lines).rstrip()+'\n'
def roff(text):
return text.replace('\\','\\e').replace('-','\\-')
def man(data):
lines=['.TH FRAMEBRIDGE 1 "2026-10-29" "FrameBridge '+__version__+'" "User Commands"',
'.SH NAME','framebridge, fb \\- use local Linux tools and enrolled nodes with ChatGPT',
'.SH SYNOPSIS','.B framebridge',
'[\\-\\-json] [\\-\\-state\\-dir DIRECTORY] COMMAND [ARGUMENTS]',
'.SH DESCRIPTION',
'FrameBridge provides one command-line interface for human operators and authorized agents.',
'The names fb and framebridge execute the same program.',
'The tools run on the selected Linux computers; the ChatGPT model remains hosted.']
for entry in data['topics'].values():
lines.extend(['.SH '+entry['title'].upper()])
for section in entry['sections']:
lines.extend(['.SS '+roff(section['heading']),roff(section['body'])])
if section['commands']:
lines.extend(['.PP','.nf',*(roff(x) for x in section['commands']),'.fi'])
lines.extend(['.SH AUTHORS','Programming: ChatGPT.',
'Concept and project coordination: Gerardo Betancourt.',
'.SH LICENSE','GNU GPL version 3 only, with the applicable third-party license notices.',
'See the installed copyright and source license files.',
'.SH SEE ALSO','.BR systemctl (1), .BR dpkg (1), .BR apt (8)'])
return '\n'.join(lines)+'\n'
def main():
data=export_guide();doc=SOURCE/'docs';doc.mkdir(exist_ok=True)
(doc/'COMMAND-GUIDE.json').write_text(json.dumps(data,indent=2,ensure_ascii=False)+'\n')
ref=markdown(data,'FrameBridge — command guide')
(doc/'COMMAND-REFERENCE.md').write_text(ref)
(SOURCE.parent/'docs/COMMAND-REFERENCE.md').write_text(ref)
(doc/'DEBIAN-TUTORIAL.md').write_text(markdown(data,'FrameBridge — Debian, Ubuntu and Linux Mint installation and first use'))
(doc/'framebridge.1').write_text(man(data))
print(json.dumps({'version':data['version'],'guide_schema':GUIDE_SCHEMA,'topics':len(data['topics']),'guide_sha256':data['guide_sha256'],'generated_files':5}))
if __name__=='__main__':main()
+87
View File
@@ -0,0 +1,87 @@
"""Bind release packages to their corresponding source; never certify runtime QA."""
import hashlib
import json
from pathlib import Path, PurePosixPath
import re
import tarfile
PUBLIC_RELEASE_PROFILES = (
"debian13-amd64-py313", "ubuntu24.04-amd64-py312", "mint22-amd64-py312",
)
def release_source(manifest_path, version, profile_id, current_files, vendor_evidence):
"""Require the same reviewed source and ABI input used by this package.
The manifest and archive are release inputs kept beside one another. Their
signatures and the OS acceptance record are verified outside the builder.
"""
if profile_id not in PUBLIC_RELEASE_PROFILES or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version):
raise RuntimeError("Public release requires a stable version and an official release profile")
manifest_path = Path(manifest_path)
data = json.loads(manifest_path.read_text())
expected_name = "FrameBridge-" + version + "-source.tar.gz"
if (data.get("version"), data.get("status"), data.get("archive")) != (version, "RELEASE_SOURCE_ASSEMBLED", expected_name):
raise RuntimeError("Matching release source manifest is required")
archive = manifest_path.parent / expected_name
if archive.is_symlink() or not archive.is_file():
raise RuntimeError("Matching release source archive is missing")
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
if archive.stat().st_size != data.get("bytes") or digest != data.get("sha256"):
raise RuntimeError("Release source archive hash or size differs from its manifest")
vendors = data.get("vendor_inputs", {}).get("vendors", {})
if set(vendors) != {"cp312", "cp313"}:
raise RuntimeError("Release source must contain the reviewed cp312 and cp313 inputs")
abi = "cp313" if profile_id == "debian13-amd64-py313" else "cp312"
if vendors[abi].get("vendor_sha256") != vendor_evidence.get("vendor_sha256"):
raise RuntimeError("Package vendor input differs from corresponding source")
rows = data.get("files", [])
if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
raise RuntimeError("Invalid release source file manifest")
recorded = {row.get("path"): row.get("sha256") for row in rows}
if len(recorded) != len(rows):
raise RuntimeError("Duplicate release source manifest path")
seen = set()
prefix = "FrameBridge-" + version + "/"
with tarfile.open(archive, "r:gz") as source:
for member in source:
if not member.isfile() or not member.name.startswith(prefix):
raise RuntimeError("Unexpected release source archive member")
relative = member.name[len(prefix):]
if not relative or PurePosixPath(relative).is_absolute() or ".." in PurePosixPath(relative).parts:
raise RuntimeError("Unsafe release source archive path")
if relative in seen or relative not in recorded:
raise RuntimeError("Duplicate or unrecorded release source archive member")
seen.add(relative)
with source.extractfile(member) as stream:
if hashlib.sha256(stream.read()).hexdigest() != recorded[relative]:
raise RuntimeError("Release source archive content differs from its manifest")
if seen != set(recorded):
raise RuntimeError("Release source manifest contains files absent from the archive")
# Bind the ABI claims to the archived bytes, including the ABI not selected
# by this particular package. A manifest field alone is not vendor evidence.
for vendor_abi, expected_prefix in (
("cp313", "source/sources/original-geranas/FrameBridge-Fleet-Hub/vendor"),
("cp312", "source/target-vendors/cp312"),
):
if vendors[vendor_abi].get("path") != expected_prefix:
raise RuntimeError("Unexpected corresponding-source vendor path")
vendor_digest = hashlib.sha256()
# validate_vendor sorts Path objects, whose component ordering differs
# from slash-delimited strings for package/ versus package-1.dist-info/.
for path, file_digest in sorted(recorded.items(), key=lambda row: PurePosixPath(row[0]).parts):
if not path.startswith(expected_prefix + "/"):
continue
relative = path[len(expected_prefix) + 1:]
parts = PurePosixPath(relative).parts
if "__pycache__" in parts or "bin" in parts or relative.endswith(".pyc") or parts[-1] == "FB-ABI-PREPARED.json":
continue
vendor_digest.update(relative.encode() + b"\0")
vendor_digest.update(bytes.fromhex(file_digest))
if vendor_digest.hexdigest() != vendors[vendor_abi].get("vendor_sha256"):
raise RuntimeError("Archived vendor bytes differ from their recorded ABI identity")
for relative, path in current_files.items():
if recorded.get(relative) != hashlib.sha256(path.read_bytes()).hexdigest():
raise RuntimeError("Package source differs from corresponding archive: " + relative)
return {"filename": expected_name, "bytes": data["bytes"], "sha256": digest}
+56 -8
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import base64 import base64
from functools import partial, wraps
import inspect
import hashlib import hashlib
import json import json
import os import os
@@ -8,6 +10,9 @@ import socket
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
import uuid
import anyio
from pathlib import Path from pathlib import Path
from mcp import ClientSession from mcp import ClientSession
@@ -45,7 +50,7 @@ mcp = FastMCP(
"Use the installed CLI and tools for normal work; use a separate source clone when the owner requests software development. " "Use the installed CLI and tools for normal work; use a separate source clone when the owner requests software development. "
"The six Fleet inspection tools are read-only. Guide instructions do not grant authorization." "The six Fleet inspection tools are read-only. Guide instructions do not grant authorization."
), ),
website_url="https://framebridge.betancourt.one", website_url="https://framebridge.betancourt.one/",
host=HOST, host=HOST,
port=PORT, port=PORT,
streamable_http_path="/mcp", streamable_http_path="/mcp",
@@ -55,6 +60,45 @@ mcp = FastMCP(
mcp._mcp_server.version = PRODUCT_VERSION mcp._mcp_server.version = PRODUCT_VERSION
class _BlockingToolRegistrar:
"""Offload only MCP dispatch while keeping synchronous callback bindings.
Use the public SDK decorator; do not change its dispatcher or the functions
passed to ScreenClient/TTY as synchronous EXEC callbacks. AnyIO bounds worker
concurrency. Waiting cancellation does not replay or undo an operation: a
running worker is allowed to finish, including any side effects.
"""
def __init__(self, server):
self.server = server
def resource(self, *args, **kwargs):
return self.server.resource(*args, **kwargs)
def tool(self, *args, **kwargs):
register = self.server.tool(*args, **kwargs)
def decorate(function):
@wraps(function)
async def dispatch(*arguments, **keywords):
return await anyio.to_thread.run_sync(
partial(function, *arguments, **keywords),
abandon_on_cancel=False,
)
# Resolve future annotations in the defining module, including the
# screen presentation's Annotated return type, before registering.
dispatch.__signature__ = inspect.signature(function, eval_str=True)
register(dispatch)
return function
return decorate
_blocking_tools = _BlockingToolRegistrar(mcp)
def _registered_operations() -> list[str]: def _registered_operations() -> list[str]:
"""Registered operations are not proof of their current execution readiness.""" """Registered operations are not proof of their current execution readiness."""
return sorted(mcp._tool_manager._tools) return sorted(mcp._tool_manager._tools)
@@ -395,7 +439,7 @@ def _framebridge_exec_geranas_impl(
if not 1<=timeout_s<=600: if not 1<=timeout_s<=600:
return {"ok":False,"error":"INVALID_TIMEOUT"} return {"ok":False,"error":"INVALID_TIMEOUT"}
if not job_id: if not job_id:
job_id="mcp-"+time.strftime("%Y%m%d-%H%M%S")+"-"+digest[:8] job_id="mcp-"+time.strftime("%Y%m%d-%H%M%S")+"-"+digest[:8]+"-"+uuid.uuid4().hex[:12]
if not JOB_RE.fullmatch(job_id): if not JOB_RE.fullmatch(job_id):
return {"ok":False,"error":"INVALID_JOB_ID"} return {"ok":False,"error":"INVALID_JOB_ID"}
req={ req={
@@ -468,7 +512,7 @@ def framebridge_cloud_dsm(action: str, authorized: bool = False, params_json: st
return _cloud_dsm_rpc({"action":action,"authorized":bool(authorized),"params":params}) return _cloud_dsm_rpc({"action":action,"authorized":bool(authorized),"params":params})
@mcp.tool() @_blocking_tools.tool()
def framebridge_exec( def framebridge_exec(
script: str, script: str,
authorized: bool, authorized: bool,
@@ -641,7 +685,7 @@ def asus_cua_request(cua: dict, authorized: bool, timeout_s: int, job_id: str) -
return {"ok":False,"error":"ASUS_CUA_TIMEOUT","request_id":rid} return {"ok":False,"error":"ASUS_CUA_TIMEOUT","request_id":rid}
@mcp.tool() @_blocking_tools.tool()
def framebridge_cua( def framebridge_cua(
action: str, action: str,
authorized: bool, authorized: bool,
@@ -830,12 +874,16 @@ def _screen_exec_request(
from framebridge_expanded.real_screen.screen_frontdoor import register_framebridge_screen_tools, resolve_profile_node from framebridge_expanded.real_screen.screen_frontdoor import register_framebridge_screen_tools, resolve_profile_node
_real_screen_client = register_framebridge_screen_tools(mcp, _screen_exec_request, image_factory=Image, resolve_node=resolve_profile_node) _real_screen_client = register_framebridge_screen_tools(
_blocking_tools, _screen_exec_request, image_factory=Image,
resolve_node=resolve_profile_node, stdout_limit_bytes=65536, transfer_timeout_s=45,
)
from framebridge_expanded.real_screen.screen_presentation import register_screen_presentation from framebridge_expanded.real_screen.screen_presentation import register_screen_presentation
register_screen_presentation(mcp, _real_screen_client) register_screen_presentation(_blocking_tools, _real_screen_client)
from framebridge_expanded.tty_mcp import register_tools as register_tty_tools from framebridge_expanded.tty_mcp import register_tools as register_tty_tools
register_tty_tools(mcp, framebridge_exec) register_tty_tools(_blocking_tools, framebridge_exec, screen_client=_real_screen_client)
if __name__ == "__main__": if __name__ == "__main__":
mcp.run(transport="streamable-http") from framebridge_expanded.http_server import run_streamable_mcp
run_streamable_mcp(mcp)
@@ -0,0 +1,295 @@
Metadata-Version: 2.4
Name: annotated-types
Version: 0.8.0
Summary: Reusable constraint types to use with typing.Annotated
Project-URL: Homepage, https://github.com/annotated-types/annotated-types
Project-URL: Source, https://github.com/annotated-types/annotated-types
Project-URL: Changelog, https://github.com/annotated-types/annotated-types/releases
Author-email: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com>, Samuel Colvin <s@muelcolvin.com>, Zac Hatfield-Dodds <zac@zhd.dev>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Environment :: MacOS X
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Information Technology
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: Unix
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
# annotated-types
[![CI](https://github.com/annotated-types/annotated-types/workflows/CI/badge.svg?event=push)](https://github.com/annotated-types/annotated-types/actions?query=event%3Apush+branch%3Amain+workflow%3ACI)
[![pypi](https://img.shields.io/pypi/v/annotated-types.svg)](https://pypi.python.org/pypi/annotated-types)
[![versions](https://img.shields.io/pypi/pyversions/annotated-types.svg)](https://github.com/annotated-types/annotated-types)
[![license](https://img.shields.io/github/license/annotated-types/annotated-types.svg)](https://github.com/annotated-types/annotated-types/blob/main/LICENSE)
[PEP-593](https://peps.python.org/pep-0593/) added `typing.Annotated` as a way of
adding context-specific metadata to existing types, and specifies that
`Annotated[T, x]` _should_ be treated as `T` by any tool or library without special
logic for `x`.
This package provides metadata objects which can be used to represent common
constraints such as upper and lower bounds on scalar values and collection sizes,
a `Predicate` marker for runtime checks, and
descriptions of how we intend these metadata to be interpreted. In some cases,
we also note alternative representations which do not require this package.
## Install
```bash
pip install annotated-types
```
## Examples
```python
from typing import Annotated
from annotated_types import Gt, Len, Predicate
class MyClass:
age: Annotated[int, Gt(18)] # Valid: 19, 20, ...
# Invalid: 17, 18, "19", 19.0, ...
factors: list[Annotated[int, Predicate(is_prime)]] # Valid: 2, 3, 5, 7, 11, ...
# Invalid: 4, 8, -2, 5.0, "prime", ...
my_list: Annotated[list[int], Len(0, 10)] # Valid: [], [10, 20, 30, 40, 50]
# Invalid: (1, 2), ["abc"], [0] * 20
```
## Documentation
_While `annotated-types` avoids runtime checks for performance, users should not
construct invalid combinations such as `MultipleOf("non-numeric")` or `Annotated[int, Len(3)]`.
Downstream implementors may choose to raise an error, emit a warning, silently ignore
a metadata item, etc., if the metadata objects described below are used with an
incompatible type - or for any other reason!_
### Gt, Ge, Lt, Le
Express inclusive and/or exclusive bounds on orderable values - which may be numbers,
dates, times, strings, sets, etc. Note that the boundary value need not be of the
same type that was annotated, so long as they can be compared: `Annotated[int, Gt(1.5)]`
is fine, for example, and implies that the value is an integer x such that `x > 1.5`.
We suggest that implementors may also interpret `functools.partial(operator.le, 1.5)`
as being equivalent to `Gt(1.5)`, for users who wish to avoid a runtime dependency on
the `annotated-types` package.
To be explicit, these types have the following meanings:
* `Gt(x)` - value must be "Greater Than" `x` - equivalent to exclusive minimum
* `Ge(x)` - value must be "Greater than or Equal" to `x` - equivalent to inclusive minimum
* `Lt(x)` - value must be "Less Than" `x` - equivalent to exclusive maximum
* `Le(x)` - value must be "Less than or Equal" to `x` - equivalent to inclusive maximum
### Interval
`Interval(gt, ge, lt, le)` allows you to specify an upper and lower bound with a single
metadata object. `None` attributes should be ignored, and non-`None` attributes
treated as per the single bounds above.
### MultipleOf
`MultipleOf(multiple_of=x)` might be interpreted in two ways:
1. Python semantics, implying `value % multiple_of == 0`, or
2. [JSONschema semantics](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.6.2.1),
where `int(value / multiple_of) == value / multiple_of`.
We encourage users to be aware of these two common interpretations and their
distinct behaviours, especially since very large or non-integer numbers make
it easy to cause silent data corruption due to floating-point imprecision.
We encourage libraries to carefully document which interpretation they implement.
### MinLen, MaxLen, Len
`Len()` implies that `min_length <= len(value) <= max_length` - lower and upper bounds are inclusive.
As well as `Len()` which can optionally include upper and lower bounds, we also
provide `MinLen(x)` and `MaxLen(y)` which are equivalent to `Len(min_length=x)`
and `Len(max_length=y)` respectively.
`Len`, `MinLen`, and `MaxLen` may be used with any type which supports `len(value)`.
Examples of usage:
* `Annotated[list, MaxLen(10)]` (or `Annotated[list, Len(max_length=10)]`) - list must have a length of 10 or less
* `Annotated[str, MaxLen(10)]` - string must have a length of 10 or less
* `Annotated[list, MinLen(3)]` (or `Annotated[list, Len(min_length=3)]`) - list must have a length of 3 or more
* `Annotated[list, Len(4, 6)]` - list must have a length of 4, 5, or 6
* `Annotated[list, Len(8, 8)]` - list must have a length of exactly 8
#### Changed in v0.4.0
* `min_inclusive` has been renamed to `min_length`, no change in meaning
* `max_exclusive` has been renamed to `max_length`, upper bound is now **inclusive** instead of **exclusive**
* The recommendation that slices are interpreted as `Len` has been removed due to ambiguity and different semantic
meaning of the upper bound in slices vs. `Len`
See [issue #23](https://github.com/annotated-types/annotated-types/issues/23) for discussion.
### Timezone
`Timezone` can be used with a `datetime` or a `time` to express which timezones
are allowed. `Annotated[datetime, Timezone(None)]` must be a naive datetime.
`Timezone[...]` ([literal ellipsis](https://docs.python.org/3/library/constants.html#Ellipsis))
expresses that any timezone-aware datetime is allowed. You may also pass a specific
timezone string or [`tzinfo`](https://docs.python.org/3/library/datetime.html#tzinfo-objects)
object such as `Timezone(timezone.utc)` or `Timezone("Africa/Abidjan")` to express that you only
allow a specific timezone, though we note that this is often a symptom of fragile design.
#### Changed in v0.x.x
* `Timezone` accepts [`tzinfo`](https://docs.python.org/3/library/datetime.html#tzinfo-objects) objects instead of
`timezone`, extending compatibility to [`zoneinfo`](https://docs.python.org/3/library/zoneinfo.html) and third party libraries.
### Unit
`Unit(unit: str)` expresses that the annotated numeric value is the magnitude of
a quantity with the specified unit. For example, `Annotated[float, Unit("m/s")]`
would be a float representing a velocity in meters per second.
Please note that `annotated_types` itself makes no attempt to parse or validate
the unit string in any way. That is left entirely to downstream libraries,
such as [`pint`](https://pint.readthedocs.io) or
[`astropy.units`](https://docs.astropy.org/en/stable/units/).
An example of how a library might use this metadata:
```python
from annotated_types import Unit
from typing import Annotated, TypeVar, Callable, Any, get_origin, get_args
# given a type annotated with a unit:
Meters = Annotated[float, Unit("m")]
# you can cast the annotation to a specific unit type with any
# callable that accepts a string and returns the desired type
T = TypeVar("T")
def cast_unit(tp: Any, unit_cls: Callable[[str], T]) -> T | None:
if get_origin(tp) is Annotated:
for arg in get_args(tp):
if isinstance(arg, Unit):
return unit_cls(arg.unit)
return None
# using `pint`
import pint
pint_unit = cast_unit(Meters, pint.Unit)
# using `astropy.units`
import astropy.units as u
astropy_unit = cast_unit(Meters, u.Unit)
```
### Predicate
`Predicate(func: Callable)` expresses that `func(value)` is truthy for valid values.
Users should prefer the statically inspectable metadata above, but if you need
the full power and flexibility of arbitrary runtime predicates... here it is.
For some common constraints, we provide generic types:
* `LowerCase = Annotated[T, Predicate(str.islower)]`
* `UpperCase = Annotated[T, Predicate(str.isupper)]`
* `IsDigit = Annotated[T, Predicate(str.isdigit)]`
* `IsFinite = Annotated[T, Predicate(math.isfinite)]`
* `IsNotFinite = Annotated[T, Predicate(Not(math.isfinite))]`
* `IsNan = Annotated[T, Predicate(math.isnan)]`
* `IsNotNan = Annotated[T, Predicate(Not(math.isnan))]`
* `IsInfinite = Annotated[T, Predicate(math.isinf)]`
* `IsNotInfinite = Annotated[T, Predicate(Not(math.isinf))]`
so that you can write e.g. `x: IsFinite[float] = 2.0` instead of the longer
(but exactly equivalent) `x: Annotated[float, Predicate(math.isfinite)] = 2.0`.
Some libraries might have special logic to handle known or understandable predicates,
for example by checking for `str.isdigit` and using its presence to both call custom
logic to enforce digit-only strings, and customise some generated external schema.
Users are therefore encouraged to avoid indirection like `lambda s: s.lower()`, in
favor of introspectable methods such as `str.lower` or `re.compile("pattern").search`.
To enable basic negation of commonly used predicates like `math.isnan` without introducing introspection that makes it impossible for implementers to introspect the predicate we provide a `Not` wrapper that simply negates the predicate in an introspectable manner. Several of the predicates listed above are created in this manner.
We do not specify what behaviour should be expected for predicates that raise
an exception. For example `Annotated[int, Predicate(str.isdigit)]` might silently
skip invalid constraints, or statically raise an error; or it might try calling it
and then propagate or discard the resulting
`TypeError: descriptor 'isdigit' for 'str' objects doesn't apply to a 'int' object`
exception. We encourage libraries to document the behaviour they choose.
### Doc
`doc()` can be used to add documentation information in `Annotated`, for function and method parameters, variables, class attributes, return types, and any place where `Annotated` can be used.
It expects a value that can be statically analyzed, as the main use case is for static analysis, editors, documentation generators, and similar tools.
It returns a `DocInfo` class with a single attribute `documentation` containing the value passed to `doc()`.
This is the early adopter's alternative form of the [`typing-doc` proposal](https://github.com/tiangolo/fastapi/blob/typing-doc/typing_doc.md).
### Integrating downstream types with `GroupedMetadata`
Implementers may choose to provide a convenience wrapper that groups multiple pieces of metadata.
This can help reduce verbosity and cognitive overhead for users.
For example, an implementer like Pydantic might provide a `Field` or `Meta` type that accepts keyword arguments and transforms these into low-level metadata:
```python
from dataclasses import dataclass
from typing import Iterator
from annotated_types import GroupedMetadata, Ge
@dataclass
class Field(GroupedMetadata):
ge: int | None = None
description: str | None = None
def __iter__(self) -> Iterator[object]:
# Iterating over a GroupedMetadata object should yield annotated-types
# constraint metadata objects which describe it as fully as possible,
# and may include other unknown objects too.
if self.ge is not None:
yield Ge(self.ge)
if self.description is not None:
yield Description(self.description)
```
Libraries consuming annotated-types constraints should check for `GroupedMetadata` and unpack it by iterating over the object and treating the results as if they had been "unpacked" in the `Annotated` type. The same logic should be applied to the [PEP 646 `Unpack` type](https://peps.python.org/pep-0646/), so that `Annotated[T, Field(...)]`, `Annotated[T, Unpack[Field(...)]]` and `Annotated[T, *Field(...)]` are all treated consistently.
Libraries consuming annotated-types should also ignore any metadata they do not recongize that came from unpacking a `GroupedMetadata`, just like they ignore unrecognized metadata in `Annotated` itself.
Our own `annotated_types.Interval` class is a `GroupedMetadata` which unpacks itself into `Gt`, `Lt`, etc., so this is not an abstract concern. Similarly, `annotated_types.Len` is a `GroupedMetadata` which unpacks itself into `MinLen` (optionally) and `MaxLen`.
### Consuming metadata
We intend to not be prescriptive as to _how_ the metadata and constraints are used, but as an example of how one might parse constraints from types annotations see our [implementation in `test_main.py`](https://github.com/annotated-types/annotated-types/blob/f59cf6d1b5255a0fe359b93896759a180bec30ae/tests/test_main.py#L94-L103).
It is up to the implementer to determine how this metadata is used.
You could use the metadata for runtime type checking, for generating schemas or to generate example data, amongst other use cases.
## Design & History
This package was designed at the PyCon 2022 sprints by the maintainers of Pydantic
and Hypothesis, with the goal of making it as easy as possible for end-users to
provide more informative annotations for use by runtime libraries.
It is deliberately minimal, and following PEP-593 allows considerable downstream
discretion in what (if anything!) they choose to support. Nonetheless, we expect
that staying simple and covering _only_ the most common use-cases will give users
and maintainers the best experience we can. If you'd like more constraints for your
types - follow our lead, by defining them and documenting them downstream!
@@ -0,0 +1,7 @@
annotated_types/__init__.py,sha256=pxBKTUObJ6n3T8C-I2ubobeDHmBEAmgCogWrwSmKm8g,13273
annotated_types/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
annotated_types/test_cases.py,sha256=2GdHKstXuBzpf3iXKCjmvFZpyk2zP9Hm9kXGGDhGBo4,6310
annotated_types-0.8.0.dist-info/METADATA,sha256=YUmFsnj2Abjvhj-CeDuZHMOtpLifWS63HHxIxfLvIpg,15009
annotated_types-0.8.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
annotated_types-0.8.0.dist-info/licenses/LICENSE,sha256=_hBJiEsaDZNCkB6I4H8ykl0ksxIdmXK2poBfuYJLCV0,1083
annotated_types-0.8.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: hatchling 1.31.0
Root-Is-Purelib: true
Tag: py3-none-any
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2022 the contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,416 @@
import math
import types
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from datetime import tzinfo
from types import EllipsisType
from typing import (
TYPE_CHECKING,
Annotated,
Any,
Literal,
Protocol,
SupportsFloat,
SupportsIndex,
TypeVar,
Union,
runtime_checkable,
)
__all__ = (
'BaseMetadata',
'GroupedMetadata',
'Gt',
'Ge',
'Lt',
'Le',
'Interval',
'MultipleOf',
'MinLen',
'MaxLen',
'Len',
'Timezone',
'Predicate',
'LowerCase',
'UpperCase',
'IsDigits',
'IsFinite',
'IsNotFinite',
'IsNan',
'IsNotNan',
'IsInfinite',
'IsNotInfinite',
'doc',
'DocInfo',
'__version__',
)
__version__ = '0.8.0'
T = TypeVar('T')
# arguments that start with __ are considered
# positional only
# see https://peps.python.org/pep-0484/#positional-only-arguments
class SupportsGt(Protocol):
def __gt__(self: T, __other: T) -> bool:
...
class SupportsGe(Protocol):
def __ge__(self: T, __other: T) -> bool:
...
class SupportsLt(Protocol):
def __lt__(self: T, __other: T) -> bool:
...
class SupportsLe(Protocol):
def __le__(self: T, __other: T) -> bool:
...
class SupportsMod(Protocol):
def __mod__(self: T, __other: T) -> T:
...
class SupportsDiv(Protocol):
def __div__(self: T, __other: T) -> T:
...
class BaseMetadata:
"""Base class for all metadata.
This exists mainly so that implementers
can do `isinstance(..., BaseMetadata)` while traversing field annotations.
"""
__slots__ = ()
@dataclass(frozen=True, slots=True)
class Gt(BaseMetadata):
"""Gt(gt=x) implies that the value must be greater than x.
It can be used with any type that supports the ``>`` operator,
including numbers, dates and times, strings, sets, and so on.
"""
gt: SupportsGt
@dataclass(frozen=True, slots=True)
class Ge(BaseMetadata):
"""Ge(ge=x) implies that the value must be greater than or equal to x.
It can be used with any type that supports the ``>=`` operator,
including numbers, dates and times, strings, sets, and so on.
"""
ge: SupportsGe
@dataclass(frozen=True, slots=True)
class Lt(BaseMetadata):
"""Lt(lt=x) implies that the value must be less than x.
It can be used with any type that supports the ``<`` operator,
including numbers, dates and times, strings, sets, and so on.
"""
lt: SupportsLt
@dataclass(frozen=True, slots=True)
class Le(BaseMetadata):
"""Le(le=x) implies that the value must be less than or equal to x.
It can be used with any type that supports the ``<=`` operator,
including numbers, dates and times, strings, sets, and so on.
"""
le: SupportsLe
@runtime_checkable
class GroupedMetadata(Protocol):
"""A grouping of multiple objects, like typing.Unpack.
`GroupedMetadata` on its own is not metadata and has no meaning.
All of the constraints and metadata should be fully expressable
in terms of the `BaseMetadata`'s returned by `GroupedMetadata.__iter__()`.
Concrete implementations should override `GroupedMetadata.__iter__()`
to add their own metadata.
For example:
>>> @dataclass
>>> class Field(GroupedMetadata):
>>> gt: float | None = None
>>> description: str | None = None
...
>>> def __iter__(self) -> Iterable[object]:
>>> if self.gt is not None:
>>> yield Gt(self.gt)
>>> if self.description is not None:
>>> yield Description(self.gt)
Also see the implementation of `Interval` below for an example.
Parsers should recognize this and unpack it so that it can be used
both with and without unpacking:
- `Annotated[int, Field(...)]` (parser must unpack Field)
- `Annotated[int, *Field(...)]` (PEP-646)
""" # noqa: trailing-whitespace
@property
def __is_annotated_types_grouped_metadata__(self) -> Literal[True]:
return True
def __iter__(self) -> Iterator[object]:
...
if not TYPE_CHECKING:
__slots__ = () # allow subclasses to use slots
def __init_subclass__(cls, *args: Any, **kwargs: Any) -> None:
# Basic ABC like functionality without the complexity of an ABC
super().__init_subclass__(*args, **kwargs)
if cls.__iter__ is GroupedMetadata.__iter__:
raise TypeError("Can't subclass GroupedMetadata without implementing __iter__")
def __iter__(self) -> Iterator[object]: # noqa: F811
raise NotImplementedError # more helpful than "None has no attribute..." type errors
@dataclass(frozen=True, kw_only=True, slots=True)
class Interval(GroupedMetadata):
"""Interval can express inclusive or exclusive bounds with a single object.
It accepts keyword arguments ``gt``, ``ge``, ``lt``, and/or ``le``, which
are interpreted the same way as the single-bound constraints.
"""
gt: SupportsGt | None = None
ge: SupportsGe | None = None
lt: SupportsLt | None = None
le: SupportsLe | None = None
def __iter__(self) -> Iterator[BaseMetadata]:
"""Unpack an Interval into zero or more single-bounds."""
if self.gt is not None:
yield Gt(self.gt)
if self.ge is not None:
yield Ge(self.ge)
if self.lt is not None:
yield Lt(self.lt)
if self.le is not None:
yield Le(self.le)
@dataclass(frozen=True, slots=True)
class MultipleOf(BaseMetadata):
"""MultipleOf(multiple_of=x) might be interpreted in two ways:
1. Python semantics, implying ``value % multiple_of == 0``, or
2. JSONschema semantics, where ``int(value / multiple_of) == value / multiple_of``
We encourage users to be aware of these two common interpretations,
and libraries to carefully document which they implement.
"""
multiple_of: SupportsDiv | SupportsMod
@dataclass(frozen=True, slots=True)
class MinLen(BaseMetadata):
"""
MinLen() implies minimum inclusive length,
e.g. ``len(value) >= min_length``.
"""
min_length: Annotated[int, Ge(0)]
@dataclass(frozen=True, slots=True)
class MaxLen(BaseMetadata):
"""
MaxLen() implies maximum inclusive length,
e.g. ``len(value) <= max_length``.
"""
max_length: Annotated[int, Ge(0)]
@dataclass(frozen=True, slots=True)
class Len(GroupedMetadata):
"""
Len() implies that ``min_length <= len(value) <= max_length``.
Upper bound may be omitted or ``None`` to indicate no upper length bound.
"""
min_length: Annotated[int, Ge(0)] = 0
max_length: Annotated[int, Ge(0)] | None = None
def __iter__(self) -> Iterator[BaseMetadata]:
"""Unpack a Len into zero or more single-bounds."""
if self.min_length > 0:
yield MinLen(self.min_length)
if self.max_length is not None:
yield MaxLen(self.max_length)
@dataclass(frozen=True, slots=True)
class Timezone(BaseMetadata):
"""Timezone(tz=...) requires a datetime to be aware (or ``tz=None``, naive).
``Annotated[datetime, Timezone(None)]`` must be a naive datetime.
``Timezone(...)`` (the ellipsis literal) expresses that the datetime must be
tz-aware but any timezone is allowed.
You may also pass a specific timezone string or tzinfo object such as
``Timezone(timezone.utc)`` or ``Timezone("Africa/Abidjan")`` to express that
you only allow a specific timezone, though we note that this is often
a symptom of poor design.
"""
tz: str | tzinfo | EllipsisType | None
@dataclass(frozen=True, slots=True)
class Unit(BaseMetadata):
"""Indicates that the value is a physical quantity with the specified unit.
It is intended for usage with numeric types, where the value represents the
magnitude of the quantity. For example, ``distance: Annotated[float, Unit('m')]``
or ``speed: Annotated[float, Unit('m/s')]``.
Interpretation of the unit string is left to the discretion of the consumer.
It is suggested to follow conventions established by python libraries that work
with physical quantities, such as
- ``pint`` : <https://pint.readthedocs.io/en/stable/>
- ``astropy.units``: <https://docs.astropy.org/en/stable/units/>
For indicating a quantity with a certain dimensionality but without a specific unit
it is recommended to use square brackets, e.g. `Annotated[float, Unit('[time]')]`.
Note, however, ``annotated_types`` itself makes no use of the unit string.
"""
unit: str
@dataclass(frozen=True, slots=True)
class Predicate(BaseMetadata):
"""``Predicate(func: Callable)`` implies `func(value)` is truthy for valid values.
Users should prefer statically inspectable metadata, but if you need the full
power and flexibility of arbitrary runtime predicates... here it is.
We provide a few predefined predicates for common string constraints:
``LowerCase = Predicate(str.islower)``, ``UpperCase = Predicate(str.isupper)``, and
``IsDigits = Predicate(str.isdigit)``. Users are encouraged to use methods which
can be given special handling, and avoid indirection like ``lambda s: s.lower()``.
Some libraries might have special logic to handle certain predicates, e.g. by
checking for `str.isdigit` and using its presence to both call custom logic to
enforce digit-only strings, and customise some generated external schema.
We do not specify what behaviour should be expected for predicates that raise
an exception. For example `Annotated[int, Predicate(str.isdigit)]` might silently
skip invalid constraints, or statically raise an error; or it might try calling it
and then propagate or discard the resulting exception.
"""
func: Callable[[Any], bool]
def __repr__(self) -> str:
if getattr(self.func, "__name__", "<lambda>") == "<lambda>":
return f"{self.__class__.__name__}({self.func!r})"
if isinstance(self.func, (types.MethodType, types.BuiltinMethodType)) and (
namespace := getattr(self.func.__self__, "__name__", None)
):
return f"{self.__class__.__name__}({namespace}.{self.func.__name__})"
if isinstance(self.func, type(str.isascii)): # method descriptor
return f"{self.__class__.__name__}({self.func.__qualname__})"
return f"{self.__class__.__name__}({self.func.__name__})"
@dataclass
class Not:
func: Callable[[Any], bool]
def __call__(self, __v: Any) -> bool:
return not self.func(__v)
_StrType = TypeVar("_StrType", bound=str)
LowerCase = Annotated[_StrType, Predicate(str.islower)]
"""
Return True if the string is a lowercase string, False otherwise.
A string is lowercase if all cased characters in the string are lowercase and there is at least one cased character in the string.
""" # noqa: E501
UpperCase = Annotated[_StrType, Predicate(str.isupper)]
"""
Return True if the string is an uppercase string, False otherwise.
A string is uppercase if all cased characters in the string are uppercase and there is at least one cased character in the string.
""" # noqa: E501
IsDigit = Annotated[_StrType, Predicate(str.isdigit)]
IsDigits = IsDigit # type: ignore # plural for backwards compatibility, see #63
"""
Return True if the string is a digit string, False otherwise.
A string is a digit string if all characters in the string are digits and there is at least one character in the string.
""" # noqa: E501
IsAscii = Annotated[_StrType, Predicate(str.isascii)]
"""
Return True if all characters in the string are ASCII, False otherwise.
ASCII characters have code points in the range U+0000-U+007F. Empty string is ASCII too.
"""
_NumericType = TypeVar('_NumericType', bound=Union[SupportsFloat, SupportsIndex])
IsFinite = Annotated[_NumericType, Predicate(math.isfinite)]
"""Return True if x is neither an infinity nor a NaN, and False otherwise."""
IsNotFinite = Annotated[_NumericType, Predicate(Not(math.isfinite))]
"""Return True if x is one of infinity or NaN, and False otherwise"""
IsNan = Annotated[_NumericType, Predicate(math.isnan)]
"""Return True if x is a NaN (not a number), and False otherwise."""
IsNotNan = Annotated[_NumericType, Predicate(Not(math.isnan))]
"""Return True if x is anything but NaN (not a number), and False otherwise."""
IsInfinite = Annotated[_NumericType, Predicate(math.isinf)]
"""Return True if x is a positive or negative infinity, and False otherwise."""
IsNotInfinite = Annotated[_NumericType, Predicate(Not(math.isinf))]
"""Return True if x is neither a positive or negative infinity, and False otherwise."""
try:
# PEP 727 – Documentation in Annotated Metadata
from typing_extensions import Doc # type: ignore[attr-defined]
except ImportError:
@dataclass(frozen=True, slots=True)
class Doc: # type: ignore [no-redef]
""" "
The return value of doc(), mainly to be used by tools that want to extract the
Annotated documentation at runtime.
"""
documentation: str
"""The documentation string passed to doc()."""
DocInfo = Doc # backwards compatibility
doc = Doc
Whitespace-only changes.
@@ -0,0 +1,146 @@
import math
from collections.abc import Iterable, Iterator
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from typing import Annotated, Any, NamedTuple
import annotated_types as at
class Case(NamedTuple):
"""
A test case for `annotated_types`.
"""
annotation: Any
valid_cases: Iterable[Any]
invalid_cases: Iterable[Any]
def cases() -> Iterable[Case]:
# Gt, Ge, Lt, Le
yield Case(Annotated[int, at.Gt(4)], (5, 6, 1000), (4, 0, -1))
yield Case(Annotated[float, at.Gt(0.5)], (0.6, 0.7, 0.8, 0.9), (0.5, 0.0, -0.1))
yield Case(
Annotated[datetime, at.Gt(datetime(2000, 1, 1))],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
[datetime(2000, 1, 1), datetime(1999, 12, 31)],
)
yield Case(
Annotated[datetime, at.Gt(date(2000, 1, 1))],
[date(2000, 1, 2), date(2000, 1, 3)],
[date(2000, 1, 1), date(1999, 12, 31)],
)
yield Case(
Annotated[datetime, at.Gt(Decimal('1.123'))],
[Decimal('1.1231'), Decimal('123')],
[Decimal('1.123'), Decimal('0')],
)
yield Case(Annotated[int, at.Ge(4)], (4, 5, 6, 1000, 4), (0, -1))
yield Case(Annotated[float, at.Ge(0.5)], (0.5, 0.6, 0.7, 0.8, 0.9), (0.4, 0.0, -0.1))
yield Case(
Annotated[datetime, at.Ge(datetime(2000, 1, 1))],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
[datetime(1998, 1, 1), datetime(1999, 12, 31)],
)
yield Case(Annotated[int, at.Lt(4)], (0, -1), (4, 5, 6, 1000, 4))
yield Case(Annotated[float, at.Lt(0.5)], (0.4, 0.0, -0.1), (0.5, 0.6, 0.7, 0.8, 0.9))
yield Case(
Annotated[datetime, at.Lt(datetime(2000, 1, 1))],
[datetime(1999, 12, 31), datetime(1999, 12, 31)],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
)
yield Case(Annotated[int, at.Le(4)], (4, 0, -1), (5, 6, 1000))
yield Case(Annotated[float, at.Le(0.5)], (0.5, 0.0, -0.1), (0.6, 0.7, 0.8, 0.9))
yield Case(
Annotated[datetime, at.Le(datetime(2000, 1, 1))],
[datetime(2000, 1, 1), datetime(1999, 12, 31)],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
)
# Interval
yield Case(Annotated[int, at.Interval(gt=4)], (5, 6, 1000), (4, 0, -1))
yield Case(Annotated[int, at.Interval(gt=4, lt=10)], (5, 6), (4, 10, 1000, 0, -1))
yield Case(Annotated[float, at.Interval(ge=0.5, le=1)], (0.5, 0.9, 1), (0.49, 1.1))
yield Case(
Annotated[datetime, at.Interval(gt=datetime(2000, 1, 1), le=datetime(2000, 1, 3))],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
[datetime(2000, 1, 1), datetime(2000, 1, 4)],
)
yield Case(Annotated[int, at.MultipleOf(multiple_of=3)], (0, 3, 9), (1, 2, 4))
yield Case(Annotated[float, at.MultipleOf(multiple_of=0.5)], (0, 0.5, 1, 1.5), (0.4, 1.1))
# lengths
yield Case(Annotated[str, at.MinLen(3)], ('123', '1234', 'x' * 10), ('', '1', '12'))
yield Case(Annotated[str, at.Len(3)], ('123', '1234', 'x' * 10), ('', '1', '12'))
yield Case(Annotated[list[int], at.MinLen(3)], ([1, 2, 3], [1, 2, 3, 4], [1] * 10), ([], [1], [1, 2]))
yield Case(Annotated[list[int], at.Len(3)], ([1, 2, 3], [1, 2, 3, 4], [1] * 10), ([], [1], [1, 2]))
yield Case(Annotated[str, at.MaxLen(4)], ('', '1234'), ('12345', 'x' * 10))
yield Case(Annotated[str, at.Len(0, 4)], ('', '1234'), ('12345', 'x' * 10))
yield Case(Annotated[list[str], at.MaxLen(4)], ([], ['a', 'bcdef'], ['a', 'b', 'c']), (['a'] * 5, ['b'] * 10))
yield Case(Annotated[list[str], at.Len(0, 4)], ([], ['a', 'bcdef'], ['a', 'b', 'c']), (['a'] * 5, ['b'] * 10))
yield Case(Annotated[str, at.Len(3, 5)], ('123', '12345'), ('', '1', '12', '123456', 'x' * 10))
yield Case(Annotated[str, at.Len(3, 3)], ('123',), ('12', '1234'))
yield Case(Annotated[dict[int, int], at.Len(2, 3)], [{1: 1, 2: 2}], [{}, {1: 1}, {1: 1, 2: 2, 3: 3, 4: 4}])
yield Case(Annotated[set[int], at.Len(2, 3)], ({1, 2}, {1, 2, 3}), (set(), {1}, {1, 2, 3, 4}))
yield Case(Annotated[tuple[int, ...], at.Len(2, 3)], ((1, 2), (1, 2, 3)), ((), (1,), (1, 2, 3, 4)))
# Timezone
yield Case(
Annotated[datetime, at.Timezone(None)], [datetime(2000, 1, 1)], [datetime(2000, 1, 1, tzinfo=timezone.utc)]
)
yield Case(
Annotated[datetime, at.Timezone(...)], [datetime(2000, 1, 1, tzinfo=timezone.utc)], [datetime(2000, 1, 1)]
)
yield Case(
Annotated[datetime, at.Timezone(timezone.utc)],
[datetime(2000, 1, 1, tzinfo=timezone.utc)],
[datetime(2000, 1, 1), datetime(2000, 1, 1, tzinfo=timezone(timedelta(hours=6)))],
)
yield Case(
Annotated[datetime, at.Timezone('Europe/London')],
[datetime(2000, 1, 1, tzinfo=timezone(timedelta(0), name='Europe/London'))],
[datetime(2000, 1, 1), datetime(2000, 1, 1, tzinfo=timezone(timedelta(hours=6)))],
)
# Quantity
yield Case(Annotated[float, at.Unit(unit='m')], (5, 4.2), ('5m', '4.2m'))
# predicate types
yield Case(at.LowerCase[str], ['abc', 'foobar'], ['', 'A', 'Boom'])
yield Case(at.UpperCase[str], ['ABC', 'DEFO'], ['', 'a', 'abc', 'AbC'])
yield Case(at.IsDigit[str], ['123'], ['', 'ab', 'a1b2'])
yield Case(at.IsAscii[str], ['123', 'foo bar'], ['£100', '😊', 'whatever 👀'])
yield Case(Annotated[int, at.Predicate(lambda x: x % 2 == 0)], [0, 2, 4], [1, 3, 5])
yield Case(at.IsFinite[float], [1.23], [math.nan, math.inf, -math.inf])
yield Case(at.IsNotFinite[float], [math.nan, math.inf], [1.23])
yield Case(at.IsNan[float], [math.nan], [1.23, math.inf])
yield Case(at.IsNotNan[float], [1.23, math.inf], [math.nan])
yield Case(at.IsInfinite[float], [math.inf], [math.nan, 1.23])
yield Case(at.IsNotInfinite[float], [math.nan, 1.23], [math.inf])
# check stacked predicates
yield Case(at.IsInfinite[Annotated[float, at.Predicate(lambda x: x > 0)]], [math.inf], [-math.inf, 1.23, math.nan])
# doc
yield Case(Annotated[int, at.doc("A number")], [1, 2], [])
# custom GroupedMetadata
class MyCustomGroupedMetadata(at.GroupedMetadata):
def __iter__(self) -> Iterator[at.Predicate]:
yield at.Predicate(lambda x: float(x).is_integer())
yield Case(Annotated[float, MyCustomGroupedMetadata()], [0, 2.0], [0.01, 1.5])
@@ -0,0 +1,108 @@
Metadata-Version: 2.4
Name: anyio
Version: 4.15.1
Summary: High-level concurrency and networking framework on top of asyncio or Trio
Author-email: Alex Grönholm <alex.gronholm@nextday.fi>
License-Expression: MIT
Project-URL: Documentation, https://anyio.readthedocs.io/en/latest/
Project-URL: Changelog, https://anyio.readthedocs.io/en/stable/versionhistory.html
Project-URL: Source code, https://github.com/agronholm/anyio
Project-URL: Issue tracker, https://github.com/agronholm/anyio/issues
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Framework :: AnyIO
Classifier: Typing :: Typed
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Programming Language :: Python :: Free Threading :: 2 - Beta
Requires-Python: >=3.10
Description-Content-Type: text/x-rst
License-File: LICENSE
Requires-Dist: exceptiongroup>=1.0.2; python_version < "3.11"
Requires-Dist: idna>=2.8
Requires-Dist: typing_extensions>=4.16.0; python_version < "3.15"
Provides-Extra: trio
Requires-Dist: trio>=0.32.0; extra == "trio"
Dynamic: license-file
.. image:: https://github.com/agronholm/anyio/actions/workflows/test.yml/badge.svg
:target: https://github.com/agronholm/anyio/actions/workflows/test.yml
:alt: Build Status
.. image:: https://coveralls.io/repos/github/agronholm/anyio/badge.svg?branch=master
:target: https://coveralls.io/github/agronholm/anyio?branch=master
:alt: Code Coverage
.. image:: https://readthedocs.org/projects/anyio/badge/?version=latest
:target: https://anyio.readthedocs.io/en/latest/?badge=latest
:alt: Documentation
.. image:: https://badges.gitter.im/gitterHQ/gitter.svg
:target: https://gitter.im/python-trio/AnyIO
:alt: Gitter chat
.. image:: https://tidelift.com/badges/package/pypi/anyio
:target: https://tidelift.com/subscription/pkg/pypi-anyio
:alt: Tidelift
AnyIO is an asynchronous networking and concurrency library that works on top of either asyncio_ or
Trio_. It implements Trio-like `structured concurrency`_ (SC) on top of asyncio and works in harmony
with the native SC of Trio itself.
Applications and libraries written against AnyIO's API will run unmodified on either asyncio_ or
Trio_. AnyIO can also be adopted into a library or application incrementally – bit by bit, no full
refactoring necessary. It will blend in with the native libraries of your chosen backend.
To find out why you might want to use AnyIO's APIs instead of asyncio's, you can read about it
`here <https://anyio.readthedocs.io/en/stable/why.html>`_.
Documentation
-------------
View full documentation at: https://anyio.readthedocs.io/
Features
--------
AnyIO offers the following functionality:
* Task groups (nurseries_ in trio terminology)
* High-level networking (TCP, UDP and UNIX sockets)
* `Happy eyeballs`_ algorithm for TCP connections (more robust than that of asyncio on Python
3.8)
* async/await style UDP sockets (unlike asyncio where you still have to use Transports and
Protocols)
* A versatile API for byte streams and object streams
* Inter-task synchronization and communication (locks, conditions, events, semaphores, object
streams and futures)
* Worker threads
* Subprocesses
* Subinterpreter support for code parallelization (on Python 3.13 and later)
* Asynchronous file I/O (using worker threads)
* Signal handling
* Asynchronous versions of the functools_ and itertools_ modules
AnyIO also comes with its own pytest_ plugin which also supports asynchronous fixtures.
It even works with the popular Hypothesis_ library.
.. _asyncio: https://docs.python.org/3/library/asyncio.html
.. _Trio: https://github.com/python-trio/trio
.. _structured concurrency: https://en.wikipedia.org/wiki/Structured_concurrency
.. _nurseries: https://trio.readthedocs.io/en/stable/reference-core.html#nurseries-and-spawning
.. _Happy eyeballs: https://en.wikipedia.org/wiki/Happy_Eyeballs
.. _pytest: https://docs.pytest.org/en/latest/
.. _functools: https://docs.python.org/3/library/functools.html
.. _itertools: https://docs.python.org/3/library/itertools.html
.. _Hypothesis: https://hypothesis.works/
Security contact information
----------------------------
To report a security vulnerability, please use the `Tidelift security contact`_.
Tidelift will coordinate the fix and disclosure.
.. _Tidelift security contact: https://tidelift.com/security
@@ -0,0 +1,53 @@
anyio/__init__.py,sha256=OfroYRjIUw_JFj0Zwh1JAaajMQg4VqtoAt0XoOHDRwE,7602
anyio/_lazyimport.py,sha256=LCjnYanIfYE9G-Dv_EsqNLDuVRppNicWZjCTfS9-sJU,6082
anyio/from_thread.py,sha256=iZZbfcNySi4wAcx1_cxAQE4J0YZC88qRMIYqQ6SF9sc,19415
anyio/functools.py,sha256=T4JS8IXq-x1S0Lbo2owF8l9fza2KypO147QLeyz4cjs,11797
anyio/itertools.py,sha256=TS7_UCa2Y3cggtaLlWHbjOyf6QhPfQUdPduNW-ddXTE,16255
anyio/lowlevel.py,sha256=QyWkvfetbCH0mHkqv1yxGwOaiZ7fyxhpZkHWOXPV5JU,6280
anyio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
anyio/pytest_plugin.py,sha256=oLXyOstvRZW3VVbqIODhZsPt4jSz2UGh44YeG2nJXx0,14516
anyio/to_interpreter.py,sha256=ATnoT6kei9wDmQZtXTA3SsY4CK7XKr8Ta4_x5i6pbTw,7100
anyio/to_process.py,sha256=p6n8j0nreJUl1DEkR_9PL6KRoKtNqFxORTn8qZBvn94,9850
anyio/to_thread.py,sha256=8MPVrrQq8ttGvV21cSd68rMto5bhxJ0-i9CupXag5Gs,2750
anyio/_backends/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
anyio/_backends/_asyncio.py,sha256=Vqy7O1ymmEpxTiPzehSuBCVTziGALNV-BbsIRaKXsg4,107767
anyio/_backends/_trio.py,sha256=Gs8XzX0IeBvje8IEZtJmpNylKCUAChKv7I_YZi3wlmg,45715
anyio/_core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
anyio/_core/_asyncio_selector_thread.py,sha256=2PdxFM3cs02Kp6BSppbvmRT7q7asreTW5FgBxEsflBo,5626
anyio/_core/_concurrency_utils.py,sha256=kvRXMrnDqgQTBkZc-f279-lBnZM4YbSToXqhnAUCfSc,4522
anyio/_core/_contextmanagers.py,sha256=YInBCabiEeS-UaP_Jdxa1CaFC71ETPW8HZTHIM8Rsc8,7215
anyio/_core/_eventloop.py,sha256=ByZUeJD9alMfcyTseRo5IzTO0IltEul_Gyq9iqSjqDk,6658
anyio/_core/_exceptions.py,sha256=B4ZhLkBQT4tI6vShv4-GJui492IJsoIDTw1FQJEkmAI,5618
anyio/_core/_fileio.py,sha256=q--ft9h5Y_FRLzhAnoYrXf3tOuZ8ZyIN2A9mCfMbZRc,34163
anyio/_core/_futures.py,sha256=YQq5uhnCnKQMQD62tOLfzEm7dUXY3GQorJdH6ghfgIQ,6177
anyio/_core/_resources.py,sha256=Ld5duCe9NwgPfI7EIOh3CMIBYQ7rq6ZQEa7CFS0u9Ec,450
anyio/_core/_signals.py,sha256=mjTBB2hTKNPRlU0IhnijeQedpWOGERDiMjSlJQsFrug,1016
anyio/_core/_sockets.py,sha256=C8b-3w5H9n0Fo4g7X8WJvk0m6CGh6NVAAFtieBGHK3g,35289
anyio/_core/_streams.py,sha256=FczFwIgDpnkK0bODWJXMpsUJYdvAD04kaUaGzJU8DK0,1806
anyio/_core/_subprocesses.py,sha256=TMBuGNILz-47w5t9vayJ1GpCpDT6LxsMohJDFCewIRc,7938
anyio/_core/_synchronization.py,sha256=ggsd74QPd846faA2HQeT07ONQ5HQ1S78gy8mvB3qCrw,21658
anyio/_core/_tasks.py,sha256=J2dhM_Jq3Grsfg_enNWpRz2SxfM15uLt-pEfvoQq3uA,15391
anyio/_core/_tempfile.py,sha256=IgoBCFaPhrFvsF9KHcCcrnCRn-UqMfw0WRdSDWgxAiI,19726
anyio/_core/_testing.py,sha256=ZRucLh5kqhiFWAJMMbd8yZ6uIvz0rq3WwL72ldosxAU,2340
anyio/_core/_typedattr.py,sha256=gt9Dzn-IL0QW14aHPDD8PAtq95FYKNNupHyf37xZGPQ,2607
anyio/abc/__init__.py,sha256=XIVvCI2c6oyaOcqXe94k2rzU5_s7-ZNnlxGTk34H4T0,3050
anyio/abc/_eventloop.py,sha256=0-UwgB351rkocCt8m3fjTmcNx2Fxn52nUoujG5DzMvU,11127
anyio/abc/_resources.py,sha256=hrb0LXUgHSp3i8G6W8JJchdzKXgg1m0T-L7udXmv2y0,900
anyio/abc/_sockets.py,sha256=jQWnPX7iDIDyvyvVkbuFhjezw4oI2WggzbW6Vl2-eqs,13162
anyio/abc/_streams.py,sha256=IMeOPPutFXRbyeAutxV1HJe7FUQZYp2qKrJJpP1DCv0,7620
anyio/abc/_subprocesses.py,sha256=CDqU1p0HuBS0pXUzBW_hbfhihTJRM67n__7qZmoPU84,2123
anyio/abc/_tasks.py,sha256=HBPpT72aOy0bwMAxBCKmnXsR328wCYRjYm3iaVw2fQo,7070
anyio/abc/_testing.py,sha256=A9Y8RAClIPn1A0aNHBN8YoaRy_MKOpbFGEbTHAXgB6U,2145
anyio/streams/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
anyio/streams/buffered.py,sha256=u7hCD8SNrYHcutG6K5wEiy1F88-pizgjvEFM22Kq2Cw,6746
anyio/streams/file.py,sha256=6jujI2m-QJITqqKFamrupX_DNsU7y2Fz3omLZxOLuY0,4524
anyio/streams/memory.py,sha256=rpxNzDMag9PCOynBqN64KyuAfAPB41vFyRP-Npo-lE0,10793
anyio/streams/stapled.py,sha256=UJ2Rgt0NUmM1VovRNOoZoic960lcbDjb3vhfZg3b_Z0,4845
anyio/streams/text.py,sha256=BcVAGJw1VRvtIqnv-o0Rb0pwH7p8vwlvl21xHq522ag,5765
anyio/streams/tls.py,sha256=fqLmxvULFO9rvxGyJ33VPrOIN7E3eiU1q90BkmKigQ8,15818
anyio-4.15.1.dist-info/licenses/LICENSE,sha256=U2GsncWPLvX9LpsJxoKXwX8ElQkJu8gCO9uC6s8iwrA,1081
anyio-4.15.1.dist-info/METADATA,sha256=BDrstXGvcYnopsUo9djjJUXxss36r3YdUebVtn6jyoE,4733
anyio-4.15.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
anyio-4.15.1.dist-info/entry_points.txt,sha256=_d6Yu6uiaZmNe0CydowirE9Cmg7zUL2g08tQpoS3Qvc,39
anyio-4.15.1.dist-info/top_level.txt,sha256=QglSMiWX8_5dpoVAEIHdEYzvqFMdSYWmCj6tYw2ITkQ,6
anyio-4.15.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
Wheel-Version: 1.0
Generator: setuptools (84.0.0)
Root-Is-Purelib: true
Tag: py3-none-any
@@ -0,0 +1,2 @@
[pytest11]
anyio = anyio.pytest_plugin
@@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2018 Alex Grönholm
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
anyio
@@ -0,0 +1,154 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from ._lazyimport import (
fix_package_names,
install_lazy_importer,
set_deprecated_aliases,
)
if TYPE_CHECKING or not install_lazy_importer():
from ._core._concurrency_utils import amap as amap
from ._core._concurrency_utils import as_completed as as_completed
from ._core._concurrency_utils import gather as gather
from ._core._contextmanagers import (
AsyncContextManagerMixin as AsyncContextManagerMixin,
)
from ._core._contextmanagers import ContextManagerMixin as ContextManagerMixin
from ._core._eventloop import current_time as current_time
from ._core._eventloop import get_all_backends as get_all_backends
from ._core._eventloop import get_available_backends as get_available_backends
from ._core._eventloop import get_cancelled_exc_class as get_cancelled_exc_class
from ._core._eventloop import run as run
from ._core._eventloop import sleep as sleep
from ._core._eventloop import sleep_forever as sleep_forever
from ._core._eventloop import sleep_until as sleep_until
from ._core._exceptions import BrokenResourceError as BrokenResourceError
from ._core._exceptions import BrokenWorkerInterpreter as BrokenWorkerInterpreter
from ._core._exceptions import BrokenWorkerProcess as BrokenWorkerProcess
from ._core._exceptions import BusyResourceError as BusyResourceError
from ._core._exceptions import ClosedResourceError as ClosedResourceError
from ._core._exceptions import ConnectionFailed as ConnectionFailed
from ._core._exceptions import DelimiterNotFound as DelimiterNotFound
from ._core._exceptions import EndOfStream as EndOfStream
from ._core._exceptions import FutureAlreadyFinished as FutureAlreadyFinished
from ._core._exceptions import FutureCancelled as FutureCancelled
from ._core._exceptions import FutureFailed as FutureFailed
from ._core._exceptions import FutureNotFinished as FutureNotFinished
from ._core._exceptions import IncompleteRead as IncompleteRead
from ._core._exceptions import NoEventLoopError as NoEventLoopError
from ._core._exceptions import RunFinishedError as RunFinishedError
from ._core._exceptions import TaskCancelled as TaskCancelled
from ._core._exceptions import TaskFailed as TaskFailed
from ._core._exceptions import TaskNotFinished as TaskNotFinished
from ._core._exceptions import (
TypedAttributeLookupError as TypedAttributeLookupError,
)
from ._core._exceptions import WouldBlock as WouldBlock
from ._core._fileio import AsyncFile as AsyncFile
from ._core._fileio import Path as Path
from ._core._fileio import open_file as open_file
from ._core._fileio import wrap_file as wrap_file
from ._core._futures import Future as Future
from ._core._resources import aclose_forcefully as aclose_forcefully
from ._core._signals import open_signal_receiver as open_signal_receiver
from ._core._sockets import TCPConnectable as TCPConnectable
from ._core._sockets import UNIXConnectable as UNIXConnectable
from ._core._sockets import as_connectable as as_connectable
from ._core._sockets import connect_tcp as connect_tcp
from ._core._sockets import connect_unix as connect_unix
from ._core._sockets import (
create_connected_udp_socket as create_connected_udp_socket,
)
from ._core._sockets import (
create_connected_unix_datagram_socket as create_connected_unix_datagram_socket,
)
from ._core._sockets import create_tcp_listener as create_tcp_listener
from ._core._sockets import create_udp_socket as create_udp_socket
from ._core._sockets import (
create_unix_datagram_socket as create_unix_datagram_socket,
)
from ._core._sockets import create_unix_listener as create_unix_listener
from ._core._sockets import getaddrinfo as getaddrinfo
from ._core._sockets import getnameinfo as getnameinfo
from ._core._sockets import notify_closing as notify_closing
from ._core._sockets import wait_readable as wait_readable
from ._core._sockets import wait_socket_readable as wait_socket_readable
from ._core._sockets import wait_socket_writable as wait_socket_writable
from ._core._sockets import wait_writable as wait_writable
from ._core._streams import (
create_memory_object_stream as create_memory_object_stream,
)
from ._core._subprocesses import open_process as open_process
from ._core._subprocesses import run_process as run_process
from ._core._synchronization import CapacityLimiter as CapacityLimiter
from ._core._synchronization import (
CapacityLimiterStatistics as CapacityLimiterStatistics,
)
from ._core._synchronization import Condition as Condition
from ._core._synchronization import ConditionStatistics as ConditionStatistics
from ._core._synchronization import Event as Event
from ._core._synchronization import EventStatistics as EventStatistics
from ._core._synchronization import Lock as Lock
from ._core._synchronization import LockStatistics as LockStatistics
from ._core._synchronization import ResourceGuard as ResourceGuard
from ._core._synchronization import Semaphore as Semaphore
from ._core._synchronization import SemaphoreStatistics as SemaphoreStatistics
from ._core._tasks import TASK_STATUS_IGNORED as TASK_STATUS_IGNORED
from ._core._tasks import CancelScope as CancelScope
from ._core._tasks import TaskHandle as TaskHandle
from ._core._tasks import create_task_group as create_task_group
from ._core._tasks import current_effective_deadline as current_effective_deadline
from ._core._tasks import fail_after as fail_after
from ._core._tasks import fail_at as fail_at
from ._core._tasks import move_on_after as move_on_after
from ._core._tasks import move_on_at as move_on_at
from ._core._tempfile import NamedTemporaryFile as NamedTemporaryFile
from ._core._tempfile import SpooledTemporaryFile as SpooledTemporaryFile
from ._core._tempfile import TemporaryDirectory as TemporaryDirectory
from ._core._tempfile import TemporaryFile as TemporaryFile
from ._core._tempfile import gettempdir as gettempdir
from ._core._tempfile import gettempdirb as gettempdirb
from ._core._tempfile import mkdtemp as mkdtemp
from ._core._tempfile import mkstemp as mkstemp
from ._core._testing import TaskInfo as TaskInfo
from ._core._testing import get_current_task as get_current_task
from ._core._testing import get_running_tasks as get_running_tasks
from ._core._testing import wait_all_tasks_blocked as wait_all_tasks_blocked
from ._core._typedattr import TypedAttributeProvider as TypedAttributeProvider
from ._core._typedattr import TypedAttributeSet as TypedAttributeSet
from ._core._typedattr import typed_attribute as typed_attribute
# ruff: isort: off
from . import (
abc as abc,
)
from . import (
from_thread as from_thread,
)
from . import (
functools as functools,
)
from . import (
itertools as itertools,
)
from . import (
lowlevel as lowlevel,
)
from . import (
to_interpreter as to_interpreter,
)
from . import (
to_process as to_process,
)
from . import (
to_thread as to_thread,
)
fix_package_names()
set_deprecated_aliases(
{
"BrokenWorkerIntepreter": "anyio.BrokenWorkerInterpreter",
}
)
Whitespace-only changes.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Whitespace-only changes.
@@ -0,0 +1,167 @@
from __future__ import annotations
import asyncio
import socket
import threading
from collections.abc import Callable
from selectors import EVENT_READ, EVENT_WRITE, DefaultSelector
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from _typeshed import FileDescriptorLike
_selector_lock = threading.Lock()
_selector: Selector | None = None
class Selector:
def __init__(self) -> None:
self._thread = threading.Thread(target=self.run, name="AnyIO socket selector")
self._selector = DefaultSelector()
self._send, self._receive = socket.socketpair()
self._send.setblocking(False)
self._receive.setblocking(False)
# This somewhat reduces the amount of memory wasted queueing up data
# for wakeups. With these settings, maximum number of 1-byte sends
# before getting BlockingIOError:
# Linux 4.8: 6
# macOS (darwin 15.5): 1
# Windows 10: 525347
# Windows you're weird. (And on Windows setting SNDBUF to 0 makes send
# blocking, even on non-blocking sockets, so don't do that.)
self._receive.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1)
self._send.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 1)
# On Windows this is a TCP socket so this might matter. On other
# platforms this fails b/c AF_UNIX sockets aren't actually TCP.
try:
self._send.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
except OSError:
pass
self._selector.register(self._receive, EVENT_READ)
self._closed = False
def start(self) -> None:
self._thread.start()
threading._register_atexit(self._stop) # type: ignore[attr-defined]
def _stop(self) -> None:
global _selector
self._closed = True
self._notify_self()
self._send.close()
self._thread.join()
self._selector.unregister(self._receive)
self._receive.close()
self._selector.close()
_selector = None
assert not self._selector.get_map(), (
"selector still has registered file descriptors after shutdown"
)
def _notify_self(self) -> None:
try:
self._send.send(b"\x00")
except BlockingIOError:
pass
def add_reader(self, fd: FileDescriptorLike, callback: Callable[[], Any]) -> None:
loop = asyncio.get_running_loop()
try:
key = self._selector.get_key(fd)
except KeyError:
self._selector.register(fd, EVENT_READ, {EVENT_READ: (loop, callback)})
else:
if EVENT_READ in key.data:
raise ValueError(
"this file descriptor is already registered for reading"
)
key.data[EVENT_READ] = loop, callback
self._selector.modify(fd, key.events | EVENT_READ, key.data)
self._notify_self()
def add_writer(self, fd: FileDescriptorLike, callback: Callable[[], Any]) -> None:
loop = asyncio.get_running_loop()
try:
key = self._selector.get_key(fd)
except KeyError:
self._selector.register(fd, EVENT_WRITE, {EVENT_WRITE: (loop, callback)})
else:
if EVENT_WRITE in key.data:
raise ValueError(
"this file descriptor is already registered for writing"
)
key.data[EVENT_WRITE] = loop, callback
self._selector.modify(fd, key.events | EVENT_WRITE, key.data)
self._notify_self()
def remove_reader(self, fd: FileDescriptorLike) -> bool:
try:
key = self._selector.get_key(fd)
except KeyError:
return False
if new_events := key.events ^ EVENT_READ:
del key.data[EVENT_READ]
self._selector.modify(fd, new_events, key.data)
else:
self._selector.unregister(fd)
return True
def remove_writer(self, fd: FileDescriptorLike) -> bool:
try:
key = self._selector.get_key(fd)
except KeyError:
return False
if new_events := key.events ^ EVENT_WRITE:
del key.data[EVENT_WRITE]
self._selector.modify(fd, new_events, key.data)
else:
self._selector.unregister(fd)
return True
def run(self) -> None:
while not self._closed:
for key, events in self._selector.select():
if key.fileobj is self._receive:
try:
while self._receive.recv(4096):
pass
except BlockingIOError:
pass
continue
if events & EVENT_READ:
loop, callback = key.data[EVENT_READ]
self.remove_reader(key.fd)
try:
loop.call_soon_threadsafe(callback)
except RuntimeError:
pass # the loop was already closed
if events & EVENT_WRITE:
loop, callback = key.data[EVENT_WRITE]
self.remove_writer(key.fd)
try:
loop.call_soon_threadsafe(callback)
except RuntimeError:
pass # the loop was already closed
def get_selector() -> Selector:
global _selector
with _selector_lock:
if _selector is None:
_selector = Selector()
_selector.start()
return _selector
@@ -0,0 +1,156 @@
from __future__ import annotations
from inspect import iscoroutine
__all__ = ("amap", "as_completed", "gather")
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine, Iterable
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any, TypeVar, overload
from ..abc._tasks import get_coro_name
from ._exceptions import BrokenResourceError
from ._streams import create_memory_object_stream
from ._tasks import TaskHandle, create_task_group
if TYPE_CHECKING:
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
R = TypeVar("R")
S = TypeVar("S")
T = TypeVar("T")
U = TypeVar("U")
V = TypeVar("V")
W = TypeVar("W")
@overload
async def gather(
coro1: Coroutine[Any, Any, R], coro2: Coroutine[Any, Any, S], /
) -> tuple[R, S]: ...
@overload
async def gather(
coro1: Coroutine[Any, Any, R],
coro2: Coroutine[Any, Any, S],
coro3: Coroutine[Any, Any, T],
/,
) -> tuple[R, S, T]: ...
@overload
async def gather(
coro1: Coroutine[Any, Any, R],
coro2: Coroutine[Any, Any, S],
coro3: Coroutine[Any, Any, T],
coro4: Coroutine[Any, Any, U],
/,
) -> tuple[R, S, T, U]: ...
@overload
async def gather(
coro1: Coroutine[Any, Any, R],
coro2: Coroutine[Any, Any, S],
coro3: Coroutine[Any, Any, T],
coro4: Coroutine[Any, Any, U],
coro5: Coroutine[Any, Any, V],
/,
) -> tuple[R, S, T, U, V]: ...
@overload
async def gather(
coro1: Coroutine[Any, Any, R],
coro2: Coroutine[Any, Any, S],
coro3: Coroutine[Any, Any, T],
coro4: Coroutine[Any, Any, U],
coro5: Coroutine[Any, Any, V],
coro6: Coroutine[Any, Any, W],
/,
) -> tuple[R, S, T, U, V, W]: ...
# handle arbitrary length if awaitables are all of the same type
@overload
async def gather(*coros: Coroutine[Any, Any, R]) -> tuple[R, ...]: ...
async def gather(*coros: Coroutine[Any, Any, Any]) -> tuple[Any, ...]:
"""
Run coroutines concurrently in a task group. The order of result values corresponds
to the order of coroutines passed.
:param coros: coroutine objects to run as tasks
:return: task results for each argument in the same order as they were passed
"""
handles: list[TaskHandle[Any]] = []
async with create_task_group() as tg:
handles.extend(tg.create_task(coro) for coro in coros)
return tuple(h.return_value for h in handles)
@asynccontextmanager
async def as_completed(
*awaitables: Awaitable[R],
) -> AsyncGenerator[MemoryObjectReceiveStream[TaskHandle[R]]]:
"""
Run awaitable objects concurrently in a task group, returning an iterator which can
be used to get finished task handles in the order they complete.
:param awaitables: awaitable objects to run as tasks
:return: MemoryObjectReceiveStream for iterating over task handles as tasks complete.
"""
if not awaitables:
raise ValueError("as_completed() takes at least one awaitable")
send, recv = create_memory_object_stream[TaskHandle[R]](len(awaitables))
task_handles: list[TaskHandle[R]] = []
async def runner(
awaitable: Awaitable[R],
index: int,
_send: MemoryObjectSendStream[TaskHandle[R]],
) -> R:
async with _send:
try:
return await awaitable
finally:
try:
_send.send_nowait(task_handles[index])
except BrokenResourceError:
pass
async with recv, create_task_group() as tg:
async with send:
for i, awaitable in enumerate(awaitables):
name = get_coro_name(awaitable) if iscoroutine(awaitable) else None
task_handles.append(
tg.start_soon(runner, awaitable, i, send.clone(), name=name)
)
try:
yield recv
finally:
tg.cancel_scope.cancel()
async def amap(
func: Callable[[T], Coroutine[Any, Any, R]], args: Iterable[T]
) -> list[R]:
"""
Run the given coroutine function concurrently for multiple argument values.
:param func: a coroutine function that takes a single argument
:param args: a sequence of argument values to pass to ``func``
:return: task results for each argument in the same order as they were passed
"""
handles: list[TaskHandle[R]] = []
async with create_task_group() as tg:
handles.extend(tg.start_soon(func, arg) for arg in args)
return [h.return_value for h in handles]
@@ -0,0 +1,200 @@
from __future__ import annotations
from abc import abstractmethod
from contextlib import AbstractAsyncContextManager, AbstractContextManager
from inspect import isasyncgen, iscoroutine, isgenerator
from types import TracebackType
from typing import Protocol, TypeVar, cast, final
_T_co = TypeVar("_T_co", covariant=True)
_ExitT_co = TypeVar("_ExitT_co", covariant=True, bound="bool | None")
class _SupportsCtxMgr(Protocol[_T_co, _ExitT_co]):
def __contextmanager__(self) -> AbstractContextManager[_T_co, _ExitT_co]: ...
class _SupportsAsyncCtxMgr(Protocol[_T_co, _ExitT_co]):
def __asynccontextmanager__(
self,
) -> AbstractAsyncContextManager[_T_co, _ExitT_co]: ...
class ContextManagerMixin:
"""
Mixin class providing context manager functionality via a generator-based
implementation.
This class allows you to implement a context manager via :meth:`__contextmanager__`
which should return a generator. The mechanics are meant to mirror those of
:func:`@contextmanager <contextlib.contextmanager>`.
.. note:: Classes using this mix-in are not reentrant as context managers, meaning
that once you enter it, you can't re-enter before first exiting it.
.. seealso:: :doc:`contextmanagers`
"""
__cm: AbstractContextManager[object, bool | None] | None = None
@final
def __enter__(self: _SupportsCtxMgr[_T_co, bool | None]) -> _T_co:
# Needed for mypy to assume self still has the __cm member
assert isinstance(self, ContextManagerMixin)
if self.__cm is not None:
raise RuntimeError(
f"this {self.__class__.__qualname__} has already been entered"
)
cm = self.__contextmanager__()
if not isinstance(cm, AbstractContextManager):
if isgenerator(cm):
raise TypeError(
"__contextmanager__() returned a generator object instead of "
"a context manager. Did you forget to add the @contextmanager "
"decorator?"
)
raise TypeError(
f"__contextmanager__() did not return a context manager object, "
f"but {cm.__class__!r}"
)
if cm is self:
raise TypeError(
f"{self.__class__.__qualname__}.__contextmanager__() returned "
f"self. Did you forget to add the @contextmanager decorator and a "
f"'yield' statement?"
)
value = cm.__enter__()
self.__cm = cm
return value
@final
def __exit__(
self: _SupportsCtxMgr[object, _ExitT_co],
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> _ExitT_co:
# Needed for mypy to assume self still has the __cm member
assert isinstance(self, ContextManagerMixin)
if self.__cm is None:
raise RuntimeError(
f"this {self.__class__.__qualname__} has not been entered yet"
)
# Prevent circular references
cm = self.__cm
del self.__cm
return cast(_ExitT_co, cm.__exit__(exc_type, exc_val, exc_tb))
@abstractmethod
def __contextmanager__(self) -> AbstractContextManager[object, bool | None]:
"""
Implement your context manager logic here.
This method **must** be decorated with
:func:`@contextmanager <contextlib.contextmanager>`.
.. note:: Remember that the ``yield`` will raise any exception raised in the
enclosed context block, so use a ``finally:`` block to clean up resources!
:return: a context manager object
"""
class AsyncContextManagerMixin:
"""
Mixin class providing async context manager functionality via a generator-based
implementation.
This class allows you to implement a context manager via
:meth:`__asynccontextmanager__`. The mechanics are meant to mirror those of
:func:`@asynccontextmanager <contextlib.asynccontextmanager>`.
.. note:: Classes using this mix-in are not reentrant as context managers, meaning
that once you enter it, you can't re-enter before first exiting it.
.. seealso:: :doc:`contextmanagers`
"""
__cm: AbstractAsyncContextManager[object, bool | None] | None = None
@final
async def __aenter__(self: _SupportsAsyncCtxMgr[_T_co, bool | None]) -> _T_co:
# Needed for mypy to assume self still has the __cm member
assert isinstance(self, AsyncContextManagerMixin)
if self.__cm is not None:
raise RuntimeError(
f"this {self.__class__.__qualname__} has already been entered"
)
cm = self.__asynccontextmanager__()
if not isinstance(cm, AbstractAsyncContextManager):
if isasyncgen(cm):
raise TypeError(
"__asynccontextmanager__() returned an async generator instead of "
"an async context manager. Did you forget to add the "
"@asynccontextmanager decorator?"
)
elif iscoroutine(cm):
cm.close()
raise TypeError(
"__asynccontextmanager__() returned a coroutine object instead of "
"an async context manager. Did you forget to add the "
"@asynccontextmanager decorator and a 'yield' statement?"
)
raise TypeError(
f"__asynccontextmanager__() did not return an async context manager, "
f"but {cm.__class__!r}"
)
if cm is self:
raise TypeError(
f"{self.__class__.__qualname__}.__asynccontextmanager__() returned "
f"self. Did you forget to add the @asynccontextmanager decorator and a "
f"'yield' statement?"
)
value = await cm.__aenter__()
self.__cm = cm
return value
@final
async def __aexit__(
self: _SupportsAsyncCtxMgr[object, _ExitT_co],
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> _ExitT_co:
assert isinstance(self, AsyncContextManagerMixin)
if self.__cm is None:
raise RuntimeError(
f"this {self.__class__.__qualname__} has not been entered yet"
)
# Prevent circular references
cm = self.__cm
del self.__cm
return cast(_ExitT_co, await cm.__aexit__(exc_type, exc_val, exc_tb))
@abstractmethod
def __asynccontextmanager__(
self,
) -> AbstractAsyncContextManager[object, bool | None]:
"""
Implement your async context manager logic here.
This method **must** be decorated with
:func:`@asynccontextmanager <contextlib.asynccontextmanager>`.
.. note:: Remember that the ``yield`` will raise any exception raised in the
enclosed context block, so use a ``finally:`` block to clean up resources!
:return: an async context manager object
"""
@@ -0,0 +1,240 @@
from __future__ import annotations
import math
import sys
import threading
from collections.abc import Awaitable, Callable, Generator
from contextlib import contextmanager
from contextvars import Token
from importlib import import_module
from typing import TYPE_CHECKING, Any, TypeVar
from ._exceptions import NoEventLoopError
if sys.version_info >= (3, 11):
from typing import TypeVarTuple, Unpack
else:
from typing_extensions import TypeVarTuple, Unpack
sniffio: Any
try:
import sniffio
except ModuleNotFoundError:
sniffio = None
if TYPE_CHECKING:
from ..abc import AsyncBackend
# This must be updated when new backends are introduced
BACKENDS = "asyncio", "trio"
T_Retval = TypeVar("T_Retval")
PosArgsT = TypeVarTuple("PosArgsT")
threadlocals = threading.local()
loaded_backends: dict[str, type[AsyncBackend]] = {}
def run(
func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval]],
*args: Unpack[PosArgsT],
backend: str = "asyncio",
backend_options: dict[str, Any] | None = None,
) -> T_Retval:
"""
Run the given coroutine function in an asynchronous event loop.
The current thread must not be already running an event loop.
:param func: a coroutine function
:param args: positional arguments to ``func``
:param backend: name of the asynchronous event loop implementation – currently
either ``asyncio`` or ``trio``
:param backend_options: keyword arguments to call the backend ``run()``
implementation with (documented :ref:`here <backend options>`)
:return: the return value of the coroutine function
:raises RuntimeError: if an asynchronous event loop is already running in this
thread
:raises LookupError: if the named backend is not found
"""
if asynclib_name := current_async_library():
raise RuntimeError(f"Already running {asynclib_name} in this thread")
try:
async_backend = get_async_backend(backend)
except ImportError as exc:
if backend in BACKENDS:
raise LookupError(
f"Backend {backend!r} is not available. "
f"Install it with: pip install anyio[{backend}]"
) from exc
raise LookupError(f"No such backend: {backend}") from exc
token = None
if asynclib_name is None:
# Since we're in control of the event loop, we can cache the name of the async
# library
token = set_current_async_library(backend)
try:
backend_options = backend_options or {}
return async_backend.run(func, args, {}, backend_options)
finally:
reset_current_async_library(token)
async def sleep(delay: float) -> None:
"""
Pause the current task for the specified duration.
:param delay: the duration, in seconds
"""
return await get_async_backend().sleep(delay)
async def sleep_forever() -> None:
"""
Pause the current task until it's cancelled.
This is a shortcut for ``sleep(math.inf)``.
.. versionadded:: 3.1
"""
await sleep(math.inf)
async def sleep_until(deadline: float) -> None:
"""
Pause the current task until the given time.
:param deadline: the absolute time to wake up at (according to the internal
monotonic clock of the event loop)
.. versionadded:: 3.1
"""
now = current_time()
await sleep(max(deadline - now, 0))
def current_time() -> float:
"""
Return the current value of the event loop's internal clock.
:return: the clock value (seconds)
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
"""
return get_async_backend().current_time()
def get_all_backends() -> tuple[str, ...]:
"""Return a tuple of the names of all built-in backends."""
return BACKENDS
def get_available_backends() -> tuple[str, ...]:
"""
Test for the availability of built-in backends.
:return a tuple of the built-in backend names that were successfully imported
.. versionadded:: 4.12
"""
available_backends: list[str] = []
for backend_name in get_all_backends():
try:
get_async_backend(backend_name)
except ImportError:
continue
available_backends.append(backend_name)
return tuple(available_backends)
def get_cancelled_exc_class() -> type[BaseException]:
"""
Return the current async library's cancellation exception class.
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
"""
return get_async_backend().cancelled_exception_class()
#
# Private API
#
@contextmanager
def claim_worker_thread(
backend_class: type[AsyncBackend], token: object
) -> Generator[Any, None, None]:
from ..lowlevel import EventLoopToken
threadlocals.current_token = EventLoopToken(backend_class, token)
try:
yield
finally:
del threadlocals.current_token
def get_async_backend(asynclib_name: str | None = None) -> type[AsyncBackend]:
if asynclib_name is None:
asynclib_name = current_async_library()
if not asynclib_name:
raise NoEventLoopError(
f"Not currently running on any asynchronous event loop. "
f"Available async backends: {', '.join(get_all_backends())}"
)
# We use our own dict instead of sys.modules to get the already imported back-end
# class because the appropriate modules in sys.modules could potentially be only
# partially initialized
try:
return loaded_backends[asynclib_name]
except KeyError:
module = import_module(f"anyio._backends._{asynclib_name}")
loaded_backends[asynclib_name] = module.backend_class
return module.backend_class
def current_async_library() -> str | None:
if sniffio is None:
# If sniffio is not installed, we assume we're either running asyncio or nothing
import asyncio
try:
asyncio.get_running_loop()
return "asyncio"
except RuntimeError:
pass
else:
try:
return sniffio.current_async_library()
except sniffio.AsyncLibraryNotFoundError:
pass
return None
def set_current_async_library(asynclib_name: str | None) -> Token | None:
# no-op if sniffio is not installed
if sniffio is None:
return None
return sniffio.current_async_library_cvar.set(asynclib_name)
def reset_current_async_library(token: Token | None) -> None:
if token is not None:
sniffio.current_async_library_cvar.reset(token)
@@ -0,0 +1,205 @@
from __future__ import annotations
import sys
from collections.abc import Generator
from textwrap import dedent
from typing import Any
if sys.version_info < (3, 11):
from exceptiongroup import BaseExceptionGroup
class BrokenResourceError(Exception):
"""
Raised when trying to use a resource that has been rendered unusable due to external
causes (e.g. a send stream whose peer has disconnected).
"""
class BrokenWorkerProcess(Exception):
"""
Raised by :meth:`~anyio.to_process.run_sync` if the worker process terminates abruptly or
otherwise misbehaves.
"""
class BrokenWorkerInterpreter(Exception):
"""
Raised by :meth:`~anyio.to_interpreter.run_sync` if an unexpected exception is
raised in the subinterpreter.
"""
def __init__(self, excinfo: Any):
# This was adapted from concurrent.futures.interpreter.ExecutionFailed
msg = excinfo.formatted
if not msg:
if excinfo.type and excinfo.msg:
msg = f"{excinfo.type.__name__}: {excinfo.msg}"
else:
msg = excinfo.type.__name__ or excinfo.msg
super().__init__(msg)
self.excinfo = excinfo
def __str__(self) -> str:
try:
formatted = self.excinfo.errdisplay
except Exception:
return super().__str__()
else:
return dedent(
f"""
{super().__str__()}
Uncaught in the interpreter:
{formatted}
""".strip()
)
class BusyResourceError(Exception):
"""
Raised when two tasks are trying to read from or write to the same resource
concurrently.
"""
def __init__(self, action: str):
super().__init__(f"Another task is already {action} this resource")
class ClosedResourceError(Exception):
"""Raised when trying to use a resource that has been closed."""
class ConnectionFailed(OSError):
"""
Raised when a connection attempt fails.
.. note:: This class inherits from :exc:`OSError` for backwards compatibility.
"""
def iterate_exceptions(
exception: BaseException,
) -> Generator[BaseException, None, None]:
if isinstance(exception, BaseExceptionGroup):
for exc in exception.exceptions:
yield from iterate_exceptions(exc)
else:
yield exception
class DelimiterNotFound(Exception):
"""
Raised during
:meth:`~anyio.streams.buffered.BufferedByteReceiveStream.receive_until` if the
maximum number of bytes has been read without the delimiter being found.
"""
def __init__(self, max_bytes: int) -> None:
super().__init__(
f"The delimiter was not found among the first {max_bytes} bytes"
)
class EndOfStream(Exception):
"""
Raised when trying to read from a stream that has been closed from the other end.
"""
class IncompleteRead(Exception):
"""
Raised during
:meth:`~anyio.streams.buffered.BufferedByteReceiveStream.receive_exactly` or
:meth:`~anyio.streams.buffered.BufferedByteReceiveStream.receive_until` if the
connection is closed before the requested amount of bytes has been read.
"""
def __init__(self) -> None:
super().__init__(
"The stream was closed before the read operation could be completed"
)
class TypedAttributeLookupError(LookupError):
"""
Raised by :meth:`~anyio.TypedAttributeProvider.extra` when the given typed attribute
is not found and no default value has been given.
"""
class WouldBlock(Exception):
"""Raised by ``X_nowait`` functions if ``X()`` would block."""
class NoEventLoopError(RuntimeError):
"""
Raised by several functions that require an event loop to be running in the current
thread when there is no running event loop.
This is also raised by :func:`.from_thread.run` and :func:`.from_thread.run_sync`
if not calling from an AnyIO worker thread, and no ``token`` was passed.
"""
class RunFinishedError(RuntimeError):
"""
Raised by :func:`.from_thread.run` and :func:`.from_thread.run_sync` if the event
loop associated with the explicitly passed token has already finished.
"""
def __init__(self) -> None:
super().__init__(
"The event loop associated with the given token has already finished"
)
class TaskFailed(Exception):
"""
Raised when awaiting on, or attempting to access the return value of, a
:class:`.TaskHandle` that raised an exception.
"""
class TaskCancelled(TaskFailed):
"""
Raised when awaiting on, or attempting to access the return value of, a
:class:`.TaskHandle` that was cancelled.
"""
class TaskNotFinished(Exception):
"""
Raised when attempting to access the return value or exception of a
:class:`.TaskHandle` that is still pending completion.
"""
class FutureFailed(Exception):
"""
Raised when awaiting on, or attempting to access the return value of, a
:class:`.Future` that raised an exception.
"""
class FutureCancelled(FutureFailed):
"""
Raised when attempting to access the return value or exception of a
:class:`.Future` that was cancelled.
"""
class FutureNotFinished(Exception):
"""
Raised when attempting to access the return value or exception of a
:class:`.Future` that is still pending completion.
"""
class FutureAlreadyFinished(Exception):
"""
Raised when attempting set a result of or await a
:class:`.Future` that has already completed.
"""
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,191 @@
from __future__ import annotations
from collections.abc import Generator
from enum import Enum, auto
from typing import Any, Generic, TypeVar
from ._exceptions import (
FutureAlreadyFinished,
FutureCancelled,
FutureFailed,
FutureNotFinished,
)
from ._synchronization import Event
T = TypeVar("T")
class Future(Generic[T]):
"""
An awaitable object that works similar to a :class:`asyncio.Future` but
with similar characteristics to a :class:`.TaskHandle`.
"""
class Status(Enum):
"""
The status of a future handle.
.. attribute:: PENDING
The future has not finished yet.
.. attribute:: FINISHED
The future has finished with a return value.
.. attribute:: CANCELLED
The future was cancelled and has finished since.
.. attribute:: FAILED
The future raised an exception.
"""
PENDING = auto()
FINISHED = auto()
CANCELLED = auto()
FAILED = auto()
__slots__ = (
"_cancelled",
"_exception",
"_finished_event",
"_name",
"_return_value",
)
_return_value: T
def __init__(self, *, name: str | None = None) -> None:
self._finished_event = Event()
self._exception: BaseException | None = None
self._cancelled: bool = False
self._name = name
def _check_pending(self) -> None:
"""Shortcut for checking if a Future is pending
:raises FutureAlreadyFinished: if future was already given a result or exception.
:raises FutureCancelled: if future has been cancelled previously.
"""
match self.status:
case Future.Status.PENDING:
return
case Future.Status.FINISHED:
raise FutureAlreadyFinished("future has already finished")
case Future.Status.FAILED:
raise FutureAlreadyFinished("future already failed")
case Future.Status.CANCELLED:
raise FutureCancelled("future was cancelled")
async def wait(self) -> None:
"""
Waits for the future to finish.
This method will attempt to wait for a result or exception
"""
await self._finished_event.wait()
def cancel(self) -> None:
"""Cancels a pending `.Future` object
Does nothing if the Future was already finished.
"""
if self.status is Future.Status.PENDING:
self._cancelled = True
self._finished_event.set()
@property
def exception(self) -> BaseException | None:
"""
The exception value of a `.Future`
:raises FutureNotFinished: if future is still pending
:raises FutureCancelled: if future was cancelled
:returns: None if future succeeds with a result sent instead
otherwise this will be an exception
"""
match self.status:
case Future.Status.PENDING:
raise FutureNotFinished("the future has not finished yet")
case Future.Status.FINISHED:
return None
case Future.Status.CANCELLED:
raise FutureCancelled("the future was cancelled")
case Future.Status.FAILED:
return self._exception
@exception.setter
def exception(self, exception: BaseException) -> None:
"""Send exception for a `.Future` object
:raises FutureAlreadyFinished: if future was already given a result or exception.
:raises FutureCancelled: if future has been cancelled previously.
"""
self._check_pending()
self._exception = exception
self._finished_event.set()
@property
def return_value(self) -> T:
"""
The return value of the future.
:raises FutureNotFinished: if the future has not finished yet
:raises FutureCancelled: if the future was cancelled
:raises FutureFailed: if the future raised an exception
"""
match self.status:
case Future.Status.PENDING:
raise FutureNotFinished("the future has not finished yet")
case Future.Status.FINISHED:
return self._return_value
case Future.Status.CANCELLED:
raise FutureCancelled("the future was cancelled")
case Future.Status.FAILED:
raise FutureFailed(
"the future raised an exception"
) from self._exception
@return_value.setter
def return_value(self, value: T) -> None:
"""
Send pending result for a `.Future` object
:raises FutureAlreadyFinished: if future was already given a result or exception.
:raises FutureCancelled: if future has been cancelled previously.
"""
self._check_pending()
self._return_value = value
self._finished_event.set()
@property
def status(self) -> Future.Status:
"""
The current status of a future.
Every future starts in the :attr:`~Future.Status.PENDING` state.
If a future is cancelled while in this state, it will transition to the
:attr:`Future.Status.CANCELLED` state. When the task finishes, it will
transition to one of the three final states (
:attr:`Future.Status.FINISHED`, :attr:`Future.Status.FAILED`, or
:attr:`Future.Status.CANCELLED`) depending on the exception the task
raised, if any. No other status transitions will happen.
"""
if not self._finished_event.is_set():
return Future.Status.PENDING
elif self._cancelled:
return Future.Status.CANCELLED
elif self._exception is not None:
return Future.Status.FAILED
else:
return Future.Status.FINISHED
def __await__(self) -> Generator[Any, Any, T]:
yield from self.wait().__await__()
return self.return_value
def __repr__(self) -> str:
return (
f"<{self.__class__.__name__} {self.status.name.lower()} "
f"name={self._name!r}>"
)
@@ -0,0 +1,18 @@
from __future__ import annotations
from ..abc import AsyncResource # noqa: TC001
from ._tasks import CancelScope
async def aclose_forcefully(resource: AsyncResource) -> None:
"""
Close an asynchronous resource in a cancelled scope.
Doing this closes the resource without waiting on anything.
:param resource: the resource to close
"""
with CancelScope() as scope:
scope.cancel()
await resource.aclose()
@@ -0,0 +1,29 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import AbstractContextManager
from signal import Signals
from ._eventloop import get_async_backend
def open_signal_receiver(
*signals: Signals,
) -> AbstractContextManager[AsyncIterator[Signals]]:
"""
Start receiving operating system signals.
:param signals: signals to receive (e.g. ``signal.SIGINT``)
:return: an asynchronous context manager for an asynchronous iterator which yields
signal numbers
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
.. warning:: Windows does not support signals natively so it is best to avoid
relying on this in cross-platform applications.
.. warning:: On asyncio, this permanently replaces any previous signal handler for
the given signals, as set via :meth:`~asyncio.loop.add_signal_handler`.
"""
return get_async_backend().open_signal_receiver(*signals)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,52 @@
from __future__ import annotations
import math
from typing import TypeVar
from warnings import warn
from ..streams.memory import (
MemoryObjectReceiveStream,
MemoryObjectSendStream,
_MemoryObjectStreamState,
)
T_Item = TypeVar("T_Item")
class create_memory_object_stream(
tuple[MemoryObjectSendStream[T_Item], MemoryObjectReceiveStream[T_Item]],
):
"""
Create a memory object stream.
The stream's item type can be annotated like
:func:`create_memory_object_stream[T_Item]`.
:param max_buffer_size: number of items held in the buffer until ``send()`` starts
blocking
:param item_type: old way of marking the streams with the right generic type for
static typing (does nothing on AnyIO 4)
.. deprecated:: 4.0
Use ``create_memory_object_stream[YourItemType](...)`` instead.
:return: a tuple of (send stream, receive stream)
"""
def __new__( # type: ignore[misc]
cls, max_buffer_size: float = 0, item_type: object = None
) -> tuple[MemoryObjectSendStream[T_Item], MemoryObjectReceiveStream[T_Item]]:
if max_buffer_size != math.inf and not isinstance(max_buffer_size, int):
raise ValueError("max_buffer_size must be either an integer or math.inf")
if max_buffer_size < 0:
raise ValueError("max_buffer_size cannot be negative")
if item_type is not None:
warn(
"The item_type argument has been deprecated in AnyIO 4.0. "
"Use create_memory_object_stream[YourItemType](...) instead.",
DeprecationWarning,
stacklevel=2,
)
state = _MemoryObjectStreamState[T_Item](max_buffer_size)
return (MemoryObjectSendStream(state), MemoryObjectReceiveStream(state))
@@ -0,0 +1,196 @@
from __future__ import annotations
from collections.abc import AsyncIterable, Iterable, Mapping, Sequence
from io import BytesIO
from os import PathLike
from subprocess import PIPE, CalledProcessError, CompletedProcess
from typing import IO, Any, TypeAlias, cast
from ..abc import Process # noqa: TC001
from ._eventloop import get_async_backend
from ._tasks import create_task_group
StrOrBytesPath: TypeAlias = str | bytes | PathLike[str] | PathLike[bytes]
async def run_process(
command: StrOrBytesPath | Sequence[StrOrBytesPath],
*,
input: bytes | None = None,
stdin: int | IO[Any] | None = None,
stdout: int | IO[Any] | None = PIPE,
stderr: int | IO[Any] | None = PIPE,
check: bool = True,
cwd: StrOrBytesPath | None = None,
env: Mapping[str, str] | None = None,
startupinfo: Any = None,
creationflags: int = 0,
start_new_session: bool = False,
pass_fds: Sequence[int] = (),
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
) -> CompletedProcess[bytes]:
"""
Run an external command in a subprocess and wait until it completes.
.. seealso:: :func:`subprocess.run`
:param command: either a string to pass to the shell, or an iterable of strings
containing the executable name or path and its arguments
:param input: bytes passed to the standard input of the subprocess
:param stdin: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`,
a file-like object, or `None`; ``input`` overrides this
:param stdout: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`,
a file-like object, or `None`
:param stderr: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`,
:data:`subprocess.STDOUT`, a file-like object, or `None`
:param check: if ``True``, raise :exc:`~subprocess.CalledProcessError` if the
process terminates with a return code other than 0
:param cwd: If not ``None``, change the working directory to this before running the
command
:param env: if not ``None``, this mapping replaces the inherited environment
variables from the parent process
:param startupinfo: an instance of :class:`subprocess.STARTUPINFO` that can be used
to specify process startup parameters (Windows only)
:param creationflags: flags that can be used to control the creation of the
subprocess (see :class:`subprocess.Popen` for the specifics)
:param start_new_session: if ``true`` the setsid() system call will be made in the
child process prior to the execution of the subprocess. (POSIX only)
:param pass_fds: sequence of file descriptors to keep open between the parent and
child processes. (POSIX only)
:param user: effective user to run the process as (Python >= 3.9, POSIX only)
:param group: effective group to run the process as (Python >= 3.9, POSIX only)
:param extra_groups: supplementary groups to set in the subprocess (Python >= 3.9,
POSIX only)
:param umask: if not negative, this umask is applied in the child process before
running the given command (Python >= 3.9, POSIX only)
:return: an object representing the completed process
:raises ~subprocess.CalledProcessError: if ``check`` is ``True`` and the process
exits with a nonzero return code
"""
async def drain_stream(stream: AsyncIterable[bytes], index: int) -> None:
buffer = BytesIO()
async for chunk in stream:
buffer.write(chunk)
stream_contents[index] = buffer.getvalue()
if stdin is not None and input is not None:
raise ValueError("only one of stdin and input is allowed")
async with await open_process(
command,
stdin=PIPE if input else stdin,
stdout=stdout,
stderr=stderr,
cwd=cwd,
env=env,
startupinfo=startupinfo,
creationflags=creationflags,
start_new_session=start_new_session,
pass_fds=pass_fds,
user=user,
group=group,
extra_groups=extra_groups,
umask=umask,
) as process:
stream_contents: list[bytes | None] = [None, None]
async with create_task_group() as tg:
if process.stdout:
tg.start_soon(drain_stream, process.stdout, 0)
if process.stderr:
tg.start_soon(drain_stream, process.stderr, 1)
if process.stdin and input:
await process.stdin.send(input)
await process.stdin.aclose()
await process.wait()
output, errors = stream_contents
if check and process.returncode != 0:
raise CalledProcessError(cast(int, process.returncode), command, output, errors)
return CompletedProcess(command, cast(int, process.returncode), output, errors)
async def open_process(
command: StrOrBytesPath | Sequence[StrOrBytesPath],
*,
stdin: int | IO[Any] | None = PIPE,
stdout: int | IO[Any] | None = PIPE,
stderr: int | IO[Any] | None = PIPE,
cwd: StrOrBytesPath | None = None,
env: Mapping[str, str] | None = None,
startupinfo: Any = None,
creationflags: int = 0,
start_new_session: bool = False,
pass_fds: Sequence[int] = (),
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
) -> Process:
"""
Start an external command in a subprocess.
.. seealso:: :class:`subprocess.Popen`
:param command: either a string to pass to the shell, or an iterable of strings
containing the executable name or path and its arguments
:param stdin: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`, a
file-like object, or ``None``
:param stdout: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`,
a file-like object, or ``None``
:param stderr: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`,
:data:`subprocess.STDOUT`, a file-like object, or ``None``
:param cwd: If not ``None``, the working directory is changed before executing
:param env: If env is not ``None``, it must be a mapping that defines the
environment variables for the new process
:param creationflags: flags that can be used to control the creation of the
subprocess (see :class:`subprocess.Popen` for the specifics)
:param startupinfo: an instance of :class:`subprocess.STARTUPINFO` that can be used
to specify process startup parameters (Windows only)
:param start_new_session: if ``true`` the setsid() system call will be made in the
child process prior to the execution of the subprocess. (POSIX only)
:param pass_fds: sequence of file descriptors to keep open between the parent and
child processes. (POSIX only)
:param user: effective user to run the process as (POSIX only)
:param group: effective group to run the process as (POSIX only)
:param extra_groups: supplementary groups to set in the subprocess (POSIX only)
:param umask: if not negative, this umask is applied in the child process before
running the given command (POSIX only)
:return: an asynchronous process object
"""
kwargs: dict[str, Any] = {}
if user is not None:
kwargs["user"] = user
if group is not None:
kwargs["group"] = group
if extra_groups is not None:
kwargs["extra_groups"] = extra_groups
if umask >= 0:
kwargs["umask"] = umask
return await get_async_backend().open_process(
command,
stdin=stdin,
stdout=stdout,
stderr=stderr,
cwd=cwd,
env=env,
startupinfo=startupinfo,
creationflags=creationflags,
start_new_session=start_new_session,
pass_fds=pass_fds,
**kwargs,
)
@@ -0,0 +1,778 @@
from __future__ import annotations
import math
import sys
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass
from types import TracebackType
from typing import TypeVar
from ..lowlevel import checkpoint_if_cancelled
from ._eventloop import get_async_backend
from ._exceptions import BusyResourceError, NoEventLoopError
from ._tasks import CancelScope
from ._testing import TaskInfo, get_current_task
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
T = TypeVar("T")
@dataclass(frozen=True)
class EventStatistics:
"""
:ivar int tasks_waiting: number of tasks waiting on :meth:`~.Event.wait`
"""
tasks_waiting: int
@dataclass(frozen=True)
class CapacityLimiterStatistics:
"""
:ivar int borrowed_tokens: number of tokens currently borrowed by tasks
:ivar float total_tokens: total number of available tokens
:ivar tuple borrowers: tasks or other objects currently holding tokens borrowed from
this limiter
:ivar int tasks_waiting: number of tasks waiting on
:meth:`~.CapacityLimiter.acquire` or
:meth:`~.CapacityLimiter.acquire_on_behalf_of`
"""
borrowed_tokens: int
total_tokens: float
borrowers: tuple[object, ...]
tasks_waiting: int
@dataclass(frozen=True)
class LockStatistics:
"""
:ivar bool locked: flag indicating if this lock is locked or not
:ivar ~anyio.TaskInfo owner: task currently holding the lock (or ``None`` if the
lock is not held by any task)
:ivar int tasks_waiting: number of tasks waiting on :meth:`~.Lock.acquire`
"""
locked: bool
owner: TaskInfo | None
tasks_waiting: int
@dataclass(frozen=True)
class ConditionStatistics:
"""
:ivar int tasks_waiting: number of tasks blocked on :meth:`~.Condition.wait`
:ivar ~anyio.LockStatistics lock_statistics: statistics of the underlying
:class:`~.Lock`
"""
tasks_waiting: int
lock_statistics: LockStatistics
@dataclass(frozen=True)
class SemaphoreStatistics:
"""
:ivar int tasks_waiting: number of tasks waiting on :meth:`~.Semaphore.acquire`
"""
tasks_waiting: int
class Event:
__slots__ = ("__weakref__",)
def __new__(cls) -> Event:
try:
return get_async_backend().create_event()
except NoEventLoopError:
return EventAdapter()
def set(self) -> None:
"""Set the flag, notifying all listeners."""
raise NotImplementedError
def is_set(self) -> bool:
"""Return ``True`` if the flag is set, ``False`` if not."""
raise NotImplementedError
async def wait(self) -> None:
"""
Wait until the flag has been set.
If the flag has already been set when this method is called, it returns
immediately.
"""
raise NotImplementedError
def statistics(self) -> EventStatistics:
"""Return statistics about the current state of this event."""
raise NotImplementedError
class EventAdapter(Event):
__slots__ = "_internal_event", "_is_set"
def __new__(cls) -> Self:
return object.__new__(cls)
def __init__(self) -> None:
self._internal_event: Event | None = None
self._is_set = False
@property
def _event(self) -> Event:
if self._internal_event is None:
self._internal_event = get_async_backend().create_event()
if self._is_set:
self._internal_event.set()
return self._internal_event
def set(self) -> None:
if self._internal_event is None:
self._is_set = True
else:
self._event.set()
def is_set(self) -> bool:
if self._internal_event is None:
return self._is_set
return self._internal_event.is_set()
async def wait(self) -> None:
await self._event.wait()
def statistics(self) -> EventStatistics:
if self._internal_event is None:
return EventStatistics(tasks_waiting=0)
return self._internal_event.statistics()
class Lock:
__slots__ = ("__weakref__",)
def __new__(cls, *, fast_acquire: bool = False) -> Lock:
try:
return get_async_backend().create_lock(fast_acquire=fast_acquire)
except NoEventLoopError:
return LockAdapter(fast_acquire=fast_acquire)
async def __aenter__(self) -> None:
await self.acquire()
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.release()
async def acquire(self) -> None:
"""Acquire the lock."""
raise NotImplementedError
def acquire_nowait(self) -> None:
"""
Acquire the lock, without blocking.
:raises ~anyio.WouldBlock: if the operation would block
"""
raise NotImplementedError
def release(self) -> None:
"""Release the lock."""
raise NotImplementedError
def locked(self) -> bool:
"""Return True if the lock is currently held."""
raise NotImplementedError
def statistics(self) -> LockStatistics:
"""
Return statistics about the current state of this lock.
.. versionadded:: 3.0
"""
raise NotImplementedError
class LockAdapter(Lock):
__slots__ = "_fast_acquire", "_internal_lock"
def __new__(cls, *, fast_acquire: bool = False) -> Self:
return object.__new__(cls)
def __init__(self, *, fast_acquire: bool = False):
self._internal_lock: Lock | None = None
self._fast_acquire = fast_acquire
@property
def _lock(self) -> Lock:
if self._internal_lock is None:
self._internal_lock = get_async_backend().create_lock(
fast_acquire=self._fast_acquire
)
return self._internal_lock
async def __aenter__(self) -> None:
await self._lock.acquire()
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
if self._internal_lock is not None:
self._internal_lock.release()
async def acquire(self) -> None:
"""Acquire the lock."""
await self._lock.acquire()
def acquire_nowait(self) -> None:
"""
Acquire the lock, without blocking.
:raises ~anyio.WouldBlock: if the operation would block
"""
self._lock.acquire_nowait()
def release(self) -> None:
"""Release the lock."""
self._lock.release()
def locked(self) -> bool:
"""Return True if the lock is currently held."""
return self._lock.locked()
def statistics(self) -> LockStatistics:
"""
Return statistics about the current state of this lock.
.. versionadded:: 3.0
"""
if self._internal_lock is None:
return LockStatistics(False, None, 0)
return self._internal_lock.statistics()
class Condition:
__slots__ = "__weakref__", "_lock", "_owner_task", "_waiters"
def __init__(self, lock: Lock | None = None):
self._owner_task: TaskInfo | None = None
self._lock = lock or Lock()
self._waiters: deque[Event] = deque()
async def __aenter__(self) -> None:
await self.acquire()
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.release()
def _check_acquired(self) -> None:
if self._owner_task != get_current_task():
raise RuntimeError("The current task is not holding the underlying lock")
async def acquire(self) -> None:
"""Acquire the underlying lock."""
await self._lock.acquire()
self._owner_task = get_current_task()
def acquire_nowait(self) -> None:
"""
Acquire the underlying lock, without blocking.
:raises ~anyio.WouldBlock: if the operation would block
"""
self._lock.acquire_nowait()
self._owner_task = get_current_task()
def release(self) -> None:
"""Release the underlying lock."""
self._lock.release()
def locked(self) -> bool:
"""Return True if the lock is set."""
return self._lock.locked()
def notify(self, n: int = 1) -> None:
"""Notify exactly n listeners."""
self._check_acquired()
for _ in range(n):
try:
event = self._waiters.popleft()
except IndexError:
break
event.set()
def notify_all(self) -> None:
"""Notify all the listeners."""
self._check_acquired()
for event in self._waiters:
event.set()
self._waiters.clear()
async def wait(self) -> None:
"""Wait for a notification."""
await checkpoint_if_cancelled()
self._check_acquired()
event = Event()
self._waiters.append(event)
self.release()
try:
await event.wait()
except BaseException:
if not event.is_set():
self._waiters.remove(event)
elif self._waiters:
# This task was notified by could not act on it, so pass
# it on to the next task
self._waiters.popleft().set()
raise
finally:
with CancelScope(shield=True):
await self.acquire()
async def wait_for(self, predicate: Callable[[], T]) -> T:
"""
Wait until a predicate becomes true.
:param predicate: a callable that returns a truthy value when the condition is
met
:return: the result of the predicate
.. versionadded:: 4.11.0
"""
while not (result := predicate()):
await self.wait()
return result
def statistics(self) -> ConditionStatistics:
"""
Return statistics about the current state of this condition.
.. versionadded:: 3.0
"""
return ConditionStatistics(len(self._waiters), self._lock.statistics())
class Semaphore:
__slots__ = "__weakref__", "_fast_acquire"
def __new__(
cls,
initial_value: int,
*,
max_value: int | None = None,
fast_acquire: bool = False,
) -> Semaphore:
try:
return get_async_backend().create_semaphore(
initial_value, max_value=max_value, fast_acquire=fast_acquire
)
except NoEventLoopError:
return SemaphoreAdapter(initial_value, max_value=max_value)
def __init__(
self,
initial_value: int,
*,
max_value: int | None = None,
fast_acquire: bool = False,
):
if not isinstance(initial_value, int):
raise TypeError("initial_value must be an integer")
if initial_value < 0:
raise ValueError("initial_value must be >= 0")
if max_value is not None:
if not isinstance(max_value, int):
raise TypeError("max_value must be an integer or None")
if max_value < initial_value:
raise ValueError(
"max_value must be equal to or higher than initial_value"
)
self._fast_acquire = fast_acquire
async def __aenter__(self) -> Self:
await self.acquire()
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.release()
async def acquire(self) -> None:
"""Decrement the semaphore value, blocking if necessary."""
raise NotImplementedError
def acquire_nowait(self) -> None:
"""
Acquire the underlying lock, without blocking.
:raises ~anyio.WouldBlock: if the operation would block
"""
raise NotImplementedError
def release(self) -> None:
"""Increment the semaphore value."""
raise NotImplementedError
@property
def value(self) -> int:
"""The current value of the semaphore."""
raise NotImplementedError
@property
def max_value(self) -> int | None:
"""The maximum value of the semaphore."""
raise NotImplementedError
def statistics(self) -> SemaphoreStatistics:
"""
Return statistics about the current state of this semaphore.
.. versionadded:: 3.0
"""
raise NotImplementedError
class SemaphoreAdapter(Semaphore):
__slots__ = "_initial_value", "_internal_semaphore", "_max_value"
def __new__(
cls,
initial_value: int,
*,
max_value: int | None = None,
fast_acquire: bool = False,
) -> Self:
return object.__new__(cls)
def __init__(
self,
initial_value: int,
*,
max_value: int | None = None,
fast_acquire: bool = False,
) -> None:
super().__init__(initial_value, max_value=max_value, fast_acquire=fast_acquire)
self._internal_semaphore: Semaphore | None = None
self._initial_value = initial_value
self._max_value = max_value
@property
def _semaphore(self) -> Semaphore:
if self._internal_semaphore is None:
self._internal_semaphore = get_async_backend().create_semaphore(
self._initial_value, max_value=self._max_value
)
return self._internal_semaphore
async def acquire(self) -> None:
await self._semaphore.acquire()
def acquire_nowait(self) -> None:
self._semaphore.acquire_nowait()
def release(self) -> None:
self._semaphore.release()
@property
def value(self) -> int:
if self._internal_semaphore is None:
return self._initial_value
return self._semaphore.value
@property
def max_value(self) -> int | None:
return self._max_value
def statistics(self) -> SemaphoreStatistics:
if self._internal_semaphore is None:
return SemaphoreStatistics(tasks_waiting=0)
return self._semaphore.statistics()
class CapacityLimiter:
__slots__ = ("__weakref__",)
def __new__(cls, total_tokens: float) -> CapacityLimiter:
try:
return get_async_backend().create_capacity_limiter(total_tokens)
except NoEventLoopError:
return CapacityLimiterAdapter(total_tokens)
async def __aenter__(self) -> None:
raise NotImplementedError
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
raise NotImplementedError
@property
def total_tokens(self) -> float:
"""
The total number of tokens available for borrowing.
This is a read-write property. If the total number of tokens is increased, the
proportionate number of tasks waiting on this limiter will be granted their
tokens.
.. versionchanged:: 3.0
The property is now writable.
.. versionchanged:: 4.12
The value can now be set to 0.
"""
raise NotImplementedError
@total_tokens.setter
def total_tokens(self, value: float) -> None:
raise NotImplementedError
@property
def borrowed_tokens(self) -> int:
"""The number of tokens that have currently been borrowed."""
raise NotImplementedError
@property
def available_tokens(self) -> float:
"""The number of tokens currently available to be borrowed"""
raise NotImplementedError
def acquire_nowait(self) -> None:
"""
Acquire a token for the current task without waiting for one to become
available.
:raises ~anyio.WouldBlock: if there are no tokens available for borrowing
"""
raise NotImplementedError
def acquire_on_behalf_of_nowait(self, borrower: object) -> None:
"""
Acquire a token without waiting for one to become available.
:param borrower: the entity borrowing a token
:raises ~anyio.WouldBlock: if there are no tokens available for borrowing
"""
raise NotImplementedError
async def acquire(self) -> None:
"""
Acquire a token for the current task, waiting if necessary for one to become
available.
"""
raise NotImplementedError
async def acquire_on_behalf_of(self, borrower: object) -> None:
"""
Acquire a token, waiting if necessary for one to become available.
:param borrower: the entity borrowing a token
"""
raise NotImplementedError
def release(self) -> None:
"""
Release the token held by the current task.
:raises RuntimeError: if the current task has not borrowed a token from this
limiter.
"""
raise NotImplementedError
def release_on_behalf_of(self, borrower: object) -> None:
"""
Release the token held by the given borrower.
:raises RuntimeError: if the borrower has not borrowed a token from this
limiter.
"""
raise NotImplementedError
def statistics(self) -> CapacityLimiterStatistics:
"""
Return statistics about the current state of this limiter.
.. versionadded:: 3.0
"""
raise NotImplementedError
class CapacityLimiterAdapter(CapacityLimiter):
__slots__ = "_internal_limiter", "_total_tokens"
def __new__(cls, total_tokens: float) -> Self:
return object.__new__(cls)
def __init__(self, total_tokens: float) -> None:
self._internal_limiter: CapacityLimiter | None = None
self.total_tokens = total_tokens
@property
def _limiter(self) -> CapacityLimiter:
if self._internal_limiter is None:
self._internal_limiter = get_async_backend().create_capacity_limiter(
self._total_tokens
)
return self._internal_limiter
async def __aenter__(self) -> None:
await self._limiter.__aenter__()
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
return await self._limiter.__aexit__(exc_type, exc_val, exc_tb)
@property
def total_tokens(self) -> float:
if self._internal_limiter is None:
return self._total_tokens
return self._internal_limiter.total_tokens
@total_tokens.setter
def total_tokens(self, value: float) -> None:
if not isinstance(value, int) and not math.isinf(value):
raise TypeError("total_tokens must be an int or math.inf")
elif value < 0:
raise ValueError("total_tokens must be >= 0")
if self._internal_limiter is None:
self._total_tokens = value
return
self._limiter.total_tokens = value
@property
def borrowed_tokens(self) -> int:
if self._internal_limiter is None:
return 0
return self._internal_limiter.borrowed_tokens
@property
def available_tokens(self) -> float:
if self._internal_limiter is None:
return self._total_tokens
return self._internal_limiter.available_tokens
def acquire_nowait(self) -> None:
self._limiter.acquire_nowait()
def acquire_on_behalf_of_nowait(self, borrower: object) -> None:
self._limiter.acquire_on_behalf_of_nowait(borrower)
async def acquire(self) -> None:
await self._limiter.acquire()
async def acquire_on_behalf_of(self, borrower: object) -> None:
await self._limiter.acquire_on_behalf_of(borrower)
def release(self) -> None:
self._limiter.release()
def release_on_behalf_of(self, borrower: object) -> None:
self._limiter.release_on_behalf_of(borrower)
def statistics(self) -> CapacityLimiterStatistics:
if self._internal_limiter is None:
return CapacityLimiterStatistics(
borrowed_tokens=0,
total_tokens=self.total_tokens,
borrowers=(),
tasks_waiting=0,
)
return self._internal_limiter.statistics()
class ResourceGuard:
"""
A context manager for ensuring that a resource is only used by a single task at a
time.
Entering this context manager while the previous has not exited it yet will trigger
:exc:`BusyResourceError`.
:param action: the action to guard against (visible in the :exc:`BusyResourceError`
when triggered, e.g. "Another task is already {action} this resource")
.. versionadded:: 4.1
"""
__slots__ = "__weakref__", "_guarded", "action"
def __init__(self, action: str = "using"):
self.action: str = action
self._guarded = False
def __enter__(self) -> None:
if self._guarded:
raise BusyResourceError(self.action)
self._guarded = True
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self._guarded = False
@@ -0,0 +1,464 @@
from __future__ import annotations
import math
import sys
from collections.abc import (
Coroutine,
Generator,
)
from contextlib import contextmanager
from enum import Enum, auto
from inspect import iscoroutine
from types import TracebackType
from typing import Any, Generic, final
from ..abc import TaskGroup, TaskStatus
from ._eventloop import get_async_backend, get_cancelled_exc_class
from ._exceptions import TaskCancelled, TaskFailed, TaskNotFinished
if sys.version_info >= (3, 13):
from typing import TypeVar
else:
from typing_extensions import TypeVar
if sys.version_info >= (3, 11):
from typing import Never, Self, TypeVarTuple
else:
from typing_extensions import Never, Self, TypeVarTuple
T = TypeVar("T")
T_co = TypeVar("T_co", covariant=True)
T_startval_co = TypeVar("T_startval_co", covariant=True, default=Never)
PosArgsT = TypeVarTuple("PosArgsT")
class _IgnoredTaskStatus(TaskStatus[object]):
def started(self, value: object = None) -> None:
pass
TASK_STATUS_IGNORED = _IgnoredTaskStatus()
class CancelScope:
"""
Wraps a unit of work that can be made separately cancellable.
:param deadline: The time (clock value) when this scope is cancelled automatically
:param shield: ``True`` to shield the cancel scope from external cancellation
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
"""
__slots__ = ("__weakref__",)
def __new__(
cls, *, deadline: float = math.inf, shield: bool = False
) -> CancelScope:
return get_async_backend().create_cancel_scope(shield=shield, deadline=deadline)
def cancel(self, reason: str | None = None) -> None:
"""
Cancel this scope immediately.
:param reason: a message describing the reason for the cancellation
"""
raise NotImplementedError
@property
def deadline(self) -> float:
"""
The time (clock value) when this scope is cancelled automatically.
Will be ``float('inf')`` if no timeout has been set.
"""
raise NotImplementedError
@deadline.setter
def deadline(self, value: float) -> None:
raise NotImplementedError
@property
def cancel_called(self) -> bool:
"""``True`` if :meth:`cancel` has been called."""
raise NotImplementedError
@property
def cancelled_caught(self) -> bool:
"""
``True`` if this scope suppressed a cancellation exception it itself raised.
This is typically used to check if any work was interrupted, or to see if the
scope was cancelled due to its deadline being reached. The value will, however,
only be ``True`` if the cancellation was triggered by the scope itself (and not
an outer scope).
"""
raise NotImplementedError
@property
def shield(self) -> bool:
"""
``True`` if this scope is shielded from external cancellation.
While a scope is shielded, it will not receive cancellations from outside.
"""
raise NotImplementedError
@shield.setter
def shield(self, value: bool) -> None:
raise NotImplementedError
def __enter__(self) -> Self:
raise NotImplementedError
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> bool:
raise NotImplementedError
@contextmanager
def fail_at(
deadline: float | None, shield: bool = False, reason: str | None = None
) -> Generator[CancelScope, None, None]:
"""
Create a context manager which raises a :class:`TimeoutError` if the code in the
enclosing context does not finish by the given deadline.
:param deadline: the deadline before raising the exception, or
``None`` to disable the timeout
:param shield: ``True`` to shield the cancel scope from external cancellation
:param reason: explanation for timeout to add to the message of a raised `TimeoutError`
:return: a context manager that yields a cancel scope
:rtype: :class:`~typing.ContextManager`\\[:class:`~anyio.CancelScope`\\]
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
.. versionadded:: 4.15.0
"""
current_time = get_async_backend().current_time
effective_deadline = math.inf if deadline is None else deadline
with get_async_backend().create_cancel_scope(
deadline=effective_deadline, shield=shield
) as cancel_scope:
yield cancel_scope
if cancel_scope.cancelled_caught and current_time() >= cancel_scope.deadline:
raise TimeoutError(reason) if reason else TimeoutError
@contextmanager
def fail_after(
delay: float | None, shield: bool = False, reason: str | None = None
) -> Generator[CancelScope, None, None]:
"""
Create a context manager which raises a :class:`TimeoutError` if the code in the
enclosing context block does not finish in time.
:param delay: maximum allowed time (in seconds) before raising the exception, or
``None`` to disable the timeout
:param shield: ``True`` to shield the cancel scope from external cancellation
:param reason: explanation for timeout to add to the message of a raised `TimeoutError`
:return: a context manager that yields a cancel scope
:rtype: :class:`~typing.ContextManager`\\[:class:`~anyio.CancelScope`\\]
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
"""
current_time = get_async_backend().current_time
deadline = (current_time() + delay) if delay is not None else math.inf
with fail_at(deadline, shield=shield, reason=reason) as scope:
yield scope
def move_on_at(deadline: float | None, shield: bool = False) -> CancelScope:
"""
Create a cancel scope with a deadline that expires after the given delay.
:param deadline: the deadline before exiting the context block, or
``None`` to disable the timeout
:param shield: ``True`` to shield the cancel scope from external cancellation
:return: a cancel scope
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
.. versionadded:: 4.15.0
"""
return get_async_backend().create_cancel_scope(
deadline=deadline if deadline is not None else math.inf, shield=shield
)
def move_on_after(delay: float | None, shield: bool = False) -> CancelScope:
"""
Create a cancel scope with a deadline that expires after the given delay.
:param delay: maximum allowed time (in seconds) before exiting the context block, or
``None`` to disable the timeout
:param shield: ``True`` to shield the cancel scope from external cancellation
:return: a cancel scope
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
.. note:: Unlike with :func:`fail_after`, the timer starts when this function is
called, not when the context manager is entered. This will be changed in v5.0.
"""
deadline = (
(get_async_backend().current_time() + delay) if delay is not None else math.inf
)
return get_async_backend().create_cancel_scope(deadline=deadline, shield=shield)
def current_effective_deadline() -> float:
"""
Return the nearest deadline among all the cancel scopes effective for the current
task.
:return: a clock value from the event loop's internal clock (or ``float('inf')`` if
there is no deadline in effect, or ``float('-inf')`` if the current scope has
been cancelled)
:rtype: float
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
"""
return get_async_backend().current_effective_deadline()
def create_task_group() -> TaskGroup:
"""
Create a task group.
:return: a task group
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
"""
return get_async_backend().create_task_group()
@final
class TaskHandle(Generic[T_co, T_startval_co]):
"""
Returned from the task-spawning methods of :class:`TaskGroup`. Can be awaited on to
get the return value of the task (or the raised exception). If the task was
terminated by a :exc:`BaseException`, :exc:`TaskFailed` will be raised (or its
subclass :exc:`TaskCancelled` if the task was cancelled).
.. versionadded:: 4.14.0
"""
class Status(Enum):
"""
The status of a task handle.
.. attribute:: PENDING
The task has not finished yet.
.. attribute:: FINISHED
The task has finished with a return value.
.. attribute:: CANCELLING
The task has been cancelled but has not finished yet.
.. attribute:: CANCELLED
The task was cancelled and has finished since.
.. attribute:: FAILED
The task raised an exception.
"""
PENDING = auto()
FINISHED = auto()
CANCELLING = auto()
CANCELLED = auto()
FAILED = auto()
__slots__ = (
"__weakref__",
"_cancel_scope",
"_coro",
"_exception",
"_finished_event",
"_name",
"_return_value",
"_start_value",
)
_return_value: T_co
_start_value: T_startval_co
def __init__(
self,
coro: Coroutine[Any, Any, T_co],
name: object,
cancel_scope: CancelScope | None = None,
) -> None:
from ._synchronization import Event
self._coro = coro
self._cancel_scope = cancel_scope if cancel_scope is not None else CancelScope()
self._finished_event = Event()
self._exception: BaseException | None = None
if name is not None:
self._name = str(name)
elif iscoroutine(coro):
self._name = coro.__qualname__
else:
self._name = str(coro) # coroutine-like object (e.g. asend() objects)
async def _run_coro(self) -> None:
__tracebackhide__ = True
with self._cancel_scope:
try:
retval = await self._coro
except BaseException as exc:
self._exception = exc
raise
else:
self._return_value = retval
finally:
self._finished_event.set()
del self # Break the reference cycle
def cancel(self) -> None:
"""
Set the task to a cancelled state.
This will interrupt any interruptible asynchronous operation, and will cause
any further awaits on this task to get immediately cancelled, unless done in
a shielded cancel scope.
If the task has already finished, this method has no effect.
"""
if not self._finished_event.is_set():
self._cancel_scope.cancel()
@property
def coro(self) -> Coroutine[Any, Any, T_co]:
"""
The coroutine object that was passed to one of the task-spawning methods in
:class:`TaskGroup`.
"""
return self._coro
@property
def status(self) -> TaskHandle.Status:
"""
The current status of the task.
Every task starts in the :attr:`~TaskHandle.Status.PENDING` state.
If a task is cancelled while in this state, it will transition to the
:attr:`~TaskHandle.Status.CANCELLING` state. When the task finishes, it will
transition to one of the three final states (
:attr:`~TaskHandle.Status.FINISHED`, :attr:`~TaskHandle.Status.FAILED`, or
:attr:`~TaskHandle.Status.CANCELLING`) depending on the exception the task
raised, if any. No other status transitions will happen.
"""
if not self._finished_event.is_set():
if self._cancel_scope.cancel_called:
return TaskHandle.Status.CANCELLING
else:
return TaskHandle.Status.PENDING
elif self._exception is not None:
if isinstance(self._exception, get_cancelled_exc_class()):
return TaskHandle.Status.CANCELLED
else:
return TaskHandle.Status.FAILED
else:
return TaskHandle.Status.FINISHED
@property
def name(self) -> str:
"""The name of the task."""
return self._name
@property
def exception(self) -> BaseException | None:
"""
The exception raised by the task, or ``None`` if it finished without raising.
:raises TaskNotFinished: if the task has not finished yet
:raises TaskCancelled: if the task was cancelled
"""
match self.status:
case TaskHandle.Status.PENDING:
raise TaskNotFinished("the task has not finished yet")
case TaskHandle.Status.FINISHED:
return None
case TaskHandle.Status.CANCELLING:
raise TaskCancelled("the task was cancelled")
case TaskHandle.Status.CANCELLED:
raise TaskCancelled("the task was cancelled") from self._exception
case TaskHandle.Status.FAILED:
return self._exception
@property
def return_value(self) -> T_co:
"""
The return value of the task.
:raises TaskNotFinished: if the task has not finished yet
:raises TaskCancelled: if the task was cancelled
:raises TaskFailed: if the task raised an exception
"""
match self.status:
case TaskHandle.Status.PENDING:
raise TaskNotFinished("the task has not finished yet")
case TaskHandle.Status.FINISHED:
return self._return_value
case TaskHandle.Status.CANCELLING:
raise TaskCancelled("the task was cancelled")
case TaskHandle.Status.CANCELLED:
raise TaskCancelled("the task was cancelled") from self._exception
case TaskHandle.Status.FAILED:
raise TaskFailed("the task raised an exception") from self._exception
@property
def start_value(self) -> T_startval_co:
"""
The value passed to :meth:`task_status.started() <.abc.TaskStatus.started>`,
:raises RuntimeError: if the task was not started with :meth:`TaskGroup.start()
<.abc.TaskGroup.start>`
"""
try:
return self._start_value
except AttributeError:
raise RuntimeError(
"the task was not started with TaskGroup.start()"
) from None
async def wait(self) -> None:
"""
Wait for the task to finish.
This method will return as soon as the task has finished, no matter how it
happened.
"""
await self._finished_event.wait()
def __await__(self) -> Generator[Any, Any, T_co]:
yield from self._finished_event.wait().__await__()
return self.return_value
def __repr__(self) -> str:
return (
f"<{self.__class__.__name__} {self.status.name.lower()} "
f"name={self._name!r} coro={self._coro!r}>"
)
@@ -0,0 +1,615 @@
from __future__ import annotations
import os
import sys
import tempfile
from collections.abc import Iterable
from io import BytesIO, TextIOWrapper
from types import TracebackType
from typing import (
TYPE_CHECKING,
Any,
AnyStr,
Generic,
overload,
)
from .. import to_thread
from .._core._fileio import AsyncFile
from ..lowlevel import checkpoint_if_cancelled
from ._tasks import CancelScope
if TYPE_CHECKING:
from _typeshed import OpenBinaryMode, OpenTextMode, ReadableBuffer, WriteableBuffer
class TemporaryFile(Generic[AnyStr]):
"""
An asynchronous temporary file that is automatically created and cleaned up.
This class provides an asynchronous context manager interface to a temporary file.
The file is created using Python's standard `tempfile.TemporaryFile` function in a
background thread, and is wrapped as an asynchronous file using `AsyncFile`.
:param mode: The mode in which the file is opened. Defaults to "w+b".
:param buffering: The buffering policy (-1 means the default buffering).
:param encoding: The encoding used to decode or encode the file. Only applicable in
text mode.
:param newline: Controls how universal newlines mode works (only applicable in text
mode).
:param suffix: The suffix for the temporary file name.
:param prefix: The prefix for the temporary file name.
:param dir: The directory in which the temporary file is created.
:param errors: The error handling scheme used for encoding/decoding errors.
"""
_async_file: AsyncFile[AnyStr]
@overload
def __init__(
self: TemporaryFile[bytes],
mode: OpenBinaryMode = ...,
buffering: int = ...,
encoding: str | None = ...,
newline: str | None = ...,
suffix: str | None = ...,
prefix: str | None = ...,
dir: str | None = ...,
*,
errors: str | None = ...,
): ...
@overload
def __init__(
self: TemporaryFile[str],
mode: OpenTextMode,
buffering: int = ...,
encoding: str | None = ...,
newline: str | None = ...,
suffix: str | None = ...,
prefix: str | None = ...,
dir: str | None = ...,
*,
errors: str | None = ...,
): ...
def __init__(
self,
mode: OpenTextMode | OpenBinaryMode = "w+b",
buffering: int = -1,
encoding: str | None = None,
newline: str | None = None,
suffix: str | None = None,
prefix: str | None = None,
dir: str | None = None,
*,
errors: str | None = None,
) -> None:
self.mode = mode
self.buffering = buffering
self.encoding = encoding
self.newline = newline
self.suffix: str | None = suffix
self.prefix: str | None = prefix
self.dir: str | None = dir
self.errors = errors
async def __aenter__(self) -> AsyncFile[AnyStr]:
fp = await to_thread.run_sync(
lambda: tempfile.TemporaryFile(
self.mode,
self.buffering,
self.encoding,
self.newline,
self.suffix,
self.prefix,
self.dir,
errors=self.errors,
)
)
self._async_file = AsyncFile(fp)
return self._async_file
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
await self._async_file.aclose()
class NamedTemporaryFile(Generic[AnyStr]):
"""
An asynchronous named temporary file that is automatically created and cleaned up.
This class provides an asynchronous context manager for a temporary file with a
visible name in the file system. It uses Python's standard
:func:`~tempfile.NamedTemporaryFile` function and wraps the file object with
:class:`AsyncFile` for asynchronous operations.
:param mode: The mode in which the file is opened. Defaults to "w+b".
:param buffering: The buffering policy (-1 means the default buffering).
:param encoding: The encoding used to decode or encode the file. Only applicable in
text mode.
:param newline: Controls how universal newlines mode works (only applicable in text
mode).
:param suffix: The suffix for the temporary file name.
:param prefix: The prefix for the temporary file name.
:param dir: The directory in which the temporary file is created.
:param delete: Whether to delete the file when it is closed.
:param errors: The error handling scheme used for encoding/decoding errors.
:param delete_on_close: (Python 3.12+) Whether to delete the file on close.
"""
_async_file: AsyncFile[AnyStr]
@overload
def __init__(
self: NamedTemporaryFile[bytes],
mode: OpenBinaryMode = ...,
buffering: int = ...,
encoding: str | None = ...,
newline: str | None = ...,
suffix: str | None = ...,
prefix: str | None = ...,
dir: str | None = ...,
delete: bool = ...,
*,
errors: str | None = ...,
delete_on_close: bool = ...,
): ...
@overload
def __init__(
self: NamedTemporaryFile[str],
mode: OpenTextMode,
buffering: int = ...,
encoding: str | None = ...,
newline: str | None = ...,
suffix: str | None = ...,
prefix: str | None = ...,
dir: str | None = ...,
delete: bool = ...,
*,
errors: str | None = ...,
delete_on_close: bool = ...,
): ...
def __init__(
self,
mode: OpenBinaryMode | OpenTextMode = "w+b",
buffering: int = -1,
encoding: str | None = None,
newline: str | None = None,
suffix: str | None = None,
prefix: str | None = None,
dir: str | None = None,
delete: bool = True,
*,
errors: str | None = None,
delete_on_close: bool = True,
) -> None:
self._params: dict[str, Any] = {
"mode": mode,
"buffering": buffering,
"encoding": encoding,
"newline": newline,
"suffix": suffix,
"prefix": prefix,
"dir": dir,
"delete": delete,
"errors": errors,
}
if sys.version_info >= (3, 12):
self._params["delete_on_close"] = delete_on_close
async def __aenter__(self) -> AsyncFile[AnyStr]:
fp = await to_thread.run_sync(
lambda: tempfile.NamedTemporaryFile(**self._params)
)
self._async_file = AsyncFile(fp)
return self._async_file
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
await self._async_file.aclose()
class SpooledTemporaryFile(AsyncFile[AnyStr]):
"""
An asynchronous spooled temporary file that starts in memory and is spooled to disk.
This class provides an asynchronous interface to a spooled temporary file, much like
Python's standard :class:`~tempfile.SpooledTemporaryFile`. It supports asynchronous
write operations and provides a method to force a rollover to disk.
:param max_size: Maximum size in bytes before the file is rolled over to disk.
:param mode: The mode in which the file is opened. Defaults to "w+b".
:param buffering: The buffering policy (-1 means the default buffering).
:param encoding: The encoding used to decode or encode the file (text mode only).
:param newline: Controls how universal newlines mode works (text mode only).
:param suffix: The suffix for the temporary file name.
:param prefix: The prefix for the temporary file name.
:param dir: The directory in which the temporary file is created.
:param errors: The error handling scheme used for encoding/decoding errors.
"""
_rolled: bool = False
@overload
def __init__(
self: SpooledTemporaryFile[bytes],
max_size: int = ...,
mode: OpenBinaryMode = ...,
buffering: int = ...,
encoding: str | None = ...,
newline: str | None = ...,
suffix: str | None = ...,
prefix: str | None = ...,
dir: str | None = ...,
*,
errors: str | None = ...,
): ...
@overload
def __init__(
self: SpooledTemporaryFile[str],
max_size: int = ...,
mode: OpenTextMode = ...,
buffering: int = ...,
encoding: str | None = ...,
newline: str | None = ...,
suffix: str | None = ...,
prefix: str | None = ...,
dir: str | None = ...,
*,
errors: str | None = ...,
): ...
def __init__(
self,
max_size: int = 0,
mode: OpenBinaryMode | OpenTextMode = "w+b",
buffering: int = -1,
encoding: str | None = None,
newline: str | None = None,
suffix: str | None = None,
prefix: str | None = None,
dir: str | None = None,
*,
errors: str | None = None,
) -> None:
self._tempfile_params: dict[str, Any] = {
"mode": mode,
"buffering": buffering,
"encoding": encoding,
"newline": newline,
"suffix": suffix,
"prefix": prefix,
"dir": dir,
"errors": errors,
}
self._max_size = max_size
if "b" in mode:
super().__init__(BytesIO()) # type: ignore[arg-type]
else:
super().__init__(
TextIOWrapper( # type: ignore[arg-type]
BytesIO(),
encoding=encoding,
errors=errors,
newline=newline,
write_through=True,
)
)
async def aclose(self) -> None:
if not self._rolled:
self._fp.close()
return
await super().aclose()
async def _check(self) -> None:
if self._rolled or self._fp.tell() <= self._max_size:
return
await self.rollover()
async def rollover(self) -> None:
if self._rolled:
return
self._rolled = True
buffer = self._fp
buffer.seek(0)
self._fp = await to_thread.run_sync(
lambda: tempfile.TemporaryFile(**self._tempfile_params)
)
await self.write(buffer.read())
buffer.close()
@property
def closed(self) -> bool:
return self._fp.closed
async def read(self, size: int = -1) -> AnyStr:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.read(size)
return await super().read(size) # type: ignore[return-value]
async def read1(self: SpooledTemporaryFile[bytes], size: int = -1) -> bytes:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.read1(size)
return await super().read1(size)
async def readline(self) -> AnyStr:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.readline()
return await super().readline() # type: ignore[return-value]
async def readlines(self) -> list[AnyStr]:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.readlines()
return await super().readlines() # type: ignore[return-value]
async def readinto(self: SpooledTemporaryFile[bytes], b: WriteableBuffer) -> int:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.readinto(b)
return await super().readinto(b)
async def readinto1(self: SpooledTemporaryFile[bytes], b: WriteableBuffer) -> int:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.readinto1(b)
return await super().readinto1(b)
async def seek(self, offset: int, whence: int | None = os.SEEK_SET) -> int:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.seek(offset, whence)
return await super().seek(offset, whence)
async def tell(self) -> int:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.tell()
return await super().tell()
async def truncate(self, size: int | None = None) -> int:
if not self._rolled:
await checkpoint_if_cancelled()
return self._fp.truncate(size)
return await super().truncate(size)
@overload
async def write(self: SpooledTemporaryFile[bytes], b: ReadableBuffer) -> int: ...
@overload
async def write(self: SpooledTemporaryFile[str], b: str) -> int: ...
async def write(self, b: ReadableBuffer | str) -> int:
"""
Asynchronously write data to the spooled temporary file.
If the file has not yet been rolled over, the data is written synchronously,
and a rollover is triggered if the size exceeds the maximum size.
:param s: The data to write.
:return: The number of bytes written.
:raises RuntimeError: If the underlying file is not initialized.
"""
if not self._rolled:
await checkpoint_if_cancelled()
result = self._fp.write(b)
await self._check()
return result
return await super().write(b) # type: ignore[misc]
@overload
async def writelines(
self: SpooledTemporaryFile[bytes], lines: Iterable[ReadableBuffer]
) -> None: ...
@overload
async def writelines(
self: SpooledTemporaryFile[str], lines: Iterable[str]
) -> None: ...
async def writelines(self, lines: Iterable[str] | Iterable[ReadableBuffer]) -> None:
"""
Asynchronously write a list of lines to the spooled temporary file.
If the file has not yet been rolled over, the lines are written synchronously,
and a rollover is triggered if the size exceeds the maximum size.
:param lines: An iterable of lines to write.
:raises RuntimeError: If the underlying file is not initialized.
"""
if not self._rolled:
await checkpoint_if_cancelled()
result = self._fp.writelines(lines)
await self._check()
return result
return await super().writelines(lines) # type: ignore[misc]
class TemporaryDirectory(Generic[AnyStr]):
"""
An asynchronous temporary directory that is created and cleaned up automatically.
This class provides an asynchronous context manager for creating a temporary
directory. It wraps Python's standard :class:`~tempfile.TemporaryDirectory` to
perform directory creation and cleanup operations in a background thread.
:param suffix: Suffix to be added to the temporary directory name.
:param prefix: Prefix to be added to the temporary directory name.
:param dir: The parent directory where the temporary directory is created.
:param ignore_cleanup_errors: Whether to ignore errors during cleanup
:param delete: Whether to delete the directory upon closing (Python 3.12+).
"""
def __init__(
self,
suffix: AnyStr | None = None,
prefix: AnyStr | None = None,
dir: AnyStr | None = None,
*,
ignore_cleanup_errors: bool = False,
delete: bool = True,
) -> None:
self.suffix: AnyStr | None = suffix
self.prefix: AnyStr | None = prefix
self.dir: AnyStr | None = dir
self.ignore_cleanup_errors = ignore_cleanup_errors
self.delete = delete
self._tempdir: tempfile.TemporaryDirectory | None = None
async def __aenter__(self) -> str:
params: dict[str, Any] = {
"suffix": self.suffix,
"prefix": self.prefix,
"dir": self.dir,
"ignore_cleanup_errors": self.ignore_cleanup_errors,
}
if sys.version_info >= (3, 12):
params["delete"] = self.delete
self._tempdir = await to_thread.run_sync(
lambda: tempfile.TemporaryDirectory(**params)
)
return await to_thread.run_sync(self._tempdir.__enter__)
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
if self._tempdir is not None:
with CancelScope(shield=True):
await to_thread.run_sync(
self._tempdir.__exit__, exc_type, exc_value, traceback
)
async def cleanup(self) -> None:
if self._tempdir is not None:
await to_thread.run_sync(self._tempdir.cleanup)
@overload
async def mkstemp(
suffix: str | None = None,
prefix: str | None = None,
dir: str | None = None,
text: bool = False,
) -> tuple[int, str]: ...
@overload
async def mkstemp(
suffix: bytes | None = None,
prefix: bytes | None = None,
dir: bytes | None = None,
text: bool = False,
) -> tuple[int, bytes]: ...
async def mkstemp(
suffix: AnyStr | None = None,
prefix: AnyStr | None = None,
dir: AnyStr | None = None,
text: bool = False,
) -> tuple[int, str | bytes]:
"""
Asynchronously create a temporary file and return an OS-level handle and the file
name.
This function wraps `tempfile.mkstemp` and executes it in a background thread.
:param suffix: Suffix to be added to the file name.
:param prefix: Prefix to be added to the file name.
:param dir: Directory in which the temporary file is created.
:param text: Whether the file is opened in text mode.
:return: A tuple containing the file descriptor and the file name.
"""
return await to_thread.run_sync(tempfile.mkstemp, suffix, prefix, dir, text)
@overload
async def mkdtemp(
suffix: str | None = None,
prefix: str | None = None,
dir: str | None = None,
) -> str: ...
@overload
async def mkdtemp(
suffix: bytes | None = None,
prefix: bytes | None = None,
dir: bytes | None = None,
) -> bytes: ...
async def mkdtemp(
suffix: AnyStr | None = None,
prefix: AnyStr | None = None,
dir: AnyStr | None = None,
) -> str | bytes:
"""
Asynchronously create a temporary directory and return its path.
This function wraps `tempfile.mkdtemp` and executes it in a background thread.
:param suffix: Suffix to be added to the directory name.
:param prefix: Prefix to be added to the directory name.
:param dir: Parent directory where the temporary directory is created.
:return: The path of the created temporary directory.
"""
return await to_thread.run_sync(tempfile.mkdtemp, suffix, prefix, dir)
async def gettempdir() -> str:
"""
Asynchronously return the name of the directory used for temporary files.
This function wraps `tempfile.gettempdir` and executes it in a background thread.
:return: The path of the temporary directory as a string.
"""
return await to_thread.run_sync(tempfile.gettempdir)
async def gettempdirb() -> bytes:
"""
Asynchronously return the name of the directory used for temporary files in bytes.
This function wraps `tempfile.gettempdirb` and executes it in a background thread.
:return: The path of the temporary directory as bytes.
"""
return await to_thread.run_sync(tempfile.gettempdirb)
@@ -0,0 +1,82 @@
from __future__ import annotations
from collections.abc import Awaitable, Generator
from typing import Any, cast
from ._eventloop import get_async_backend
class TaskInfo:
"""
Represents an asynchronous task.
:ivar int id: the unique identifier of the task
:ivar parent_id: the identifier of the parent task, if any
:vartype parent_id: Optional[int]
:ivar str name: the description of the task (if any)
:ivar ~collections.abc.Coroutine coro: the coroutine object of the task
"""
__slots__ = "_name", "coro", "id", "name", "parent_id"
def __init__(
self,
id: int,
parent_id: int | None,
name: str | None,
coro: Generator[Any, Any, Any] | Awaitable[Any],
):
func = get_current_task
self._name = f"{func.__module__}.{func.__qualname__}"
self.id: int = id
self.parent_id: int | None = parent_id
self.name: str | None = name
self.coro: Generator[Any, Any, Any] | Awaitable[Any] = coro
def __eq__(self, other: object) -> bool:
if isinstance(other, TaskInfo):
return self.id == other.id
return NotImplemented
def __hash__(self) -> int:
return hash(self.id)
def __repr__(self) -> str:
return f"{self.__class__.__name__}(id={self.id!r}, name={self.name!r})"
def has_pending_cancellation(self) -> bool:
"""
Return ``True`` if the task has a cancellation pending, ``False`` otherwise.
"""
return False
def get_current_task() -> TaskInfo:
"""
Return the current task.
:return: a representation of the current task
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
"""
return get_async_backend().get_current_task()
def get_running_tasks() -> list[TaskInfo]:
"""
Return a list of running tasks in the current event loop.
:return: a list of task info objects
:raises NoEventLoopError: if no supported asynchronous event loop is running in the
current thread
"""
return cast("list[TaskInfo]", get_async_backend().get_running_tasks())
async def wait_all_tasks_blocked() -> None:
"""Wait until all other tasks are waiting for something."""
await get_async_backend().wait_all_tasks_blocked()
@@ -0,0 +1,85 @@
from __future__ import annotations
import sys
from collections.abc import Callable, Mapping
from typing import Any, TypeVar, final, overload
from ._exceptions import TypedAttributeLookupError
if sys.version_info < (3, 15):
from typing_extensions import sentinel
T_Attr = TypeVar("T_Attr")
T_Default = TypeVar("T_Default")
undefined = sentinel("undefined")
def typed_attribute() -> Any:
"""Return a unique object, used to mark typed attributes."""
return object()
class TypedAttributeSet:
"""
Superclass for typed attribute collections.
Checks that every public attribute of every subclass has a type annotation.
"""
def __init_subclass__(cls) -> None:
annotations: dict[str, Any] = getattr(cls, "__annotations__", {})
for attrname in dir(cls):
if not attrname.startswith("_") and attrname not in annotations:
raise TypeError(
f"Attribute {attrname!r} is missing its type annotation"
)
super().__init_subclass__()
class TypedAttributeProvider:
"""Base class for classes that wish to provide typed extra attributes."""
@property
def extra_attributes(self) -> Mapping[T_Attr, Callable[[], T_Attr]]:
"""
A mapping of the extra attributes to callables that return the corresponding
values.
If the provider wraps another provider, the attributes from that wrapper should
also be included in the returned mapping (but the wrapper may override the
callables from the wrapped instance).
"""
return {}
@overload
def extra(self, attribute: T_Attr) -> T_Attr: ...
@overload
def extra(self, attribute: T_Attr, default: T_Default) -> T_Attr | T_Default: ...
@final
def extra(self, attribute: Any, default: object = undefined) -> object:
"""
extra(attribute, default=undefined)
Return the value of the given typed extra attribute.
:param attribute: the attribute (member of a :class:`~TypedAttributeSet`) to
look for
:param default: the value that should be returned if no value is found for the
attribute
:raises ~anyio.TypedAttributeLookupError: if the search failed and no default
value was given
"""
try:
getter = self.extra_attributes[attribute]
except KeyError:
if default is undefined:
raise TypedAttributeLookupError("Attribute not found") from None
else:
return default
return getter()
@@ -0,0 +1,176 @@
from __future__ import annotations
__all__ = (
"fix_package_names",
"install_lazy_importer",
"set_deprecated_aliases",
)
import ast
import inspect
import sys
import warnings
from importlib import import_module
from types import ModuleType
from typing import Any
def install_lazy_importer() -> bool:
module_globals = sys._getframe(1).f_globals
module_name = module_globals["__name__"]
module_prefix = module_name + "."
module = sys.modules[module_name]
lazy_map, deprecated_aliases, submodule_names = _build_lazy_map(module)
names = sorted(lazy_map)
# Delete symbols that are not part of the API
del module_globals["TYPE_CHECKING"]
del module_globals["install_lazy_importer"]
if not lazy_map and not deprecated_aliases:
return False
def __getattr__(name: str) -> Any:
if new_name := deprecated_aliases.get(name):
emit_deprecation_warning(module_name, name, new_name)
target_mod, target_attr = new_name.rsplit(".", 1)
elif name in submodule_names:
target_mod, target_attr = "." + name, ""
else:
try:
target_mod, target_attr = lazy_map[name]
except KeyError:
raise AttributeError(
f"module {module_name!r} has no attribute {name!r}"
) from None
imported = import_module(target_mod, module_name)
value = getattr(imported, target_attr) if target_attr else imported
# patch the module name to match
if (
getattr(value, "__module__", "").startswith(module_prefix)
and name not in deprecated_aliases
):
value.__module__ = module_name
module_globals[name] = value
return value
def __dir__() -> list[str]:
return names
module_globals["__dir__"] = __dir__
module_globals["__getattr__"] = __getattr__
module_globals.pop("fix_package_names", None)
module_globals.pop("set_deprecated_aliases", None)
return True
def fix_package_names() -> None:
module_globals = sys._getframe(1).f_globals
module_prefix = module_globals["__name__"] + "."
del module_globals[fix_package_names.__name__]
for value in module_globals.values():
if modname := getattr(value, "__module__", ""):
if modname.startswith(module_prefix):
parts = modname.split(".")
value.__module__ = ".".join(
part for part in parts if not part.startswith("_")
)
def emit_deprecation_warning(module_name: str, name: str, target: str) -> None:
warnings.warn(
f"The {module_name}.{name} alias is deprecated, use {target} instead.",
DeprecationWarning,
stacklevel=3,
)
def set_deprecated_aliases(aliases: dict[str, str]) -> None:
module_globals = sys._getframe(1).f_globals
module_name = module_globals["__name__"]
del module_globals[set_deprecated_aliases.__name__]
def __getattr__(name: str) -> Any:
try:
target = aliases[name]
except KeyError:
raise AttributeError(
f"module {module_name!r} has no attribute {name!r}"
) from None
emit_deprecation_warning(module_name, name, target)
target_modname, attrname = target.rsplit(".", 1)
module = import_module(target_modname)
return getattr(module, attrname)
sys.modules[module_name].__dict__["__getattr__"] = __getattr__
def _build_lazy_map(
module: ModuleType,
) -> tuple[dict[str, tuple[str, str]], dict[str, str], list[str]]:
try:
source = inspect.getsource(module)
except OSError:
return {}, {}, []
tree = compile(source, module.__file__ or "", "exec", ast.PyCF_ONLY_AST)
assert isinstance(tree, ast.Module)
out: dict[str, tuple[str, str]] = {}
deprecated_aliases: dict[str, str] = {}
submodule_names: list[str] = []
for node in tree.body:
if not isinstance(node, ast.If) or not _is_type_checking_block(node.test):
continue
for stmt in node.body:
match stmt:
case ast.ImportFrom():
if stmt.module is None:
submodule_names.extend(alias.name for alias in stmt.names)
else:
base = "." * stmt.level + (stmt.module or "")
for alias in stmt.names:
if alias.name == "*":
raise RuntimeError("star imports not supported")
exported = alias.asname or alias.name
out[exported] = (base, alias.name)
case ast.Expr() if isinstance(stmt.value, ast.Call):
call = stmt.value
if (
isinstance(call.func, ast.Name)
and call.func.id == "set_deprecated_aliases"
):
arg0 = call.args[0]
assert isinstance(arg0, ast.Dict)
for key, value in zip(arg0.keys, arg0.values, strict=True):
assert isinstance(key, ast.Constant)
assert isinstance(key.value, str)
assert isinstance(value, ast.Constant)
assert isinstance(value.value, str)
deprecated_aliases[key.value] = value.value
return out, deprecated_aliases, submodule_names
def _is_type_checking_block(test: ast.AST) -> bool:
if not isinstance(test, ast.BoolOp):
return False
subtest = test.values[0]
match subtest:
case ast.Name():
return subtest.id == "TYPE_CHECKING"
case ast.Attribute():
return (
isinstance(subtest.value, ast.Name)
and subtest.value.id == "typing"
and subtest.attr == "TYPE_CHECKING"
)
case _:
return False
@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .._lazyimport import (
fix_package_names,
install_lazy_importer,
set_deprecated_aliases,
)
if TYPE_CHECKING or not install_lazy_importer():
from ._eventloop import AsyncBackend as AsyncBackend
from ._resources import AsyncResource as AsyncResource
from ._sockets import ConnectedUDPSocket as ConnectedUDPSocket
from ._sockets import ConnectedUNIXDatagramSocket as ConnectedUNIXDatagramSocket
from ._sockets import IPAddressType as IPAddressType
from ._sockets import IPSockAddrType as IPSockAddrType
from ._sockets import SocketAttribute as SocketAttribute
from ._sockets import SocketListener as SocketListener
from ._sockets import SocketStream as SocketStream
from ._sockets import UDPPacketType as UDPPacketType
from ._sockets import UDPSocket as UDPSocket
from ._sockets import UNIXDatagramPacketType as UNIXDatagramPacketType
from ._sockets import UNIXDatagramSocket as UNIXDatagramSocket
from ._sockets import UNIXSocketStream as UNIXSocketStream
from ._streams import AnyByteReceiveStream as AnyByteReceiveStream
from ._streams import AnyByteSendStream as AnyByteSendStream
from ._streams import AnyByteStream as AnyByteStream
from ._streams import AnyByteStreamConnectable as AnyByteStreamConnectable
from ._streams import (
AnyUnreliableByteReceiveStream as AnyUnreliableByteReceiveStream,
)
from ._streams import AnyUnreliableByteSendStream as AnyUnreliableByteSendStream
from ._streams import AnyUnreliableByteStream as AnyUnreliableByteStream
from ._streams import ByteReceiveStream as ByteReceiveStream
from ._streams import ByteSendStream as ByteSendStream
from ._streams import ByteStream as ByteStream
from ._streams import ByteStreamConnectable as ByteStreamConnectable
from ._streams import Listener as Listener
from ._streams import ObjectReceiveStream as ObjectReceiveStream
from ._streams import ObjectSendStream as ObjectSendStream
from ._streams import ObjectStream as ObjectStream
from ._streams import ObjectStreamConnectable as ObjectStreamConnectable
from ._streams import UnreliableObjectReceiveStream as UnreliableObjectReceiveStream
from ._streams import UnreliableObjectSendStream as UnreliableObjectSendStream
from ._streams import UnreliableObjectStream as UnreliableObjectStream
from ._subprocesses import Process as Process
from ._tasks import TaskGroup as TaskGroup
from ._tasks import TaskStatus as TaskStatus
from ._testing import TestRunner as TestRunner
fix_package_names()
set_deprecated_aliases(
{
"CapacityLimiter": "anyio.CapacityLimiter",
"Condition": "anyio.Condition",
"Event": "anyio.Event",
"Lock": "anyio.Lock",
"Semaphore": "anyio.Semaphore",
"CancelScope": "anyio.CancelScope",
"BlockingPortal": "anyio.from_thread.BlockingPortal",
}
)
@@ -0,0 +1,428 @@
from __future__ import annotations
import math
import sys
from abc import ABCMeta, abstractmethod
from collections.abc import (
AsyncIterator,
Awaitable,
Callable,
Coroutine,
Iterable,
Mapping,
Sequence,
)
from contextlib import AbstractContextManager
from os import PathLike
from signal import Signals
from socket import AddressFamily, SocketKind, socket
from typing import (
IO,
TYPE_CHECKING,
Any,
TypeAlias,
TypeVar,
overload,
)
if sys.version_info >= (3, 11):
from typing import TypeVarTuple, Unpack
else:
from typing_extensions import TypeVarTuple, Unpack
if TYPE_CHECKING:
from _typeshed import FileDescriptorLike
from .._core._synchronization import CapacityLimiter, Event, Lock, Semaphore
from .._core._tasks import CancelScope
from .._core._testing import TaskInfo
from ._sockets import (
ConnectedUDPSocket,
ConnectedUNIXDatagramSocket,
IPSockAddrType,
SocketListener,
SocketStream,
UDPSocket,
UNIXDatagramSocket,
UNIXSocketStream,
)
from ._subprocesses import Process
from ._tasks import TaskGroup
from ._testing import TestRunner
T_Retval = TypeVar("T_Retval")
T_co = TypeVar("T_co", covariant=True)
PosArgsT = TypeVarTuple("PosArgsT")
StrOrBytesPath: TypeAlias = str | bytes | PathLike[str] | PathLike[bytes]
class AsyncBackend(metaclass=ABCMeta):
@classmethod
@abstractmethod
def run(
cls,
func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval]],
args: tuple[Unpack[PosArgsT]],
kwargs: dict[str, Any],
options: dict[str, Any],
) -> T_Retval:
"""
Run the given coroutine function in an asynchronous event loop.
The current thread must not be already running an event loop.
:param func: a coroutine function
:param args: positional arguments to ``func``
:param kwargs: positional arguments to ``func``
:param options: keyword arguments to call the backend ``run()`` implementation
with
:return: the return value of the coroutine function
"""
@classmethod
@abstractmethod
def current_token(cls) -> object:
"""
Return an object that allows other threads to run code inside the event loop.
:return: a token object, specific to the event loop running in the current
thread
"""
@classmethod
@abstractmethod
def current_time(cls) -> float:
"""
Return the current value of the event loop's internal clock.
:return: the clock value (seconds)
"""
@classmethod
@abstractmethod
def cancelled_exception_class(cls) -> type[BaseException]:
"""Return the exception class that is raised in a task if it's cancelled."""
@classmethod
@abstractmethod
async def checkpoint(cls) -> None:
"""
Check if the task has been cancelled, and allow rescheduling of other tasks.
This is effectively the same as running :meth:`checkpoint_if_cancelled` and then
:meth:`cancel_shielded_checkpoint`.
"""
@classmethod
async def checkpoint_if_cancelled(cls) -> None:
"""
Check if the current task group has been cancelled.
This will check if the task has been cancelled, but will not allow other tasks
to be scheduled if not.
"""
if cls.current_effective_deadline() == -math.inf:
await cls.checkpoint()
@classmethod
async def cancel_shielded_checkpoint(cls) -> None:
"""
Allow the rescheduling of other tasks.
This will give other tasks the opportunity to run, but without checking if the
current task group has been cancelled, unlike with :meth:`checkpoint`.
"""
with cls.create_cancel_scope(shield=True):
await cls.sleep(0)
@classmethod
@abstractmethod
async def sleep(cls, delay: float) -> None:
"""
Pause the current task for the specified duration.
:param delay: the duration, in seconds
"""
@classmethod
@abstractmethod
def create_cancel_scope(
cls, *, deadline: float = math.inf, shield: bool = False
) -> CancelScope:
pass
@classmethod
@abstractmethod
def current_effective_deadline(cls) -> float:
"""
Return the nearest deadline among all the cancel scopes effective for the
current task.
:return:
- a clock value from the event loop's internal clock
- ``inf`` if there is no deadline in effect
- ``-inf`` if the current scope has been cancelled
:rtype: float
"""
@classmethod
@abstractmethod
def create_task_group(cls) -> TaskGroup:
pass
@classmethod
@abstractmethod
def create_event(cls) -> Event:
pass
@classmethod
@abstractmethod
def create_lock(cls, *, fast_acquire: bool) -> Lock:
pass
@classmethod
@abstractmethod
def create_semaphore(
cls,
initial_value: int,
*,
max_value: int | None = None,
fast_acquire: bool = False,
) -> Semaphore:
pass
@classmethod
@abstractmethod
def create_capacity_limiter(cls, total_tokens: float) -> CapacityLimiter:
pass
@classmethod
@abstractmethod
async def run_sync_in_worker_thread(
cls,
func: Callable[[Unpack[PosArgsT]], T_Retval],
args: tuple[Unpack[PosArgsT]],
abandon_on_cancel: bool = False,
limiter: CapacityLimiter | None = None,
) -> T_Retval:
pass
@classmethod
@abstractmethod
def check_cancelled(cls) -> None:
pass
@classmethod
@abstractmethod
def run_async_from_thread(
cls,
func: Callable[[Unpack[PosArgsT]], Coroutine[Any, Any, T_co]],
args: tuple[Unpack[PosArgsT]],
token: object,
) -> T_co:
pass
@classmethod
@abstractmethod
def run_sync_from_thread(
cls,
func: Callable[[Unpack[PosArgsT]], T_Retval],
args: tuple[Unpack[PosArgsT]],
token: object,
) -> T_Retval:
pass
@classmethod
@abstractmethod
async def open_process(
cls,
command: StrOrBytesPath | Sequence[StrOrBytesPath],
*,
stdin: int | IO[Any] | None,
stdout: int | IO[Any] | None,
stderr: int | IO[Any] | None,
cwd: StrOrBytesPath | None = None,
env: Mapping[str, str] | None = None,
startupinfo: Any = None,
creationflags: int = 0,
start_new_session: bool = False,
pass_fds: Sequence[int] = (),
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
**kwargs: Any,
) -> Process:
pass
@classmethod
@abstractmethod
def setup_process_pool_exit_at_shutdown(cls, workers: set[Process]) -> None:
pass
@classmethod
@abstractmethod
async def connect_tcp(
cls, host: str, port: int, local_address: IPSockAddrType | None = None
) -> SocketStream:
pass
@classmethod
@abstractmethod
async def connect_unix(cls, path: str | bytes) -> UNIXSocketStream:
pass
@classmethod
@abstractmethod
def create_tcp_listener(cls, sock: socket) -> SocketListener:
pass
@classmethod
@abstractmethod
def create_unix_listener(cls, sock: socket) -> SocketListener:
pass
@classmethod
@abstractmethod
async def create_udp_socket(
cls,
family: AddressFamily,
local_address: IPSockAddrType | None,
remote_address: IPSockAddrType | None,
reuse_port: bool,
) -> UDPSocket | ConnectedUDPSocket:
pass
@classmethod
@overload
async def create_unix_datagram_socket(
cls, raw_socket: socket, remote_path: None
) -> UNIXDatagramSocket: ...
@classmethod
@overload
async def create_unix_datagram_socket(
cls, raw_socket: socket, remote_path: str | bytes
) -> ConnectedUNIXDatagramSocket: ...
@classmethod
@abstractmethod
async def create_unix_datagram_socket(
cls, raw_socket: socket, remote_path: str | bytes | None
) -> UNIXDatagramSocket | ConnectedUNIXDatagramSocket:
pass
@classmethod
@abstractmethod
async def getaddrinfo(
cls,
host: bytes | str | None,
port: str | int | None,
*,
family: int | AddressFamily = 0,
type: int | SocketKind = 0,
proto: int = 0,
flags: int = 0,
) -> Sequence[
tuple[
AddressFamily,
SocketKind,
int,
str,
tuple[str, int] | tuple[str, int, int, int] | tuple[int, bytes],
]
]:
pass
@classmethod
@abstractmethod
async def getnameinfo(
cls, sockaddr: IPSockAddrType, flags: int = 0
) -> tuple[str, str]:
pass
@classmethod
@abstractmethod
async def wait_readable(cls, obj: FileDescriptorLike) -> None:
pass
@classmethod
@abstractmethod
async def wait_writable(cls, obj: FileDescriptorLike) -> None:
pass
@classmethod
@abstractmethod
def notify_closing(cls, obj: FileDescriptorLike) -> None:
pass
@classmethod
@abstractmethod
async def wrap_listener_socket(cls, sock: socket) -> SocketListener:
pass
@classmethod
@abstractmethod
async def wrap_stream_socket(cls, sock: socket) -> SocketStream:
pass
@classmethod
@abstractmethod
async def wrap_unix_stream_socket(cls, sock: socket) -> UNIXSocketStream:
pass
@classmethod
@abstractmethod
async def wrap_udp_socket(cls, sock: socket) -> UDPSocket:
pass
@classmethod
@abstractmethod
async def wrap_connected_udp_socket(cls, sock: socket) -> ConnectedUDPSocket:
pass
@classmethod
@abstractmethod
async def wrap_unix_datagram_socket(cls, sock: socket) -> UNIXDatagramSocket:
pass
@classmethod
@abstractmethod
async def wrap_connected_unix_datagram_socket(
cls, sock: socket
) -> ConnectedUNIXDatagramSocket:
pass
@classmethod
@abstractmethod
def current_default_thread_limiter(cls) -> CapacityLimiter:
pass
@classmethod
@abstractmethod
def open_signal_receiver(
cls, *signals: Signals
) -> AbstractContextManager[AsyncIterator[Signals]]:
pass
@classmethod
@abstractmethod
def get_current_task(cls) -> TaskInfo:
pass
@classmethod
@abstractmethod
def get_running_tasks(cls) -> Sequence[TaskInfo]:
pass
@classmethod
@abstractmethod
async def wait_all_tasks_blocked(cls) -> None:
pass
@classmethod
@abstractmethod
def create_test_runner(cls, options: dict[str, Any]) -> TestRunner:
pass
Loaded 100 of 1040 files, more files were not shown because too many files have changed in this diff. Show more