Compare commits
24
Commits
v0.3.1
...
a52625219e
No files matched your search
@@ -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).
|
||||
|
||||
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.
|
||||
@@ -4,7 +4,7 @@ FrameBridge is free software under the applicable licenses. Everyone retains the
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -2,100 +2,115 @@
|
||||
|
||||
**Bring your ChatGPT conversations to Linux computers you control.**
|
||||
|
||||
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.
|
||||
Run a task on your own machine, return to the same Bash terminal, inspect an X11 desktop, or select an enrolled computer. ChatGPT remains hosted; FrameBridge runs the tools on your Linux computers.
|
||||
|
||||
**Community Release 0.3.1** · **Debian 13 amd64** · **Python 3.13**
|
||||
> **Unsigned release candidate: `0.3.2`.** This source preparation promotes the compatibility candidate; it is not a signed public release. Final artifact validation and signing are recorded separately. The signed 0.3.1 release remains a different artifact. Read the [compatibility scope and history](docs/LINUX-COMPATIBILITY-CANDIDATE.md); use the exact artifact release record when choosing a package.
|
||||
|
||||
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.
|
||||
## Start here
|
||||
|
||||
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 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.
|
||||
|
||||
## Install and connect
|
||||
| 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. |
|
||||
|
||||
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.
|
||||
### 1. Install the matching package
|
||||
|
||||
On Debian 13 amd64, with the downloaded installer in the current directory:
|
||||
The compatibility candidate has separate native Python builds; it is not one interchangeable `.deb` for every Linux system.
|
||||
|
||||
| Distribution | Architecture | System Python | Candidate 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` |
|
||||
|
||||
The preceding compatibility candidate `0.3.2~compat.20261027.3` passed installation, normal and optional virtual-runtime startup, actual MCP HTTP/SSE exchanges, terminal and cleanup tests on Debian 13, Ubuntu 24.04 and Mint 22.3 VMs. Mint also passed its Cinnamon window-capture test with its existing WebSocket library unchanged. This 0.3.2 source preparation preserves that runtime behavior; the exact final package bytes require their own artifact validation and release signatures. Ubuntu 26.04 and LMDE 7 builds remain experimental; other distributions, older releases and CPU architectures are outside this candidate's scope.
|
||||
|
||||
Verify official releases with the independently trusted project key as described in [Official releases](OFFICIAL-RELEASES.md). A candidate's recorded SHA-256 identifies its reviewed file; it is not a release signature. Candidate packages are for reviewed pilots.
|
||||
|
||||
For example, with the **Mint** candidate downloaded into the current directory:
|
||||
|
||||
~~~bash
|
||||
sudo apt install ./FrameBridge-0.3.1.deb
|
||||
fb connect
|
||||
fb status
|
||||
sudo apt install ./FrameBridge-0.3.2-mint22-amd64-py312.deb
|
||||
~~~
|
||||
|
||||
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 |
|
||||
| --- | --- |
|
||||
| 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. |
|
||||
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`.
|
||||
|
||||
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
|
||||
fb --json status
|
||||
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
|
||||
`fb` and `framebridge` are names for the same program. Read `fb help` for the English guide and `fb help compatibility` for legacy commands.
|
||||
|
||||
Service execution, connection intent, boot policy and saved material are separate:
|
||||
## Pause, stop or remove
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
| 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. |
|
||||
|
||||
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.
|
||||
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`.
|
||||
|
||||
CLI terminal observation is text. Real CUA captures are separate, authentic screen evidence. Private virtual displays, when explicitly selected, are identified as virtual.
|
||||
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.
|
||||
|
||||
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.
|
||||
## Source, licensing and contributions
|
||||
|
||||
## Source, licenses and official releases
|
||||
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).
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
The distribution source set contains:
|
||||
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-source.tar.gz — FrameBridge implementation, builder, tests and legal records.
|
||||
- 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.
|
||||
Read [CONTRIBUTING.md](CONTRIBUTING.md) to propose a change and [SECURITY.md](SECURITY.md) for security reports. 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.
|
||||
|
||||
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.
|
||||
Project home and 0.3.2 publication origin: https://betancourt.one/framebridge/
|
||||
|
||||
~~~bash
|
||||
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/
|
||||
Use the corresponding-source archive identified in each release record. When that record marks the source mirror published, its read-only HTTP Git endpoint is `https://betancourt.one/framebridge/git/framebridge.git`. Final 0.3.2 publication is pending; this source preparation does not claim that the new packages or mirror are already live.
|
||||
|
||||
Programming: **ChatGPT**.
|
||||
Original concept and project coordination: **Gerardo Betancourt**.
|
||||
|
||||
FrameBridge is an independent community project. No endorsement by OpenAI is claimed.
|
||||
|
||||
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.
|
||||
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.
|
||||
@@ -0,0 +1,24 @@
|
||||
# FrameBridge 0.3.3
|
||||
|
||||
FrameBridge administers Linux computers on a home network through a shared command-line interface for the human administrator and authorized agents. The language model remains hosted.
|
||||
|
||||
## Changes
|
||||
|
||||
- The desktop console reconnects to the existing official console window when the same tmux session contains other windows. It preserves the working shell and unrelated windows, and displays jobs from the selected profile. Recognized legacy desktop entries and console wrappers can be migrated to the packaged command with private backups.
|
||||
- Forward package upgrades record active managed profiles before replacing files. A detached upgrade worker waits for DPKG to finish, then restarts only those profiles in independent user services. It preserves existing connection processes, profile settings, enrollment and boot policy, and records readiness separately from package installation. Stopped profiles remain stopped.
|
||||
- The release provides separate amd64 builds and signed APT suites for Debian 13 (`trixie`), Ubuntu 24.04 (`noble`) and Linux Mint 22.x (`mint22`). Use the package for the distribution actually installed; keep its system Python.
|
||||
- The public documentation uses the canonical website and Gitea repository under `framebridge.betancourt.one` and describes network administration as the primary workflow. Desktop tools remain auxiliary.
|
||||
|
||||
## Updating
|
||||
|
||||
After configuring the matching signed APT source, use the normal package manager to install or update `framebridge-original-expanded`. Inspect `fb status` after an upgrade. A successful APT command confirms package installation; the upgrade worker records runtime restart and readiness independently. An upgrade from a stopped profile does not connect it automatically.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Physical desktop input requires a compatible active X11 session. Native Wayland control is not certified. The guarded x86-64 X11 text backend supports active keyboard group 0 and its documented modifier restrictions.
|
||||
|
||||
Legacy integrations can have a different default execution target from the current receiver. Preserve explicit target routing or use a compatibility adapter when migrating existing clients. Replacing an endpoint without checking its default target is not a compatibility guarantee.
|
||||
|
||||
## Validation
|
||||
|
||||
The release is being validated against the three distribution profiles. Build, signature, APT and live upgrade results are recorded in the accompanying release verification manifest; publication and installation must be verified against the actual signed artifacts.
|
||||
@@ -1,5 +1,19 @@
|
||||
# FrameBridge release notes
|
||||
|
||||
## 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
|
||||
|
||||
Installer: FrameBridge-0.3.1.deb
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FrameBridge — command guide
|
||||
|
||||
Version: 0.3.1 | Guide schema: 1
|
||||
Version: 0.3.3 | Guide schema: 1
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
fb help
|
||||
@@ -33,16 +33,16 @@ fb --json status
|
||||
|
||||
### 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
|
||||
sudo apt install ./FrameBridge-0.3.1.deb
|
||||
sudo apt install ./framebridge-expanded_0.3.3_amd64.deb
|
||||
fb connect
|
||||
~~~
|
||||
|
||||
### 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
|
||||
sudo dpkg --remove framebridge-original-expanded
|
||||
@@ -303,13 +303,17 @@ A CLI tty transcript describes a terminal and explicitly does not claim authenti
|
||||
|
||||
### 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
|
||||
fb status
|
||||
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
|
||||
|
||||
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,7 +344,7 @@ fb status boot
|
||||
|
||||
### 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. Candidate eligibility checks the exact OS release, amd64 architecture, package profile and CPython ABI before starting native components. Debian 13/LMDE 7 use cp313, Ubuntu 24.04/Mint 22.x use cp312, and Ubuntu 26.04 uses cp314. A matching profile does not establish a tested installation; never repair an ABI mismatch by replacing system Python or weakening package dependencies.
|
||||
|
||||
~~~sh
|
||||
fb doctor
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,104 @@
|
||||
# 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.
|
||||
|
||||
Source preparation: **0.3.2, unsigned release candidate**, promoted from compatibility revision `0.3.2~compat.20261027.3` after its Debian 13, Ubuntu 24.04 and Mint 22.3 VM checks passed. Check the exact artifact release record before installation; this prior behavior evidence does not verify the final package bytes or claim public availability.
|
||||
|
||||
## 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.
|
||||
|
||||
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]
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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,19 +1,19 @@
|
||||
{
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.3",
|
||||
"guide_schema": 1,
|
||||
"topics": {
|
||||
"start": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "start",
|
||||
"title": "Start with FrameBridge",
|
||||
"sections": [
|
||||
{
|
||||
"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": [
|
||||
"fb help",
|
||||
"fb status",
|
||||
@@ -22,15 +22,15 @@
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"sudo apt install ./FrameBridge-0.3.1.deb",
|
||||
"sudo apt install ./framebridge-expanded_0.3.3_amd64.deb",
|
||||
"fb connect"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"sudo dpkg --remove framebridge-original-expanded"
|
||||
]
|
||||
@@ -64,12 +64,12 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "ac55eed900b5d1687b784299c5dfa238c7502c8472b1135dfa2d7231352f9daa"
|
||||
"guide_sha256": "23abd7eec3efb77ca160c97fec9b8bacc7040419946e75584ed563a509577a57"
|
||||
},
|
||||
"commands": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "commands",
|
||||
"title": "Command reference",
|
||||
@@ -136,12 +136,12 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "9bc22a59b75eba4a6a0734320f76b5216001bbe527b2575f9f5dcee2a6abfc99"
|
||||
"guide_sha256": "432ab37e7249793cbe72e42d27582d70c2d2c1394ac1f5418b5d3e211e6a7c06"
|
||||
},
|
||||
"connection": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "connection",
|
||||
"title": "Connect and disconnect ChatGPT",
|
||||
@@ -190,12 +190,12 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "601d608bf403f0cf3cd531dc42013c1d53cddaf622dd73b8ec2a13aeb9c98fb8"
|
||||
"guide_sha256": "c41d5c436a7039cfacd9cde4933e24c9f7d6235d43e2e9f4fe106f1a4c631764"
|
||||
},
|
||||
"lifecycle": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "lifecycle",
|
||||
"title": "Services, connection intent and boot policy",
|
||||
@@ -256,12 +256,12 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "e6aca4ba3fc004cc2475ea141a1f645070a5bde306387e89ec904daec2b2f317"
|
||||
"guide_sha256": "084358c9d1d41e0639b49d01c7e40b613127e3ead77e156cc2624850c7ebdb16"
|
||||
},
|
||||
"nodes": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "nodes",
|
||||
"title": "Central and node operation",
|
||||
@@ -310,12 +310,12 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "440ed8b07ab2d7c89d4383d91342801a36f46dba72692482d419f457bb412186"
|
||||
"guide_sha256": "ec2adb6ba2cb36e3747bcb4975a6b777ad88d4fe2b9b1cfb1a2f6ec1e8dab43a"
|
||||
},
|
||||
"terminals": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "terminals",
|
||||
"title": "Persistent Bash terminals",
|
||||
@@ -376,12 +376,12 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "d58f36e8b96bf4687d99fe009e0061d0e76435ddb30a1601826c9eb941b64e9d"
|
||||
"guide_sha256": "0aba231493d9ecae071ecdf5197440e0714393364948079fc4d163ff70cc67e3"
|
||||
},
|
||||
"screens": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "screens",
|
||||
"title": "Authentic screen observation and input",
|
||||
@@ -393,12 +393,17 @@
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"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",
|
||||
"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,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "b186eb36570b870490eedf424b30eabb4c130ee2e2c72d658fe09b77549bbc59"
|
||||
"guide_sha256": "eeaf9cfed9e7a84d59a727c4388b0869b02a6ed870647e87b333d318a3584c9a"
|
||||
},
|
||||
"diagnostics": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "diagnostics",
|
||||
"title": "Status and diagnosis",
|
||||
@@ -448,7 +453,7 @@
|
||||
},
|
||||
{
|
||||
"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. Candidate eligibility checks the exact OS release, amd64 architecture, package profile and CPython ABI before starting native components. Debian 13/LMDE 7 use cp313, Ubuntu 24.04/Mint 22.x use cp312, and Ubuntu 26.04 uses cp314. A matching profile does not establish a tested installation; never repair an ABI mismatch by replacing system Python or weakening package dependencies.",
|
||||
"commands": [
|
||||
"fb doctor"
|
||||
]
|
||||
@@ -476,12 +481,12 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "0e55b9cda6b528926ca2a297d8464908a300122edbc1550b841ab8c1b7673029"
|
||||
"guide_sha256": "77a65897aacfeaa2b06dd184ac78d366d28980847b0cbdd1429c889e0e2898a6"
|
||||
},
|
||||
"security": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "security",
|
||||
"title": "Authorization, distribution and safe operation",
|
||||
@@ -516,12 +521,12 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "2be17b3d7cb261f6cb5fbb94bfd027ca34147b8a4ead902f6a53b477fb4c201c"
|
||||
"guide_sha256": "f257fcbbdec39395a15655b0a5123fb385ce7eaba2a256de07a5da65eab3ec8e"
|
||||
},
|
||||
"compatibility": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "compatibility",
|
||||
"title": "Compatibility with earlier commands",
|
||||
@@ -564,8 +569,8 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "a6aa6d12e37563866404fa5834bdd7e758f3782a93b59d454316a8c9359b7258"
|
||||
"guide_sha256": "9056f77b27f326bebfdbb54dd10b748d6315797b07afe1aee29f42d6210dae36"
|
||||
}
|
||||
},
|
||||
"guide_sha256": "504c010697017b50b26de89f793944072ca214ae92d772e8e282ad44a6645634"
|
||||
"guide_sha256": "5dc6ed2744e53d7d2ce1f471e2ab94d50f9c52f83ae85abe495bacad34b1b96c"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# FrameBridge — command guide
|
||||
|
||||
Version: 0.3.1 | Guide schema: 1
|
||||
Version: 0.3.3 | Guide schema: 1
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
fb help
|
||||
@@ -33,16 +33,16 @@ fb --json status
|
||||
|
||||
### 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
|
||||
sudo apt install ./FrameBridge-0.3.1.deb
|
||||
sudo apt install ./framebridge-expanded_0.3.3_amd64.deb
|
||||
fb connect
|
||||
~~~
|
||||
|
||||
### 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
|
||||
sudo dpkg --remove framebridge-original-expanded
|
||||
@@ -303,13 +303,17 @@ A CLI tty transcript describes a terminal and explicitly does not claim authenti
|
||||
|
||||
### 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
|
||||
fb status
|
||||
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
|
||||
|
||||
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,7 +344,7 @@ fb status boot
|
||||
|
||||
### 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. Candidate eligibility checks the exact OS release, amd64 architecture, package profile and CPython ABI before starting native components. Debian 13/LMDE 7 use cp313, Ubuntu 24.04/Mint 22.x use cp312, and Ubuntu 26.04 uses cp314. A matching profile does not establish a tested installation; never repair an ABI mismatch by replacing system Python or weakening package dependencies.
|
||||
|
||||
~~~sh
|
||||
fb doctor
|
||||
|
||||
@@ -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.
|
||||
@@ -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.3 | Guide schema: 1
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
fb help
|
||||
@@ -33,16 +33,16 @@ fb --json status
|
||||
|
||||
### 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
|
||||
sudo apt install ./FrameBridge-0.3.1.deb
|
||||
sudo apt install ./framebridge-expanded_0.3.3_amd64.deb
|
||||
fb connect
|
||||
~~~
|
||||
|
||||
### 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
|
||||
sudo dpkg --remove framebridge-original-expanded
|
||||
@@ -303,13 +303,17 @@ A CLI tty transcript describes a terminal and explicitly does not claim authenti
|
||||
|
||||
### 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
|
||||
fb status
|
||||
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
|
||||
|
||||
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,7 +344,7 @@ fb status boot
|
||||
|
||||
### 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. Candidate eligibility checks the exact OS release, amd64 architecture, package profile and CPython ABI before starting native components. Debian 13/LMDE 7 use cp313, Ubuntu 24.04/Mint 22.x use cp312, and Ubuntu 26.04 uses cp314. A matching profile does not establish a tested installation; never repair an ABI mismatch by replacing system Python or weakening package dependencies.
|
||||
|
||||
~~~sh
|
||||
fb doctor
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.TH FRAMEBRIDGE 1 "2026-10-26" "FrameBridge 0.3.1" "User Commands"
|
||||
.TH FRAMEBRIDGE 1 "2026-10-28" "FrameBridge 0.3.3" "User Commands"
|
||||
.SH NAME
|
||||
framebridge, fb \- use local Linux tools and enrolled nodes with ChatGPT
|
||||
.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.
|
||||
.SH START WITH FRAMEBRIDGE
|
||||
.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
|
||||
.nf
|
||||
fb help
|
||||
@@ -18,14 +18,14 @@ fb status
|
||||
fb \-\-json status
|
||||
.fi
|
||||
.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
|
||||
.nf
|
||||
sudo apt install ./FrameBridge\-0.3.1.deb
|
||||
sudo apt install ./framebridge\-expanded_0.3.3_amd64.deb
|
||||
fb connect
|
||||
.fi
|
||||
.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
|
||||
.nf
|
||||
sudo dpkg \-\-remove framebridge\-original\-expanded
|
||||
@@ -210,12 +210,14 @@ fb status console
|
||||
.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.
|
||||
.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
|
||||
.nf
|
||||
fb status
|
||||
fb doctor
|
||||
.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
|
||||
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
|
||||
@@ -235,7 +237,7 @@ fb status runtime
|
||||
fb status boot
|
||||
.fi
|
||||
.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. Candidate eligibility checks the exact OS release, amd64 architecture, package profile and CPython ABI before starting native components. Debian 13/LMDE 7 use cp313, Ubuntu 24.04/Mint 22.x use cp312, and Ubuntu 26.04 uses cp314. A matching profile does not establish a tested installation; never repair an ABI mismatch by replacing system Python or weakening package dependencies.
|
||||
.PP
|
||||
.nf
|
||||
fb doctor
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.3.1"
|
||||
__version__ = "0.3.3"
|
||||
@@ -212,7 +212,10 @@ class RealTerminalMixin:
|
||||
|
||||
def shutdown(self):
|
||||
with self._lock:
|
||||
errors=[]
|
||||
for sid in list(self._real):
|
||||
try:self.abort(sid)
|
||||
except (TerminalDenied,SudoControlError):pass
|
||||
except (TerminalDenied,SudoControlError) as error:
|
||||
errors.append({'session_id':sid,'error':str(error)})
|
||||
super().shutdown()
|
||||
return {'cleanup_verified':not self._real,'errors':errors}
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Explicit user-service startup with host-bound encrypted systemd credentials."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
import fcntl
|
||||
import getpass
|
||||
import hashlib
|
||||
@@ -20,6 +21,152 @@ import time
|
||||
from .config import load_config, save_config, private_dir, state_path, default_config, validate
|
||||
|
||||
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 "-"])
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@@ -69,8 +216,8 @@ def _json(path, value, replace=False):
|
||||
_write(path, (json.dumps(value, sort_keys=True, indent=2) + "\n").encode(), replace)
|
||||
|
||||
|
||||
def _command(args, *, data=None, timeout=35, check=True):
|
||||
result = subprocess.run(args, input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
|
||||
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, env=env)
|
||||
if check and result.returncode:
|
||||
raise RuntimeError("Autostart command failed: " + Path(args[0]).name + ". No command output was exported.")
|
||||
return result
|
||||
@@ -148,6 +295,8 @@ def status(state):
|
||||
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)}
|
||||
if manifest and not manifest.get("node"):
|
||||
result["credential_backend"] = _credential_backend(manifest)
|
||||
result["credential_cleanup_pending"] = bool(_retired_helpers(manifest))
|
||||
try:
|
||||
cipher = _read(directory / "credential.cred")
|
||||
result["encrypted_credential_present"] = hashlib.sha256(cipher).hexdigest() == manifest.get("credential_sha256")
|
||||
@@ -336,11 +485,13 @@ def _configure_connection(state, settings, runtime_key):
|
||||
raise RuntimeError("The profile backup changed outside FrameBridge; nothing was replaced.")
|
||||
elif any(originals[path] is not None for path in (unit, blob, backup)):
|
||||
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)
|
||||
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("Protected connections require systemd 256 or later.")
|
||||
backend = _credential_backend()
|
||||
_command(["sudo", "-v"], timeout=180)
|
||||
user = pwd.getpwuid(os.getuid()).pw_name
|
||||
linger_before = _linger(user)
|
||||
@@ -349,10 +500,16 @@ def _configure_connection(state, settings, runtime_key):
|
||||
values = dict(line.split("=", 1) for line in properties.stdout.decode(errors="replace").splitlines() if "=" in line)
|
||||
was_enabled = values.get("UnitFileState") == "enabled"
|
||||
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:
|
||||
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:
|
||||
runtime_key = ""
|
||||
with _credential_transaction(state, encrypted, backend) as transaction:
|
||||
if len(encrypted) < 80 or len(encrypted) > 131072:
|
||||
raise RuntimeError("Connection protection did not complete.")
|
||||
# Recheck the exact originals after authentication/encryption and before any stop.
|
||||
@@ -373,7 +530,10 @@ def _configure_connection(state, settings, runtime_key):
|
||||
backup_bytes = (json.dumps(restored_profile, indent=2, sort_keys=True) + "\n").encode()
|
||||
record["config_before_sha256"] = hashlib.sha256(backup_bytes).hexdigest()
|
||||
record["changed_config_sha256"] = hashlib.sha256(config_bytes).hexdigest()
|
||||
record.update(unit_sha256=hashlib.sha256(text).hexdigest(), credential_sha256=hashlib.sha256(encrypted).hexdigest(), connection_settings_sha256=_connection_public_digest(config))
|
||||
retired = _retired_helpers(previous or {})
|
||||
if previous and _credential_backend(previous) == HOST_BACKEND:
|
||||
retired.append(previous["credential_sha256"])
|
||||
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))
|
||||
from . import connection_background
|
||||
background_before = connection_background.status(state)
|
||||
changed = {}
|
||||
@@ -447,6 +607,8 @@ def _configure_connection(state, settings, runtime_key):
|
||||
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)
|
||||
|
||||
|
||||
@@ -475,11 +637,8 @@ def _enable(state, *, start_now=True, confirm=True):
|
||||
else:
|
||||
_systemctl("enable", unit.name)
|
||||
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"
|
||||
backend = None if node else _credential_backend()
|
||||
if not node and not config.get("tunnel_id"):
|
||||
raise RuntimeError("Save a tunnel with fb connect chatgpt before enabling automatic startup.")
|
||||
print("FrameBridge will start automatically when this computer starts.")
|
||||
@@ -506,14 +665,19 @@ 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):
|
||||
raise ValueError("The runtime key must be nonempty and contain no whitespace or control characters.")
|
||||
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:
|
||||
runtime_key = ""
|
||||
if len(encrypted) < 80 or len(encrypted) > 131072:
|
||||
raise RuntimeError("Credential encryption returned an invalid result.")
|
||||
elif _helper_present():
|
||||
_prune_helpers(state)
|
||||
with _credential_transaction(state, encrypted, backend) as transaction:
|
||||
blob = directory / "credential.cred"
|
||||
text = unit_text(state, blob, node=node).encode()
|
||||
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()}
|
||||
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()}
|
||||
created = []
|
||||
try:
|
||||
if encrypted:
|
||||
@@ -551,6 +715,7 @@ def _enable(state, *, start_now=True, confirm=True):
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
transaction["committed"] = True
|
||||
return status(state)
|
||||
|
||||
|
||||
@@ -558,12 +723,21 @@ def _disable(state):
|
||||
state, directory, unit = _paths(state)
|
||||
record = _manifest(directory)
|
||||
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"):
|
||||
raise RuntimeError("The service changed outside FrameBridge; refusing to remove it.")
|
||||
blob = directory / "credential.cred"
|
||||
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.")
|
||||
retired = _retired_helpers(record)
|
||||
backup = None
|
||||
if record.get("headless_changed"):
|
||||
backup = _read(directory / "config-before.json")
|
||||
@@ -576,7 +750,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")
|
||||
if restore_linger:
|
||||
_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)
|
||||
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()
|
||||
if not record.get("node"):
|
||||
blob.unlink()
|
||||
@@ -609,6 +790,12 @@ def _credential(state):
|
||||
if hashlib.sha256(cipher).hexdigest() != record.get("credential_sha256"):
|
||||
raise RuntimeError("The encrypted runtime credential changed outside FrameBridge.")
|
||||
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
|
||||
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:
|
||||
key = result.stdout.decode("ascii")
|
||||
|
||||
@@ -28,6 +28,8 @@ class BrokerError(ValueError):
|
||||
"""A fixed public failure code; never include HTTP bodies or credentials."""
|
||||
|
||||
|
||||
_SETTINGS_LOCK = threading.RLock()
|
||||
|
||||
_state = None
|
||||
_url = None
|
||||
_control_path = None
|
||||
@@ -80,15 +82,19 @@ def validate_endpoint(url):
|
||||
|
||||
def configure(broker_url=None, state_dir=None, control_key_path=None):
|
||||
global _state, _url, _control_path
|
||||
_state = state_path(state_dir)
|
||||
config = _config(_state)
|
||||
# Workers must see one fully validated profile, including explicit changes.
|
||||
with _SETTINGS_LOCK:
|
||||
state = state_path(state_dir)
|
||||
config = _config(state)
|
||||
port = config.get("ports", {}).get("broker", 20878)
|
||||
_url = validate_endpoint(broker_url or config.get("broker_control_url") or config.get("broker_url")
|
||||
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")
|
||||
control_path = Path(control_key_path or state / "broker" / "control.key")
|
||||
_state, _url, _control_path = state, url, control_path
|
||||
|
||||
|
||||
def _settings():
|
||||
with _SETTINGS_LOCK:
|
||||
if _state is None:
|
||||
configure()
|
||||
return _state, _url, _control_path
|
||||
|
||||
@@ -13,7 +13,7 @@ import sys
|
||||
import shlex
|
||||
|
||||
from .config import default_config, load_config, save_config
|
||||
from . import tunnel
|
||||
from . import tunnel, __version__
|
||||
|
||||
_EMAIL = re.compile(r"[^\s@]+@[^\s@]+\.[^\s@]+\Z")
|
||||
|
||||
@@ -317,7 +317,7 @@ _OUTPUT = None
|
||||
|
||||
def _structured_result(value):
|
||||
"""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,
|
||||
"ok": not isinstance(value, dict) or value.get("ok") is not False,
|
||||
"result": value}
|
||||
@@ -394,7 +394,7 @@ def _human_status(value, scope=None):
|
||||
connection = connection if isinstance(connection, dict) else {}
|
||||
boot = value.get("boot", {})
|
||||
boot = boot if isinstance(boot, dict) else {}
|
||||
print("FrameBridge 0.3.1")
|
||||
print("FrameBridge " + __version__)
|
||||
print("Role: " + str(runtime.get("role", "unavailable")))
|
||||
print("Local tools: " + _runtime_label(runtime))
|
||||
if runtime.get("role") == "node":
|
||||
@@ -430,12 +430,23 @@ def _doctor_command(args, state):
|
||||
_show(value)
|
||||
else:
|
||||
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")))
|
||||
for item in value.get("missing_commands", []):
|
||||
print("Missing command: " + str(item))
|
||||
for item in value.get("issues", []):
|
||||
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.")
|
||||
return _result_code(value)
|
||||
|
||||
@@ -581,6 +592,8 @@ def build_parser():
|
||||
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.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.")
|
||||
ts = tty.add_subparsers(dest="tty_command", required=True, metavar="ACTION")
|
||||
tty_service = ts.add_parser("service", help="Manage the terminal daemon.")
|
||||
@@ -753,6 +766,12 @@ def _dispatch(parser, args):
|
||||
return _interaction_required("fb nodes run")
|
||||
return nodes_command(args, state)
|
||||
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
|
||||
if getattr(args, "console_login_action", None) and args.login_action:
|
||||
raise ValueError("Choose one console login command form.")
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""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", "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 != "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}
|
||||
@@ -49,8 +49,10 @@ def context(state, node="LOCAL", *, create=False):
|
||||
label = _node(state, node)
|
||||
key = hashlib.sha256(str(state).encode()).hexdigest()[:20]
|
||||
runtime = _private_runtime(Path("/run/user") / str(os.getuid()))
|
||||
root = runtime / "framebridge-expanded-console"
|
||||
profile = root / key
|
||||
from .console_desktop import legacy_context
|
||||
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:
|
||||
private_dir(state)
|
||||
for path in (root, profile):
|
||||
@@ -63,7 +65,7 @@ def context(state, node="LOCAL", *, create=False):
|
||||
return {
|
||||
"state": state, "node": label, "runtime": runtime,
|
||||
"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",
|
||||
}
|
||||
|
||||
@@ -75,10 +77,20 @@ def _active_graphical_display(env=None):
|
||||
env = os.environ if env is None else env
|
||||
display = env.get("DISPLAY", "")
|
||||
sid = env.get("XDG_SESSION_ID", "")
|
||||
if (re.fullmatch(r":[0-9]+(?:\.[0-9]+)?", display) is None
|
||||
or re.fullmatch(r"[A-Za-z0-9_-]{1,80}", sid) is None):
|
||||
if re.fullmatch(r":[0-9]+(?:\.[0-9]+)?", display) is None:
|
||||
return False
|
||||
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([
|
||||
"loginctl", "show-session", sid, "--no-pager",
|
||||
"-p", "User", "-p", "Type", "-p", "Active", "-p", "Remote",
|
||||
@@ -142,7 +154,7 @@ def status(state, node="LOCAL"):
|
||||
try:
|
||||
result = subprocess.run([
|
||||
"/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}",
|
||||
], capture_output=True, text=True, timeout=5)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
@@ -231,6 +243,7 @@ def run(state, node="LOCAL", *, inside=False, mode="auto"):
|
||||
result = subprocess.run([
|
||||
sys.executable, str(ui), "--node", ctx["node"],
|
||||
"--socket", str(ctx["socket"]), "--session", ctx["session"],
|
||||
"--jobs-root", str(ctx["jobs_root"]),
|
||||
"--layout", "horizontal" if inside else "vertical", "--apply",
|
||||
], env=env, capture_output=True, text=True, timeout=20)
|
||||
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('--socket')
|
||||
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('--probe', action='store_true', help='Read current pane metadata only')
|
||||
p.add_argument('--reflow', action='store_true', help='Resize existing panes without taking focus')
|
||||
@@ -68,10 +69,14 @@ class Console:
|
||||
if len(matches)!=1:
|
||||
raise RuntimeError('Console session identity is ambiguous')
|
||||
self.session_target=matches[0]
|
||||
windows=self.tmux('list-windows','-t',self.session_target,'-F','#{window_id}').splitlines()
|
||||
if len(windows)!=1:
|
||||
raise RuntimeError('Use a dedicated console session containing exactly one window')
|
||||
self.window=windows[0]
|
||||
windows=[line.split('|',1) for line in self.tmux(
|
||||
'list-windows','-t',self.session_target,'-F','#{window_id}|#{window_name}').splitlines()]
|
||||
if not windows or any(len(row)!=2 or not re.fullmatch(r'@[0-9]+',row[0]) for row in windows):
|
||||
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 False
|
||||
|
||||
@@ -87,7 +92,10 @@ class Console:
|
||||
return rows
|
||||
|
||||
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):
|
||||
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()
|
||||
except OSError:
|
||||
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())
|
||||
for role,pane in [('shell',shell),('monitor',monitor)]:
|
||||
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.
|
||||
if not previous_zoom:
|
||||
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,
|
||||
'--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-attached[90]','run-shell -b '+shlex.quote(command))
|
||||
self.tmux('select-window','-t',self.window)
|
||||
@@ -205,10 +216,13 @@ class Console:
|
||||
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:
|
||||
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('Ctrl+B o switches to the shell.',flush=True)
|
||||
time.sleep(2)
|
||||
@@ -220,7 +234,7 @@ def supervise(node):
|
||||
def main():
|
||||
args=arguments()
|
||||
if args.monitor:
|
||||
supervise(args.node)
|
||||
supervise(args.node, args.jobs_root)
|
||||
return 0
|
||||
console=Console(args)
|
||||
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"
|
||||
GRAPHICAL_COMMANDS = ("wmctrl", "xdotool", "xwininfo", "xclip")
|
||||
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):
|
||||
|
||||
@@ -8,10 +8,13 @@ from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
_BOOTSTRAP_LOCK = threading.RLock()
|
||||
|
||||
JOB_RE = re.compile(r"^[A-Za-z0-9._-]{1,80}$")
|
||||
READ_CAPABILITIES = frozenset({
|
||||
"system.status", "hardware.summary", "storage.inspect",
|
||||
@@ -56,6 +59,8 @@ def original_paths() -> tuple[Path, Path]:
|
||||
|
||||
def bootstrap_original() -> None:
|
||||
"""Extend the original package search path without copying or merging it."""
|
||||
# Registration now dispatches in workers; protect shared import paths.
|
||||
with _BOOTSTRAP_LOCK:
|
||||
broker_source, fleet_source = original_paths()
|
||||
for path in (fleet_source, broker_source):
|
||||
if str(path) in sys.path:
|
||||
@@ -333,7 +338,8 @@ def main() -> None:
|
||||
streamable_http_path="/mcp")
|
||||
hub = FleetHub(discover_nodes, node_read)
|
||||
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__":
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
"""Shared English guide content. Regenerate command docs after editing."""
|
||||
|
||||
from . import __version__
|
||||
|
||||
GUIDE = {'start': {'title': 'Start with FrameBridge',
|
||||
'sections': [{'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': ['fb help', 'fb status', 'fb --json status']},
|
||||
{'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.',
|
||||
'commands': ['sudo apt install ./FrameBridge-0.3.1.deb', 'fb connect']},
|
||||
'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': ["sudo apt install ./framebridge-expanded_" + __version__ + "_amd64.deb", 'fb connect']},
|
||||
{'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': ['sudo dpkg --remove framebridge-original-expanded']},
|
||||
{'heading': 'Human authentication',
|
||||
'body': 'The normal connection asks for an optional account label, a '
|
||||
@@ -310,18 +293,11 @@ GUIDE = {'start': {'title': 'Start with FrameBridge',
|
||||
'partial invented view as a real screenshot.',
|
||||
'commands': []},
|
||||
{'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': ['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',
|
||||
'body': 'screen-access is a narrow, explicit administrative permission '
|
||||
'for reading the real frame buffer. It does not by itself '
|
||||
@@ -351,11 +327,7 @@ GUIDE = {'start': {'title': 'Start with FrameBridge',
|
||||
'fb status runtime',
|
||||
'fb status boot']},
|
||||
{'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. Candidate eligibility checks the exact OS release, amd64 architecture, package profile and CPython ABI before starting native components. Debian 13/LMDE 7 use cp313, Ubuntu 24.04/Mint 22.x use cp312, and Ubuntu 26.04 uses cp314. A matching profile does not establish a tested installation; never repair an ABI mismatch by replacing system Python or weakening package dependencies.',
|
||||
'commands': ['fb doctor']},
|
||||
{'heading': 'Logs and progress',
|
||||
'body': 'Normal connection presents concise progress and a final '
|
||||
|
||||
@@ -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
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import subprocess
|
||||
from . import display_context as display
|
||||
from . import x11_text
|
||||
|
||||
META = ("input_may_have_been_sent", "do_not_replay", "performed", "display_context")
|
||||
|
||||
@@ -17,12 +19,15 @@ def _input_primitive(argv):
|
||||
"getwindowgeometry", "getwindowname", "getwindowpid",
|
||||
"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
|
||||
checker = display.check if checker is None else checker
|
||||
original_run = cua._run
|
||||
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():
|
||||
try:
|
||||
@@ -31,11 +36,60 @@ def install_cua_guard(cua, *, env=None, checker=None):
|
||||
state["failed_code"] = str(exc)
|
||||
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 guarded(argv, timeout=15):
|
||||
# The captured pointer helper calls its base runner after an internal
|
||||
# query. Guard that final runner too, immediately before subprocess.
|
||||
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):
|
||||
state["input_started"] = True
|
||||
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)
|
||||
|
||||
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()
|
||||
result = original_handle(message)
|
||||
verify()
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
"""Original FBPAIR identity and outbound protocol for an explicitly enrolled node."""
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import hashlib
|
||||
import http.client
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
import uuid
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
from . import broker
|
||||
|
||||
|
||||
_LOG = logging.getLogger(__name__)
|
||||
_RETRY_MAX_SECONDS = 30
|
||||
|
||||
|
||||
class NodeError(ValueError):
|
||||
pass
|
||||
|
||||
@@ -177,6 +186,54 @@ def dispatch_request(request, adapter_request=None, state_dir=None):
|
||||
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):
|
||||
state, config, ctx, identity = _context(state_dir)
|
||||
url = broker.validate_endpoint(config.get("broker_url", ""))
|
||||
@@ -187,16 +244,20 @@ def run(state_dir=None, once=False):
|
||||
raise NodeError("PAIRING_BINDING_MISMATCH")
|
||||
outbound = _original("outbound_agent")
|
||||
while True:
|
||||
poll = broker.post_json(url + "/fbpair/v1/node/poll",
|
||||
outbound._signed_envelope(ctx, "node.poll", {}), state_dir=state)
|
||||
poll = _post_with_recovery(url + "/fbpair/v1/node/poll", "node.poll", {},
|
||||
ctx=ctx, state=state, outbound=outbound, recover=not once)
|
||||
if poll.get("ok") is not True:
|
||||
raise NodeError("NODE_POLL_REJECTED")
|
||||
req = poll.get("request")
|
||||
result = {"ok": True, "idle": True}
|
||||
if isinstance(req, dict):
|
||||
value = dispatch_request(req, state_dir=state)
|
||||
posted = broker.post_json(url + "/fbpair/v1/node/result", outbound._signed_envelope(
|
||||
ctx, "node.result", {"request_id": req.get("request_id"), "result": value}), state_dir=state)
|
||||
# Original put_result only records this request's result; repeating
|
||||
# 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:
|
||||
raise NodeError("NODE_RESULT_REJECTED")
|
||||
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_FRAMEBUFFER_BYTES = 128 * 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")
|
||||
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")
|
||||
|
||||
@@ -13,12 +13,17 @@ import math
|
||||
import re
|
||||
import shlex
|
||||
import struct
|
||||
import time
|
||||
import uuid
|
||||
from typing import Callable
|
||||
|
||||
CHUNK_BYTES = 2100
|
||||
MAX_IMAGE_BYTES = 32 * 1024 * 1024
|
||||
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 '
|
||||
HEX_ID = re.compile(r"[0-9a-f]{32}\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,
|
||||
"idempotentHint": False, "openWorldHint": 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):
|
||||
"""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)
|
||||
self.details = details or {}
|
||||
# Local proof, never accepted from a remote JSON field.
|
||||
self.backend_rejection = backend_rejection
|
||||
|
||||
|
||||
def require(ok, code):
|
||||
@@ -62,7 +78,7 @@ class _AuthenticatedReply(dict):
|
||||
|
||||
_IDENTITY_FIELDS = ("capture_id", "node", "sha256", "session_id", "bytes",
|
||||
"width", "height", "source", "screen_type",
|
||||
"full_screen", "authentic_pixels")
|
||||
"full_screen", "authentic_pixels", "max_chunk_bytes")
|
||||
|
||||
def __init__(self, payload, owner, node, node_id):
|
||||
super().__init__(payload)
|
||||
@@ -80,23 +96,51 @@ class _AuthenticatedReply(dict):
|
||||
|
||||
|
||||
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,
|
||||
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.image_factory = image_factory
|
||||
self.resolve_node = resolve_node or (lambda node: node.upper())
|
||||
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")
|
||||
script = RPC_COMMAND + shlex.quote(payload) + "\n"
|
||||
digest = hashlib.sha256(script.encode("utf-8")).hexdigest()
|
||||
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,
|
||||
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.get("exit_code")) in (int, str) and
|
||||
str(outer["exit_code"]) in ("0", "1"), "SCREEN_EXEC_FAILED")
|
||||
@@ -109,7 +153,7 @@ class ScreenClient:
|
||||
except (ValueError, AttributeError):
|
||||
raise ScreenError("SCREEN_EXEC_NODE_ID_INVALID") from None
|
||||
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")
|
||||
try:
|
||||
envelope = json.loads(stdout)
|
||||
@@ -129,12 +173,19 @@ class ScreenClient:
|
||||
if valid_reference(code.get("cause"), ERROR):
|
||||
details["cause"] = code["cause"]
|
||||
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(type(envelope.get("result")) is dict, "SCREEN_RESPONSE_INVALID")
|
||||
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")
|
||||
total, digest = manifest.get("bytes"), manifest.get("sha256")
|
||||
require(valid_reference(capture_id, HEX_ID) and valid_reference(digest, SHA),
|
||||
@@ -151,11 +202,23 @@ class ScreenClient:
|
||||
"SCREEN_PIXEL_SOURCE_INVALID")
|
||||
require(manifest.get("full_screen") is True and manifest.get("authentic_pixels") is True,
|
||||
"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 = []
|
||||
count = math.ceil(total / CHUNK_BYTES)
|
||||
count = math.ceil(total / chunk_bytes)
|
||||
for index in range(count):
|
||||
part = self.remote(node, {"op": "read_chunk", "capture_id": capture_id,
|
||||
"index": index, "expected_sha256": digest})
|
||||
request = {"op": "read_chunk", "capture_id": capture_id,
|
||||
"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
|
||||
part.bound_to(self._reply_owner, node, manifest.node_id),
|
||||
"SCREEN_CHUNK_NODE_MISMATCH")
|
||||
@@ -167,12 +230,12 @@ class ScreenClient:
|
||||
type(part.get("more")) is bool and part["more"] == (index + 1 < count),
|
||||
"SCREEN_CHUNK_MANIFEST_INVALID")
|
||||
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:
|
||||
data = base64.b64decode(text, validate=True)
|
||||
except (ValueError, UnicodeError, binascii.Error):
|
||||
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")
|
||||
parts.append(data)
|
||||
data = b"".join(parts)
|
||||
@@ -181,10 +244,15 @@ class ScreenClient:
|
||||
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),
|
||||
"SCREEN_PNG_INVALID")
|
||||
require(time.monotonic() < deadline, "SCREEN_TRANSFER_TIMEOUT")
|
||||
return self.image_factory(data=data, format="png")
|
||||
|
||||
def call(self, op, node, authorized, **kwargs):
|
||||
requested_node = node
|
||||
action_confirmed = False
|
||||
input_reply_received = False
|
||||
submitted = {}
|
||||
deadline = time.monotonic() + self.transfer_timeout_s
|
||||
try:
|
||||
require(authorized is True, "AUTHORIZED_TRUE_REQUIRED")
|
||||
require(valid_reference(node, NODE), "SCREEN_NODE_INVALID")
|
||||
@@ -202,11 +270,20 @@ class ScreenClient:
|
||||
if op == "text":
|
||||
require(type(kwargs.get("text")) is str and 0 < len(kwargs["text"].encode("utf-8")) <= 4096,
|
||||
"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":
|
||||
require(manifest.get("capture_id") == kwargs["capture_id"],
|
||||
"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,
|
||||
"node_id": manifest.node_id, "hostname": manifest["node"],
|
||||
"result": dict(manifest, transport_node=node, node_id=manifest.node_id)}
|
||||
@@ -217,9 +294,21 @@ class ScreenClient:
|
||||
metadata["result"]["authentic_pixels"] = True
|
||||
return [metadata, image]
|
||||
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}
|
||||
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):
|
||||
@@ -233,12 +322,16 @@ def resolve_profile_node(alias):
|
||||
|
||||
|
||||
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."""
|
||||
if image_factory is None:
|
||||
from mcp.server.fastmcp import 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))
|
||||
def framebridge_screen_observe(node: str, authorized: bool,
|
||||
|
||||
@@ -13,7 +13,7 @@ import stat
|
||||
import sys
|
||||
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
|
||||
|
||||
MAX_REQUEST = 16384
|
||||
@@ -89,7 +89,10 @@ def write_record(path: Path, value, *, replace=False):
|
||||
|
||||
def public_manifest(manifest):
|
||||
# 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):
|
||||
@@ -138,7 +141,7 @@ def validate_request(request):
|
||||
allowed = {
|
||||
'observe': {'op', 'session_id'},
|
||||
'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'},
|
||||
'key': {'op', 'capture_id', 'session_id', 'key'},
|
||||
'text': {'op', 'capture_id', 'session_id', 'text'},
|
||||
@@ -149,6 +152,9 @@ def validate_request(request):
|
||||
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')
|
||||
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('expected_sha256')) is str and HEX_SHA.fullmatch(request['expected_sha256']), 'EXPECTED_SHA256_REQUIRED')
|
||||
if op == 'key':
|
||||
@@ -179,7 +185,7 @@ def dispatch(request, directory=None):
|
||||
if op == 'read_chunk':
|
||||
require(manifest['sha256'] == request['expected_sha256'], 'CAPTURE_SHA256_MISMATCH')
|
||||
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}
|
||||
if manifest.get('screen_type') == 'x11':
|
||||
raise ScreenRpcError('USE_GRAPHICAL_CUA_FOR_X11_INPUT')
|
||||
|
||||
@@ -17,7 +17,10 @@ import signal
|
||||
import subprocess
|
||||
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
|
||||
|
||||
REQUEST_TYPE = "screen_rpc"
|
||||
|
||||
@@ -16,6 +16,7 @@ import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -24,15 +25,19 @@ from .config import load_config, state_path, private_dir
|
||||
PREFIX = Path(os.environ.get("FRAMEBRIDGE_EXPANDED_PREFIX", Path(__file__).resolve().parents[2]))
|
||||
_STOP = False
|
||||
_LEGACY = None
|
||||
_LEGACY_LOCK = threading.RLock()
|
||||
|
||||
def _legacy():
|
||||
global _LEGACY
|
||||
# Publish only a fully initialized module; a failed load remains retryable.
|
||||
with _LEGACY_LOCK:
|
||||
if _LEGACY is None:
|
||||
path = PREFIX / "bin/framebridge-original"
|
||||
loader = importlib.machinery.SourceFileLoader("framebridge_original_launcher", str(path))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
_LEGACY = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(_LEGACY)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
_LEGACY = module
|
||||
return _LEGACY
|
||||
|
||||
def _args(state, config):
|
||||
@@ -151,18 +156,18 @@ def doctor(state):
|
||||
commands = ["bash", "tmux", "tee", "flock", "awk", "sha256sum", "timeout",
|
||||
"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"))]
|
||||
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)
|
||||
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"))]
|
||||
if config.get("headless") in ("no", False):
|
||||
missing.extend(graphical_missing)
|
||||
elif config.get("headless") in ("yes", True):
|
||||
missing.extend(graphical_missing + virtual_missing)
|
||||
os_release = platform.freedesktop_os_release()
|
||||
compatible = (sys.version_info[:2] == (3, 13) and platform.machine() == "x86_64"
|
||||
and os_release.get("ID") == "debian" and os_release.get("VERSION_ID") == "13")
|
||||
issues = []
|
||||
from .compatibility import assess, display_support
|
||||
compatibility = assess(PREFIX)
|
||||
compatible = compatibility["ok"]
|
||||
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 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.")
|
||||
@@ -173,14 +178,19 @@ def doctor(state):
|
||||
display_context = describe(env)
|
||||
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.")
|
||||
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,
|
||||
"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,
|
||||
"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,
|
||||
"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"},
|
||||
"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(),
|
||||
"role": config["role"], "issues": issues}
|
||||
|
||||
@@ -369,6 +379,10 @@ def _manager(state):
|
||||
from .display_context import bind_text
|
||||
bind_text(env, state, reason="PHYSICAL_X11_UNAVAILABLE_TEXT_TOOLS_ACTIVE")
|
||||
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)
|
||||
if display is None:
|
||||
raise RuntimeError("No private X11 display number is available.")
|
||||
@@ -421,7 +435,11 @@ def start(state):
|
||||
return current
|
||||
checks = doctor(state)
|
||||
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"):
|
||||
private_dir(state / name)
|
||||
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.")
|
||||
return result
|
||||
|
||||
def stop(state):
|
||||
def stop(state, *, preserve_connection=False):
|
||||
state = state_path(state)
|
||||
load_config(state)
|
||||
from .connection_background import disconnect
|
||||
connection = disconnect(state)
|
||||
from .connection_background import disconnect, status as connection_status
|
||||
# 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"):
|
||||
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
|
||||
mark_manual_stop(state)
|
||||
rec = _read(state / "manager.json")
|
||||
@@ -465,7 +496,10 @@ def stop(state):
|
||||
for child in reversed(_read(state / "worker.json").get("processes", [])):
|
||||
_terminate(None, child)
|
||||
_atomic(state / "ready.json", {"ready": False})
|
||||
return status(state)
|
||||
verify_tty_stop()
|
||||
result = status(state)
|
||||
result["tty_cleanup_verified"] = True
|
||||
return result
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
|
||||
@@ -64,6 +64,7 @@ _core, _server = _load_captured()
|
||||
TerminalDenied = _core.TerminalDenied
|
||||
from ._tty_backend import RealTerminalMixin
|
||||
from .tty_sudo_control import SudoControlError
|
||||
from . import tty_cleanup
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -192,6 +193,7 @@ class PortableManager(RealTerminalMixin, _core.Manager):
|
||||
idle_timeout_s=600)
|
||||
self.max_sessions = limits.max_sessions
|
||||
self._last_used = {}
|
||||
self.cleanup_daemon = None
|
||||
|
||||
@staticmethod
|
||||
def _local_context(context):
|
||||
@@ -213,9 +215,13 @@ class PortableManager(RealTerminalMixin, _core.Manager):
|
||||
if program != "bash":
|
||||
raise TerminalDenied("PROGRAM_NOT_SUPPORTED")
|
||||
self._size(rows, cols)
|
||||
self._record_cleanup(operation_pending=True)
|
||||
try:
|
||||
result = super().open("bash", rows, cols)
|
||||
self._last_used[result["session_id"]] = time.monotonic()
|
||||
return result
|
||||
finally:
|
||||
self._record_cleanup()
|
||||
|
||||
def reap(self):
|
||||
super().reap()
|
||||
@@ -238,8 +244,57 @@ class PortableManager(RealTerminalMixin, _core.Manager):
|
||||
raise TerminalDenied("SESSION_NOT_FOUND")
|
||||
result = super().abort(sid)
|
||||
self._last_used.pop(sid, None)
|
||||
self._record_cleanup()
|
||||
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):
|
||||
result = super().status()
|
||||
for key in ("owner_device_connected", "owner_signature_required"):
|
||||
@@ -297,6 +352,13 @@ class PortableServer(_server.LocalServer):
|
||||
os.umask(old)
|
||||
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 = {
|
||||
"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():
|
||||
raise TerminalDenied("TTY_SOCKET_UNTRUSTED")
|
||||
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.cleanup_daemon = tty_cleanup.daemon_identity(_identity(os.getpid()))
|
||||
manager._record_cleanup()
|
||||
def stopped(_signum, _frame):
|
||||
raise SystemExit(0)
|
||||
signal.signal(signal.SIGTERM, stopped)
|
||||
signal.signal(signal.SIGINT, stopped)
|
||||
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:
|
||||
with PortableServer(path, manager) as server:
|
||||
print(json.dumps({"ok": True, "ready": True, "socket": str(path),
|
||||
@@ -436,6 +504,10 @@ def start(state=None):
|
||||
current = status(state)
|
||||
if current.get("ok"):
|
||||
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()
|
||||
root = str(Path(__file__).resolve().parents[1])
|
||||
env["PYTHONPATH"] = root + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
|
||||
@@ -458,19 +530,48 @@ def start(state=None):
|
||||
def stop(state=None):
|
||||
state = state_path(state)
|
||||
p = state / "tty-process.json"
|
||||
if not p.is_file() or p.is_symlink():
|
||||
return {"ok": True, "state": "STOPPED", "already_stopped": True}
|
||||
expected = None
|
||||
already = True
|
||||
death_known = False
|
||||
try:
|
||||
record = json.loads(p.read_text())
|
||||
if not _alive(record):
|
||||
return {"ok": True, "state": "STOPPED", "already_stopped": True}
|
||||
os.kill(record["pid"], signal.SIGTERM)
|
||||
receipt = state / tty_cleanup.NAME
|
||||
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,
|
||||
"cleanup_verified": True}
|
||||
outcome = tty_cleanup.read(state)
|
||||
if p.exists() or p.is_symlink():
|
||||
record = tty_cleanup.read_private(p)
|
||||
if "boot" not in record:
|
||||
raise tty_cleanup.CleanupUnverified()
|
||||
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)
|
||||
return {"ok": not _alive(record), "state": "STOPPED" if not _alive(record) else "STOP_TIMEOUT"}
|
||||
except (ValueError, OSError, KeyError):
|
||||
return {"ok": False, "error": "TTY_STOP_FAILED"}
|
||||
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:
|
||||
child = _STARTED_CHILDREN.get(str(state))
|
||||
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
|
||||
import pwd
|
||||
import re
|
||||
import signal
|
||||
import shutil
|
||||
import shlex
|
||||
import stat
|
||||
@@ -122,6 +123,7 @@ class SudoConsoleController:
|
||||
self.timeout = float(timeout)
|
||||
self.prefix = "framebridge-tty-" + hashlib.sha256(str(self.base).encode()).hexdigest()[:12] + "-"
|
||||
self._records = {}
|
||||
self._uncertain_launches = set()
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def _run(self, command, *, data=None, optional=False):
|
||||
@@ -359,8 +361,12 @@ class SudoConsoleController:
|
||||
command.append("--setenv=" + key + "=" + value)
|
||||
command += [self.tmux, "-D", "-S", str(socket), "-f", "/dev/null"]
|
||||
launched = False
|
||||
verified_record = None
|
||||
try:
|
||||
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)
|
||||
deadline = time.monotonic() + self.timeout
|
||||
while time.monotonic() < deadline:
|
||||
@@ -382,23 +388,34 @@ class SudoConsoleController:
|
||||
deadline = time.monotonic() + self.timeout
|
||||
while time.monotonic() < deadline:
|
||||
current = self._inspect(record)
|
||||
verified_record = current
|
||||
if current["no_new_privs"] != expected:
|
||||
_fail("TTY_SUDO_PRIVILEGE_STATE_MISMATCH")
|
||||
if not current["busy"]:
|
||||
self._uncertain_launches.discard(unit_name)
|
||||
return current
|
||||
time.sleep(0.05)
|
||||
_fail("TTY_SUDO_CONSOLE_NOT_IDLE")
|
||||
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:
|
||||
unit = self._unit(unit_name)
|
||||
proc, _, _, _ = self._pane(record)
|
||||
scope = self._pane_scope(self._pane_group(proc["pid"]), unit.get("ControlGroup"))
|
||||
if scope:
|
||||
self._run([self.systemctl, "--user", "stop", scope], optional=True)
|
||||
self._run([self.systemctl, "--user", "--no-block", "stop", scope], optional=True)
|
||||
except SudoControlError:
|
||||
pass
|
||||
self._run([self.systemctl, "--user", "stop", unit_name], optional=True)
|
||||
self._run([self.systemctl, "--user", "--no-block", "stop", unit_name], optional=True)
|
||||
if unit_name not in self._uncertain_launches:
|
||||
socket.unlink(missing_ok=True)
|
||||
marker.unlink(missing_ok=True)
|
||||
rcfile.unlink(missing_ok=True)
|
||||
@@ -433,6 +450,34 @@ class SudoConsoleController:
|
||||
_fail("TTY_SUDO_ELEVATED_PROCESS_PRESENT")
|
||||
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):
|
||||
try:
|
||||
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"}:
|
||||
_fail(current["busy_reason"])
|
||||
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"):
|
||||
self._run([self.systemctl, "--user", "stop", current["pane_scope_unit"]], optional=True)
|
||||
self._run([self.systemctl, "--user", "stop", record["unit_name"]], optional=True)
|
||||
self._run([self.systemctl, "--user", "--no-block", "stop",
|
||||
current["pane_scope_unit"]], optional=True)
|
||||
self._run([self.systemctl, "--user", "--no-block", "stop",
|
||||
record["unit_name"]], optional=True)
|
||||
deadline = time.monotonic() + self.timeout
|
||||
escalate_at = time.monotonic() + min(1.0, self.timeout / 3)
|
||||
escalated = False
|
||||
while time.monotonic() < deadline:
|
||||
remaining = set()
|
||||
for group in {current["control_group"], current["pane_control_group"]}:
|
||||
if (Path("/sys/fs/cgroup") / group.lstrip("/")).exists():
|
||||
remaining.update(self._scope(group))
|
||||
if not remaining and _proc(current["pid"]) is None:
|
||||
remaining = self._close_members(current)
|
||||
shell = _proc(current["pid"])
|
||||
server = _proc(current["server_pid"])
|
||||
shell_alive = (shell and shell["birth"] == current["birth"]
|
||||
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
|
||||
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)
|
||||
else:
|
||||
_fail("TTY_SUDO_SCOPE_STILL_RUNNING")
|
||||
@@ -505,7 +593,11 @@ class SudoConsoleController:
|
||||
except BaseException as error:
|
||||
previous = _proc(current["pid"])
|
||||
if previous and previous["birth"] == current["birth"] and previous["state"] not in {"Z", "X"}:
|
||||
try:
|
||||
self._close(candidate)
|
||||
except BaseException:
|
||||
self._uncertain_launches.add(candidate["unit_name"])
|
||||
raise
|
||||
raise
|
||||
# The former Bash is gone. Keep the verified replacement and
|
||||
# retain old ownership metadata until all old scopes are empty.
|
||||
|
||||
@@ -9,6 +9,9 @@ import stat
|
||||
import subprocess
|
||||
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"
|
||||
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:
|
||||
return False
|
||||
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
|
||||
if args[1:6] != ["-B", "-m", "framebridge_expanded.autostart", "_run", "--state"]:
|
||||
return False
|
||||
@@ -105,6 +108,45 @@ def cleanup_user(home=None, *, run=None, bus_available=None):
|
||||
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):
|
||||
if os.getuid() != 0:
|
||||
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
|
||||
if failed:
|
||||
raise RuntimeError("Some FrameBridge services could not be stopped; package removal was paused.")
|
||||
purge_credential_helpers(run=run)
|
||||
|
||||
|
||||
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()
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/python3
|
||||
"""Archive the reviewed community source layout without runtime profiles or QA logs."""
|
||||
import argparse
|
||||
import calendar
|
||||
import gzip
|
||||
import hashlib
|
||||
@@ -7,12 +8,15 @@ import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
import sys
|
||||
|
||||
from build_profiles import CANDIDATE_VERSION, SOURCE_VENDOR, target_profile, validate_vendor, vendor_versions
|
||||
|
||||
BASE = Path(__file__).resolve().parents[1]
|
||||
ROOT = BASE.parent
|
||||
EPOCH = calendar.timegm((2026, 10, 26, 0, 0, 0))
|
||||
ARCHIVE_ROOT = "FrameBridge-0.3.1"
|
||||
ARCHIVE_NAME = "FrameBridge-0.3.1-source.tar.gz"
|
||||
EPOCH = calendar.timegm((2026, 10, 27, 0, 0, 0))
|
||||
ARCHIVE_ROOT = "FrameBridge-" + CANDIDATE_VERSION
|
||||
ARCHIVE_NAME = ARCHIVE_ROOT + "-source.tar.gz"
|
||||
SOURCE_FOLDERS = ("sources", "expanded", "portable", "packaging", "docs", "tests")
|
||||
ROOT_FILES = ("README.md", "LICENSE", "LICENSING.md", "COPYRIGHT.md",
|
||||
"LICENSE-STATUS.md", "OFFICIAL-RELEASES.md", "CONTRIBUTING.md", "SECURITY.md", "RELEASE-NOTES.md")
|
||||
@@ -20,7 +24,7 @@ ROOT_FOLDERS = ("docs", "provenance", "licensing")
|
||||
BLOG_POLICY_FILES = ("README.md", "LICENSING.md", "POST-TEMPLATE.md", "LICENSE-CC-BY-SA-4.0.txt")
|
||||
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'}]
|
||||
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")
|
||||
GPL_SHA256 = "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986"
|
||||
|
||||
def sha(path):
|
||||
@@ -33,7 +37,7 @@ def source_files():
|
||||
relative = path.relative_to(ROOT)
|
||||
if path.is_symlink():
|
||||
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
|
||||
if path.is_file():
|
||||
selected[relative.as_posix()] = path
|
||||
@@ -75,22 +79,54 @@ def source_files():
|
||||
raise RuntimeError("Required third-party source/notice input missing: " + name)
|
||||
return selected
|
||||
|
||||
def main():
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
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)
|
||||
names = source_files()
|
||||
release = BASE / "release"
|
||||
release.mkdir(exist_ok=True)
|
||||
expected_versions = vendor_versions(BASE / SOURCE_VENDOR)
|
||||
provenance = {"schema": 1, "version": CANDIDATE_VERSION, "validation_status": "candidate",
|
||||
"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"]:
|
||||
print("Candidate source excludes alternate vendor ABIs: " + ", ".join(provenance["omitted_vendor_abis"]), file=sys.stderr)
|
||||
generated = {ARCHIVE_README_NAME: ARCHIVE_README,
|
||||
"TARGET-VENDOR-PROVENANCE.json": (json.dumps(provenance, indent=2, sort_keys=True) + "\n").encode()}
|
||||
release = BASE / "release" / "candidate-source"
|
||||
release.mkdir(parents=True, exist_ok=True)
|
||||
output = release / ARCHIVE_NAME
|
||||
with output.open("wb") as raw:
|
||||
with gzip.GzipFile(filename="", mode="wb", fileobj=raw,
|
||||
mtime=EPOCH, compresslevel=6) as zipped:
|
||||
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 = tarfile.TarInfo(ARCHIVE_ROOT + "/" + relative)
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = ""
|
||||
info.mtime = EPOCH
|
||||
info.mode = 0o644
|
||||
info.size = len(ARCHIVE_README)
|
||||
archive.addfile(info, io.BytesIO(ARCHIVE_README))
|
||||
info.size = len(data)
|
||||
archive.addfile(info, io.BytesIO(data))
|
||||
for relative, path in sorted(names.items()):
|
||||
info = archive.gettarinfo(str(path), arcname=ARCHIVE_ROOT + "/" + relative)
|
||||
info.uid = info.gid = 0
|
||||
@@ -99,10 +135,10 @@ def main():
|
||||
info.mode = 0o755 if path.stat().st_mode & 0o111 else 0o644
|
||||
with path.open("rb") as stream:
|
||||
archive.addfile(info, stream)
|
||||
manifest = {"status": "COMMUNITY_RELEASE_BUILD", "archive": output.name,
|
||||
manifest = {"status": "COMPATIBILITY_CANDIDATE_BUILD", "version": CANDIDATE_VERSION, "vendor_inputs": provenance, "archive": output.name,
|
||||
"bytes": output.stat().st_size, "sha256": sha(output),
|
||||
"files": [{"path": ARCHIVE_README_NAME,
|
||||
"sha256": hashlib.sha256(ARCHIVE_README).hexdigest(), "generated": True}]
|
||||
"files": [{"path": name, "sha256": hashlib.sha256(data).hexdigest(), "generated": True}
|
||||
for name, data in sorted(generated.items())]
|
||||
+ [{"path": relative, "sha256": sha(path)}
|
||||
for relative, path in sorted(names.items())],
|
||||
"draft_blog_articles_included": False,
|
||||
@@ -111,7 +147,7 @@ def main():
|
||||
"dependency_source_archives": DEPENDENCY_SOURCE_ASSETS}
|
||||
(release / "SOURCE-ARCHIVE-MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
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"]}))
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
#!/usr/bin/python3
|
||||
"""Reproducible Debian package builder; never installs or starts the payload."""
|
||||
import argparse
|
||||
import ast
|
||||
import calendar
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
from build_profiles import CANDIDATE_VERSION, PROFILES, SOURCE_VENDOR, VENDOR_RELATIVE, target_profile, validate_vendor, vendor_versions
|
||||
|
||||
BASE = Path(__file__).resolve().parents[1]
|
||||
ROOT = BASE.parent
|
||||
VERSION = "0.3.1"
|
||||
VERSION = CANDIDATE_VERSION
|
||||
# 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"
|
||||
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"
|
||||
RELEASE = BASE / "release"
|
||||
STAGE = BASE / "build/package-root"
|
||||
@@ -92,7 +101,7 @@ def install_legal_material(docs):
|
||||
shutil.copy2(ROOT / "blog" / name, docs / "blog" / name)
|
||||
shutil.copy2(ROOT / "RELEASE-NOTES.md", docs / "RELEASE-NOTES.md")
|
||||
(docs / "copyright").write_text(
|
||||
"FrameBridge Original Expanded — Community Release 0.3.1\n\n"
|
||||
"FrameBridge Original Expanded — compatibility candidate " + VERSION + "\n\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"
|
||||
"identify attribution, scope and third-party exceptions.\n\n"
|
||||
@@ -105,8 +114,8 @@ def install_legal_material(docs):
|
||||
"The assembled notices and license texts are included under\n"
|
||||
"[licensing/third-party](licensing/third-party/THIRD-PARTY-NOTICES.md).\n"
|
||||
"Original vendored notices are preserved with their components.\n")
|
||||
lines = ["# Source availability for FrameBridge 0.3.1", "",
|
||||
"Project source archive: FrameBridge-0.3.1-source.tar.gz.", "",
|
||||
lines = ["# Source availability for FrameBridge " + VERSION, "",
|
||||
"Candidate source must be retained with its build identity; no public archive is asserted.", "",
|
||||
"The release source set also includes these separate assets:", ""]
|
||||
for item in DEPENDENCY_SOURCE_ASSETS:
|
||||
lines.extend(["- " + item["filename"],
|
||||
@@ -122,32 +131,133 @@ def install_legal_material(docs):
|
||||
"signature status are external release records, not builder claims.", ""])
|
||||
(docs / "SOURCE-AVAILABILITY.md").write_text("\n".join(lines))
|
||||
(docs / "COMMUNITY-RELEASE-STATUS.json").write_text(json.dumps({
|
||||
"schema_version": 1, "version": VERSION, "status": "COMMUNITY_RELEASE_BUILD",
|
||||
"schema_version": 1, "version": VERSION, "status": "COMPATIBILITY_CANDIDATE_BUILD",
|
||||
"certified_as_RC7": False,
|
||||
"public_readiness": "EXTERNAL_RELEASE_RECORD",
|
||||
"signature_status": "EXTERNAL_RELEASE_RECORD",
|
||||
"vm_lifecycle_validation": "NOT_ASSERTED_BY_BUILDER",
|
||||
"source_archive": "FrameBridge-0.3.1-source.tar.gz",
|
||||
"source_archive": "CANDIDATE_SOURCE_REQUIRED_BEFORE_PUBLICATION",
|
||||
"dependency_source_archives": DEPENDENCY_SOURCE_ASSETS
|
||||
}, 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 active profiles without stopping the process carrying APT."""
|
||||
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" lt ' + 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
|
||||
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("--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
|
||||
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
|
||||
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()
|
||||
baseline = json.loads((BASE / "evidence/BASELINE.json").read_text())
|
||||
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():
|
||||
shutil.rmtree(STAGE)
|
||||
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")]:
|
||||
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"))
|
||||
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"):
|
||||
shutil.copytree(BASE / source, PREFIX / source, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||||
(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)
|
||||
(PREFIX / "bin" / name).chmod(0o755)
|
||||
rules = STAGE / "usr/lib/udev/rules.d"
|
||||
@@ -177,6 +287,15 @@ def main():
|
||||
for file in sorted((BASE / "docs").glob("*")):
|
||||
if file.is_file():
|
||||
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()
|
||||
(docs / "README.Debian.gz").write_bytes(gzip.compress(readme, mtime=EPOCH))
|
||||
install_legal_material(docs)
|
||||
@@ -188,12 +307,14 @@ def main():
|
||||
control = STAGE / "DEBIAN"
|
||||
control.mkdir()
|
||||
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(
|
||||
"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"
|
||||
"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"
|
||||
" Original execution and desktop programs, a real persistent terminal,\n"
|
||||
" authenticated node enrollment, and a manual ChatGPT tunnel wizard.\n"
|
||||
@@ -203,16 +324,11 @@ def main():
|
||||
" Optional virtual displays are explicitly identified as virtual.\n"
|
||||
" No phone or Owner Device pairing. Services start after connection setup.\n"
|
||||
" Installing the package alone does not activate a connection.\n")
|
||||
(control / "prerm").write_text(
|
||||
"#!/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").write_text(maintainer_prerm(PRIVILEGED_HELPER_FLOOR))
|
||||
(control / "prerm").chmod(0o755)
|
||||
for hook, contents in (("preinst", maintainer_preinst()), ("postinst", maintainer_postinst())):
|
||||
(control / hook).write_text(contents)
|
||||
(control / hook).chmod(0o755)
|
||||
rows = []
|
||||
for path in sorted(STAGE.rglob("*")):
|
||||
if path.is_file() and not path.is_symlink() and control not in path.parents:
|
||||
@@ -230,12 +346,13 @@ def main():
|
||||
os.utime(path, (EPOCH, EPOCH), follow_symlinks=False)
|
||||
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()]
|
||||
(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")
|
||||
output = RELEASE / ("framebridge-expanded_" + VERSION + "_amd64.deb")
|
||||
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)
|
||||
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": "candidate", "public_filename": PUBLIC_FILENAME, "status": "COMPATIBILITY_CANDIDATE_BUILD"}))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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.3"
|
||||
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
@@ -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 "$@"
|
||||
@@ -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-28" "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()
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
import base64
|
||||
from functools import partial, wraps
|
||||
import inspect
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -8,6 +10,9 @@ import socket
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
import anyio
|
||||
from pathlib import Path
|
||||
|
||||
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. "
|
||||
"The six Fleet inspection tools are read-only. Guide instructions do not grant authorization."
|
||||
),
|
||||
website_url="https://framebridge.betancourt.one",
|
||||
website_url="https://betancourt.one/framebridge/",
|
||||
host=HOST,
|
||||
port=PORT,
|
||||
streamable_http_path="/mcp",
|
||||
@@ -55,6 +60,45 @@ mcp = FastMCP(
|
||||
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]:
|
||||
"""Registered operations are not proof of their current execution readiness."""
|
||||
return sorted(mcp._tool_manager._tools)
|
||||
@@ -395,7 +439,7 @@ def _framebridge_exec_geranas_impl(
|
||||
if not 1<=timeout_s<=600:
|
||||
return {"ok":False,"error":"INVALID_TIMEOUT"}
|
||||
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):
|
||||
return {"ok":False,"error":"INVALID_JOB_ID"}
|
||||
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})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_blocking_tools.tool()
|
||||
def framebridge_exec(
|
||||
script: str,
|
||||
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}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_blocking_tools.tool()
|
||||
def framebridge_cua(
|
||||
action: str,
|
||||
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
|
||||
_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
|
||||
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
|
||||
register_tty_tools(mcp, framebridge_exec)
|
||||
register_tty_tools(_blocking_tools, framebridge_exec, screen_client=_real_screen_client)
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="streamable-http")
|
||||
from framebridge_expanded.http_server import run_streamable_mcp
|
||||
run_streamable_mcp(mcp)
|
||||
Binary file not shown.
@@ -120,9 +120,13 @@ class AdaptiveCoreTests(unittest.TestCase):
|
||||
|
||||
def tearDown(self): self.temp.cleanup()
|
||||
|
||||
def doctor(self,missing):
|
||||
def doctor(self,missing,*,compatibility=None):
|
||||
def which(name,path=None): return None if name in missing else '/fixture/bin/'+name
|
||||
with patch.object(runtime,'environment',return_value=dict(self.env)),patch.object(runtime.shutil,'which',side_effect=which),patch('framebridge_expanded.network.inspect',return_value={}):
|
||||
# Graphical-capability tests use an eligible artifact fixture. Source
|
||||
# checkouts do not contain the installed vendor layout or manifest.
|
||||
eligibility = compatibility if compatibility is not None else {
|
||||
'ok': True, 'profile_id': 'debian13-amd64-py313', 'issues': [], 'warnings': []}
|
||||
with patch.object(runtime,'environment',return_value=dict(self.env)),patch.object(runtime.shutil,'which',side_effect=which),patch('framebridge_expanded.network.inspect',return_value={}),patch('framebridge_expanded.compatibility.assess',return_value=eligibility):
|
||||
return runtime.doctor(self.state)
|
||||
|
||||
def test_optional_gui_dependencies_do_not_block_text_tools(self):
|
||||
@@ -135,6 +139,14 @@ class AdaptiveCoreTests(unittest.TestCase):
|
||||
self.assertFalse(result['capabilities']['graphical_cua'])
|
||||
self.assertTrue(result['optional_missing_commands']['graphical_cua'])
|
||||
|
||||
def test_text_mode_does_not_bypass_incompatible_artifact(self):
|
||||
rejected = {'ok': False, 'profile_id': None, 'issues': ['Fixture artifact ABI mismatch'], 'warnings': []}
|
||||
result = self.doctor(set(dc.GRAPHICAL_COMMANDS + dc.VIRTUAL_COMMANDS), compatibility=rejected)
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('Fixture artifact ABI mismatch', result['issues'])
|
||||
self.assertEqual([], result['missing_commands'])
|
||||
self.assertFalse(result['capabilities']['graphical_cua'])
|
||||
|
||||
def test_missing_core_command_still_blocks_startup(self):
|
||||
result=self.doctor({'bash'})
|
||||
self.assertFalse(result['ok']);self.assertIn('bash',result['missing_commands'])
|
||||
|
||||
@@ -69,7 +69,8 @@ class GuideTests(unittest.TestCase):
|
||||
self.assertNotIn(marker,text)
|
||||
def test_human_agent_and_transcript_boundary_are_explicit(self):
|
||||
text=guide.render_text('all')
|
||||
self.assertIn('human operators and authorized agents',text)
|
||||
self.assertIn('one human administrator',text)
|
||||
self.assertIn('authorized agents',text)
|
||||
self.assertIn('terminal transcript',text)
|
||||
self.assertIn('not an authentic full-screen image',text)
|
||||
self.assertIn('normal operating-system authentication',text)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Candidate archive input selection; no host package or runtime changes."""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "packaging"))
|
||||
import archive_sources
|
||||
|
||||
|
||||
class CandidateArchiveTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
self.base = self.root / "source"
|
||||
self.base.mkdir()
|
||||
self.input = self.base / "example.py"
|
||||
self.input.write_text("# candidate source\n")
|
||||
|
||||
def run_archive(self, args):
|
||||
evidence = {"vendor_sha256": "a" * 64, "packages": [], "native_extensions": []}
|
||||
with mock.patch.object(archive_sources, "BASE", self.base), \
|
||||
mock.patch.object(archive_sources, "source_files", return_value={"source/example.py": self.input}), \
|
||||
mock.patch.object(archive_sources, "vendor_versions", return_value={}), \
|
||||
mock.patch.object(archive_sources, "validate_vendor", return_value=evidence), \
|
||||
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()) as stderr:
|
||||
archive_sources.main(args)
|
||||
return self.base / "release/candidate-source" / archive_sources.ARCHIVE_NAME, stderr.getvalue()
|
||||
|
||||
def test_default_archive_declares_missing_alternate_abis(self):
|
||||
output, warning = self.run_archive([])
|
||||
self.assertIn("cp312", warning)
|
||||
with tarfile.open(output) as archive:
|
||||
data = json.load(archive.extractfile(archive_sources.ARCHIVE_ROOT + "/TARGET-VENDOR-PROVENANCE.json"))
|
||||
readme = archive.extractfile(archive_sources.ARCHIVE_ROOT + "/README-SOURCE-ARCHIVE.md").read().decode()
|
||||
self.assertEqual(data["omitted_vendor_abis"], ["cp312", "cp314"])
|
||||
self.assertIn("--target debian13-amd64-py313", readme)
|
||||
self.assertIn("not a signed public release", readme)
|
||||
self.assertEqual(data["validation_status"], "candidate")
|
||||
|
||||
def test_explicit_vendors_included_without_private_markers_or_bin(self):
|
||||
args = []
|
||||
for abi in ("cp312", "cp314"):
|
||||
vendor = self.root / abi; vendor.mkdir()
|
||||
(vendor / "module.py").write_text("# reviewed input\n")
|
||||
(vendor / "private.log").write_text("private canary\n")
|
||||
(vendor / "FB-ABI-PREPARED.json").write_text('{"private_path":"/home/example"}')
|
||||
(vendor / "bin").mkdir(); (vendor / "bin/tool").write_text("#!/tmp/private/python\n")
|
||||
args.extend(["--vendor-" + abi, str(vendor)])
|
||||
output, warning = self.run_archive(args)
|
||||
self.assertFalse(warning)
|
||||
with tarfile.open(output) as archive:
|
||||
names = archive.getnames()
|
||||
data = json.load(archive.extractfile(archive_sources.ARCHIVE_ROOT + "/TARGET-VENDOR-PROVENANCE.json"))
|
||||
self.assertIn(archive_sources.ARCHIVE_ROOT + "/source/target-vendors/cp312/module.py", names)
|
||||
self.assertIn(archive_sources.ARCHIVE_ROOT + "/source/target-vendors/cp314/module.py", names)
|
||||
self.assertFalse(any("FB-ABI-PREPARED" in name or name.endswith("bin/tool") or name.endswith("private.log") for name in names))
|
||||
self.assertEqual(set(data["vendors"]), {"cp312", "cp313", "cp314"})
|
||||
self.assertEqual(data["omitted_vendor_abis"], [])
|
||||
|
||||
def test_alternate_vendor_symlink_is_rejected(self):
|
||||
vendor = self.root / "cp312"; vendor.mkdir()
|
||||
(vendor / "unsafe.py").symlink_to(self.input)
|
||||
with self.assertRaisesRegex(RuntimeError, "symlink rejected"):
|
||||
self.run_archive(["--vendor-cp312", str(vendor)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -34,6 +34,7 @@ class AutostartTests(unittest.TestCase):
|
||||
mock.patch.object(autostart, "_systemctl", side_effect=self.fake_systemctl).start()
|
||||
mock.patch.object(autostart, "_linger", return_value=False).start()
|
||||
mock.patch.object(autostart, "_enabled_units", return_value=[]).start()
|
||||
mock.patch.object(autostart, "_helper_ready").start()
|
||||
mock.patch.object(autostart.os, "geteuid", return_value=1000).start()
|
||||
mock.patch.object(autostart.resource, "setrlimit").start()
|
||||
self.inputs = mock.patch("builtins.input", side_effect=["y"]).start()
|
||||
@@ -43,9 +44,13 @@ class AutostartTests(unittest.TestCase):
|
||||
|
||||
def fake_command(self, args, **kwargs):
|
||||
self.calls.append((args, kwargs))
|
||||
if "-ll" in args:
|
||||
command = " ".join(args[args.index("--") + 1:])
|
||||
output = "Sudoers entry: /etc/sudoers.d/synthetic-test\n RunAsUsers: root\n Options: !authenticate, !log_input, !log_output, !setenv\n Commands:\n " + command + "\n Matched: " + command + "\n"
|
||||
return subprocess.CompletedProcess(args, 0, output.encode(), b"")
|
||||
if args[:2] == ["systemd-creds", "--version"]:
|
||||
return subprocess.CompletedProcess(args, 0, b"systemd 257\n", b"")
|
||||
if "encrypt" in args:
|
||||
if "encrypt" in args and "-ll" not in args:
|
||||
return subprocess.CompletedProcess(args, 0, b"CIPHERTEXT-ONLY-" * 20, b"")
|
||||
return subprocess.CompletedProcess(args, 0, b"", b"")
|
||||
|
||||
@@ -59,7 +64,7 @@ class AutostartTests(unittest.TestCase):
|
||||
state, directory, unit = autostart._paths(self.state)
|
||||
self.assertNotEqual(before, (self.state / "config.json").read_bytes())
|
||||
self.assertEqual("auto", config.load_config(self.state)["headless"])
|
||||
encryption = [(a, kw) for a, kw in self.calls if "encrypt" in a]
|
||||
encryption = [(a, kw) for a, kw in self.calls if "encrypt" in a and "-ll" not in a]
|
||||
self.assertEqual(1, len(encryption))
|
||||
args, options = encryption[0]
|
||||
self.assertEqual(MARKER.encode(), options["data"])
|
||||
@@ -111,7 +116,7 @@ class AutostartTests(unittest.TestCase):
|
||||
|
||||
def test_encryption_failure_never_creates_unit_or_disables_an_existing_service(self):
|
||||
def failing(args, **kwargs):
|
||||
if "encrypt" in args:
|
||||
if "encrypt" in args and "-ll" not in args:
|
||||
raise RuntimeError("Credential encryption failed.")
|
||||
return self.fake_command(args, **kwargs)
|
||||
self.command.side_effect = failing
|
||||
@@ -187,7 +192,7 @@ class AutostartTests(unittest.TestCase):
|
||||
self._durable_connect()
|
||||
self.inputs.assert_not_called()
|
||||
self.key_prompt.assert_not_called()
|
||||
encryptions = [(args, kw) for args, kw in self.calls if "encrypt" in args]
|
||||
encryptions = [(args, kw) for args, kw in self.calls if "encrypt" in args and "-ll" not in args]
|
||||
self.assertEqual(1, len(encryptions))
|
||||
self.assertEqual(MARKER.encode(), encryptions[0][1]["data"])
|
||||
self.assertNotIn(MARKER, " ".join(encryptions[0][0]))
|
||||
@@ -220,7 +225,7 @@ class AutostartTests(unittest.TestCase):
|
||||
def test_durable_encryption_failure_leaves_current_profile_and_service_alone(self):
|
||||
original = (self.state / "config.json").read_bytes()
|
||||
def failed(args, **kwargs):
|
||||
if "encrypt" in args:
|
||||
if "encrypt" in args and "-ll" not in args:
|
||||
raise RuntimeError("Protection failed.")
|
||||
return self.fake_command(args, **kwargs)
|
||||
self.command.side_effect = failed
|
||||
@@ -238,7 +243,7 @@ class AutostartTests(unittest.TestCase):
|
||||
events = []
|
||||
original_command = self.fake_command
|
||||
def command(args, **kwargs):
|
||||
if "encrypt" in args:
|
||||
if "encrypt" in args and "-ll" not in args:
|
||||
events.append("encrypt")
|
||||
return subprocess.CompletedProcess(args, 0, b"REPLACEMENT-CIPHERTEXT-" * 20, b"")
|
||||
return original_command(args, **kwargs)
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Synthetic backend/lifecycle tests. No host credentials or sudo are used."""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from framebridge_expanded import autostart, config, connection_background
|
||||
|
||||
ID = "tunnel_0123456789abcdef0123456789abcdef"
|
||||
KEY = "SYNTHETIC-FRAMEBRIDGE-HOST-BACKEND-NOT-A-REAL-KEY"
|
||||
|
||||
|
||||
class HostCredentialAutostartTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(); self.addCleanup(self.temp.cleanup)
|
||||
self.home = Path(self.temp.name) / "home"; self.home.mkdir(mode=0o700)
|
||||
self.state = self.home / "profile"
|
||||
value = config.default_config(); value.update(tunnel_id=ID, headless="no")
|
||||
config.save_config(self.state, value)
|
||||
self.settings = {"account_label": "owner@example.test", "tunnel_id": ID,
|
||||
"control_plane_organization_id": "", "control_plane_url_path": ""}
|
||||
self.calls = []; self.system_calls = []; self.generation = 0
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
mock.patch.object(Path, "home", return_value=self.home).start()
|
||||
self.command = mock.patch.object(autostart, "_command", side_effect=self.fake_command).start()
|
||||
self.systemctl = mock.patch.object(autostart, "_systemctl", side_effect=self.fake_systemctl).start()
|
||||
self.helper = mock.patch.object(autostart, "_helper_ready").start()
|
||||
mock.patch.object(autostart, "_linger", return_value=False).start()
|
||||
mock.patch.object(autostart, "_enabled_units", return_value=[]).start()
|
||||
mock.patch.object(autostart.os, "geteuid", return_value=1000).start()
|
||||
mock.patch.object(autostart.resource, "setrlimit").start()
|
||||
mock.patch("builtins.input", return_value="y").start()
|
||||
self.prompt = mock.patch.object(autostart.getpass, "getpass", return_value=KEY).start()
|
||||
mock.patch.object(autostart, "status", return_value={"configured": True}).start()
|
||||
mock.patch.object(connection_background, "status", return_value={"alive": False}).start()
|
||||
mock.patch.object(connection_background, "disconnect", return_value={"ok": True, "alive": False}).start()
|
||||
mock.patch("sys.stdout", new_callable=io.StringIO).start()
|
||||
|
||||
def fake_command(self, args, **kwargs):
|
||||
self.calls.append((args, kwargs))
|
||||
output = b""
|
||||
if args[:2] == ["systemd-creds", "--version"]: output = b"systemd 255\n"
|
||||
elif "-ll" in args:
|
||||
command = " ".join(args[args.index("--") + 1:])
|
||||
output = ("Sudoers entry: /etc/sudoers.d/synthetic-test\n RunAsUsers: root\n Options: !authenticate, !log_input, !log_output, !setenv\n Commands:\n " + command + "\n Matched: " + command + "\n").encode()
|
||||
elif "protect" in args or "encrypt" in args:
|
||||
self.generation += 1
|
||||
output = ("SYNTHETIC-HOST-CIPHER-%03d-" % self.generation).encode() * 8
|
||||
elif "decrypt" in args: output = KEY.encode()
|
||||
return subprocess.CompletedProcess(args, 0, output, b"")
|
||||
|
||||
def fake_systemctl(self, *args, **kwargs):
|
||||
self.system_calls.append(args)
|
||||
return subprocess.CompletedProcess(args, 0, b"", b"")
|
||||
|
||||
def operations(self, name):
|
||||
return [(args, options) for args, options in self.calls if name in args and "-ll" not in args]
|
||||
|
||||
def test_255_setup_uses_helper_stdin_and_preserves_user_unit(self):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
_, directory, unit = autostart._paths(self.state)
|
||||
record = autostart._manifest(directory)
|
||||
self.assertEqual(autostart.HOST_BACKEND, record["credential_backend"])
|
||||
args, options = self.operations("protect")[0]
|
||||
self.assertEqual(["sudo", "-n", "--", str(autostart.HELPER), "protect", autostart._profile_id(self.state)], args)
|
||||
self.assertEqual(KEY.encode(), options["data"])
|
||||
self.assertFalse(self.operations("encrypt"))
|
||||
self.assertNotIn(KEY, " ".join(args))
|
||||
self.assertNotIn("User=", unit.read_text())
|
||||
self.assertNotIn("LoadCredentialEncrypted", unit.read_text())
|
||||
for path in (unit, directory / "credential.cred", directory / "installation.json", self.state / "config.json"):
|
||||
self.assertNotIn(KEY.encode(), path.read_bytes())
|
||||
|
||||
def test_helper_missing_fails_before_privileges_or_key_prompt(self):
|
||||
self.helper.side_effect = RuntimeError("Install the matching package before connecting.")
|
||||
with self.assertRaisesRegex(RuntimeError, "Install"):
|
||||
autostart.enable(self.state)
|
||||
self.prompt.assert_not_called()
|
||||
self.assertFalse(any(args[0] == "sudo" for args, _ in self.calls))
|
||||
self.assertEqual([], self.system_calls)
|
||||
|
||||
def test_node_255_startup_does_not_need_a_credential_backend(self):
|
||||
config.save_config(self.state, config.default_config(role="node"))
|
||||
self.helper.side_effect = RuntimeError("missing helper")
|
||||
# The absent-helper case must not depend on another package being
|
||||
# installed on the machine running this source-checkout test.
|
||||
with mock.patch.object(autostart, "_helper_present", return_value=False):
|
||||
autostart._enable(self.state, confirm=False, start_now=False)
|
||||
self.helper.assert_not_called(); self.prompt.assert_not_called()
|
||||
self.assertFalse(self.operations("protect"))
|
||||
self.assertFalse(any(args[:2] == ["systemd-creds", "--version"] for args, _ in self.calls))
|
||||
self.assertTrue(autostart._manifest(autostart._paths(self.state)[1])["node"])
|
||||
|
||||
def test_255_decrypt_is_owner_bound_by_helper_not_owner_supplied_cipher(self):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
self.calls.clear()
|
||||
self.assertEqual(KEY, autostart._credential(self.state))
|
||||
args, options = self.operations("decrypt")[0]
|
||||
record = autostart._manifest(autostart._paths(self.state)[1])
|
||||
self.assertEqual(["sudo", "-n", "--", str(autostart.HELPER), "decrypt", autostart._profile_id(self.state), record["credential_sha256"]], args)
|
||||
self.assertNotIn("data", options)
|
||||
self.assertFalse(any(a.startswith("--uid") for a in args))
|
||||
|
||||
def test_legacy_manifest_keeps_256_decryption_without_autodetection(self):
|
||||
_, directory, _ = autostart._paths(self.state)
|
||||
cipher = b"LEGACY-CIPHERTEXT-" * 8
|
||||
autostart._write(directory / "credential.cred", cipher)
|
||||
autostart._json(directory / "installation.json", {"schema": 1, "node": False, "credential_sha256": hashlib.sha256(cipher).hexdigest()})
|
||||
self.assertEqual(KEY, autostart._credential(self.state))
|
||||
args, options = self.operations("decrypt")[0]
|
||||
self.assertEqual(["systemd-creds", "decrypt", "--user", "--name=chatgpt-runtime-key", "-", "-"], args)
|
||||
self.assertEqual(cipher, options["data"])
|
||||
self.helper.assert_not_called()
|
||||
|
||||
def test_changed_cipher_fails_before_any_privileged_decrypt(self):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
(autostart._paths(self.state)[1] / "credential.cred").write_bytes(b"tampered")
|
||||
self.calls.clear()
|
||||
with self.assertRaisesRegex(RuntimeError, "changed outside"):
|
||||
autostart._credential(self.state)
|
||||
self.assertEqual([], self.calls)
|
||||
|
||||
def test_failed_service_setup_revokes_new_helper_grant(self):
|
||||
def fail(*args, **kwargs):
|
||||
if args[0] == "enable": raise RuntimeError("synthetic service failure")
|
||||
return self.fake_systemctl(*args, **kwargs)
|
||||
self.systemctl.side_effect = fail
|
||||
with self.assertRaises(RuntimeError):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
self.assertEqual(1, len(self.operations("revoke")))
|
||||
self.assertIsNone(autostart._manifest(autostart._paths(self.state)[1]))
|
||||
|
||||
def test_input_race_after_protect_revokes_before_stopping_any_service(self):
|
||||
def changed(args, **kwargs):
|
||||
result = self.fake_command(args, **kwargs)
|
||||
if "protect" in args:
|
||||
settings = config.load_config(self.state); settings["name"] = "changed-during-protect"
|
||||
config.save_config(self.state, settings)
|
||||
return result
|
||||
self.command.side_effect = changed
|
||||
with self.assertRaisesRegex(RuntimeError, "changed during"):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
self.assertEqual(1, len(self.operations("revoke")))
|
||||
self.assertFalse(any(args[0] == "stop" for args in self.system_calls))
|
||||
|
||||
def test_rotation_revokes_old_grant_only_after_commit(self):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
directory = autostart._paths(self.state)[1]
|
||||
old = autostart._manifest(directory)["credential_sha256"]
|
||||
self.calls.clear(); self.system_calls.clear()
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
current = autostart._manifest(directory)
|
||||
self.assertNotEqual(old, current["credential_sha256"])
|
||||
self.assertEqual([], current["retired_helper_credentials"])
|
||||
self.assertEqual(old, self.operations("revoke")[0][0][-1])
|
||||
|
||||
def test_retired_limit_rejects_rotation_before_protect_or_service_change(self):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
directory = autostart._paths(self.state)[1]
|
||||
record = autostart._manifest(directory)
|
||||
record["retired_helper_credentials"] = [format(n, "064x") for n in range(128)]
|
||||
autostart._json(directory / "installation.json", record, replace=True)
|
||||
before = (directory / "installation.json").read_bytes()
|
||||
self.calls.clear(); self.system_calls.clear()
|
||||
with self.assertRaisesRegex(RuntimeError, "must be cleaned"):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
self.assertFalse(self.operations("protect"))
|
||||
self.assertEqual([], self.system_calls)
|
||||
self.assertEqual(before, (directory / "installation.json").read_bytes())
|
||||
|
||||
def test_retirement_failure_is_durable_and_disable_revokes_both_versions(self):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
directory = autostart._paths(self.state)[1]
|
||||
old = autostart._manifest(directory)["credential_sha256"]
|
||||
def fail_revoke(args, **kwargs):
|
||||
if "revoke" in args: raise RuntimeError("synthetic revocation failure")
|
||||
return self.fake_command(args, **kwargs)
|
||||
self.command.side_effect = fail_revoke
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
record = autostart._manifest(directory)
|
||||
self.assertEqual([old], record["retired_helper_credentials"])
|
||||
self.command.side_effect = self.fake_command; self.calls.clear()
|
||||
autostart.disable(self.state)
|
||||
self.assertEqual("-", self.operations("prune")[0][0][-1])
|
||||
self.assertIsNone(autostart._manifest(directory))
|
||||
|
||||
def test_failed_revoke_keeps_manifest_for_retry_after_service_stop(self):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
directory = autostart._paths(self.state)[1]
|
||||
def fail_revoke(args, **kwargs):
|
||||
if "prune" in args: raise RuntimeError("synthetic revocation failure")
|
||||
return self.fake_command(args, **kwargs)
|
||||
self.command.side_effect = fail_revoke
|
||||
with self.assertRaises(RuntimeError): autostart.disable(self.state)
|
||||
self.assertIsNotNone(autostart._manifest(directory))
|
||||
self.assertTrue(autostart._paths(self.state)[2].exists())
|
||||
|
||||
def test_disable_recovers_protect_without_an_owner_manifest(self):
|
||||
installed = self.home / "installed-helper"
|
||||
installed.write_text("synthetic trusted helper placeholder")
|
||||
with mock.patch.object(autostart, "HELPER", installed):
|
||||
result = autostart.disable(self.state)
|
||||
self.assertTrue(result["orphan_credential_cleanup_verified"])
|
||||
self.assertEqual("-", self.operations("prune")[0][0][-1])
|
||||
self.assertTrue(any(args[:2] == ["sudo", "-v"] for args, _ in self.calls))
|
||||
|
||||
def test_disable_without_installed_helper_does_not_claim_verified_cleanup(self):
|
||||
with mock.patch.object(autostart, "HELPER", self.home / "missing-helper"):
|
||||
result = autostart.disable(self.state)
|
||||
self.assertFalse(result["orphan_credential_cleanup_verified"])
|
||||
|
||||
def test_256_setup_and_disable_reconcile_orphans_after_os_upgrade(self):
|
||||
installed = self.home / "installed-helper"
|
||||
installed.write_text("synthetic trusted helper placeholder")
|
||||
def systemd256(args, **kwargs):
|
||||
if args[:2] == ["systemd-creds", "--version"]:
|
||||
self.calls.append((args, kwargs))
|
||||
return subprocess.CompletedProcess(args, 0, b"systemd 256\n", b"")
|
||||
return self.fake_command(args, **kwargs)
|
||||
self.command.side_effect = systemd256
|
||||
with mock.patch.object(autostart, "HELPER", installed):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
self.assertEqual(1, len(self.operations("prune")))
|
||||
self.assertTrue(self.operations("encrypt")); self.assertFalse(self.operations("protect"))
|
||||
autostart.disable(self.state)
|
||||
self.assertEqual(2, len(self.operations("prune")))
|
||||
|
||||
def test_unknown_backend_never_invokes_helper_or_decrypt(self):
|
||||
_, directory, _ = autostart._paths(self.state)
|
||||
cipher = b"CIPHERTEXT-" * 10
|
||||
autostart._write(directory / "credential.cred", cipher)
|
||||
autostart._json(directory / "installation.json", {"schema": 1, "node": False, "credential_backend": "unexpected", "credential_sha256": hashlib.sha256(cipher).hexdigest()})
|
||||
self.calls.clear()
|
||||
with self.assertRaisesRegex(RuntimeError, "Unsupported"):
|
||||
autostart._credential(self.state)
|
||||
self.assertEqual([], self.calls)
|
||||
|
||||
def test_unsafe_or_ambiguous_policy_never_receives_secret_input(self):
|
||||
for backend in (autostart.HOST_BACKEND, autostart.USER_BACKEND):
|
||||
for kind in ("logging", "missing", "ambiguous", "different-command", "query-failed", "specific-stream-logging"):
|
||||
self.calls.clear()
|
||||
def policy(args, **kwargs):
|
||||
result = self.fake_command(args, **kwargs)
|
||||
if "-ll" not in args:
|
||||
return result
|
||||
text = result.stdout.decode()
|
||||
if kind == "logging": text = text.replace("!log_input", "log_input")
|
||||
elif kind == "missing": text = text.replace("!log_output, ", "")
|
||||
elif kind == "ambiguous": text += "Matched: /usr/bin/another\n"
|
||||
elif kind == "different-command": text = text.replace(" Matched: ", " Matched: /different ")
|
||||
elif kind == "specific-stream-logging": text = text.replace("!setenv", "!setenv, log_stdin")
|
||||
return subprocess.CompletedProcess(args, 1 if kind == "query-failed" else 0, text.encode(), b"")
|
||||
self.command.side_effect = policy
|
||||
with self.subTest(backend=backend, kind=kind), self.assertRaises(RuntimeError):
|
||||
autostart._encrypt_credential(self.state, KEY, backend)
|
||||
self.assertFalse(any(options.get("data") == KEY.encode() for _, options in self.calls))
|
||||
self.assertTrue(self.operations("authorize"))
|
||||
self.assertTrue(self.operations("unauthorize"))
|
||||
|
||||
def test_policy_query_exception_precedes_secret_and_cancels_authorization(self):
|
||||
def unavailable(args, **kwargs):
|
||||
if "-ll" in args:
|
||||
raise RuntimeError("synthetic policy lookup failure")
|
||||
return self.fake_command(args, **kwargs)
|
||||
self.command.side_effect = unavailable
|
||||
with self.assertRaises(RuntimeError):
|
||||
autostart._encrypt_credential(self.state, KEY, autostart.HOST_BACKEND)
|
||||
self.assertFalse(any(options.get("data") == KEY.encode() for _, options in self.calls))
|
||||
self.assertTrue(self.operations("unauthorize"))
|
||||
|
||||
def test_decrypt_checks_effective_nolog_policy_before_running_privileged_command(self):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
self.calls.clear()
|
||||
def logging(args, **kwargs):
|
||||
result = self.fake_command(args, **kwargs)
|
||||
if "-ll" in args:
|
||||
return subprocess.CompletedProcess(args, 0, result.stdout.replace(b"!log_output", b"log_output"), b"")
|
||||
return result
|
||||
self.command.side_effect = logging
|
||||
with self.assertRaisesRegex(RuntimeError, "explicitly disable"):
|
||||
autostart._credential(self.state)
|
||||
self.assertFalse(self.operations("decrypt"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Negative build-input tests; no package installation or target imports."""
|
||||
import base64
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "packaging"))
|
||||
from build_profiles import target_profile, validate_vendor
|
||||
|
||||
|
||||
class VendorValidationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
self.profile = target_profile("mint22-amd64-py312")
|
||||
self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so")
|
||||
self.make_package("pydantic_core", "pydantic_core/_pydantic_core.cpython-312-x86_64-linux-gnu.so")
|
||||
self.make_package("rpds_py", "rpds/rpds.cpython-312-x86_64-linux-gnu.so")
|
||||
|
||||
def make_package(self, name, relative, tag="cp312-cp312-manylinux_2_28_x86_64", abi_extra=b""):
|
||||
native = self.root / relative
|
||||
native.parent.mkdir(parents=True, exist_ok=True)
|
||||
native.write_bytes(b"\x7fELF\x02\x01" + b"\0" * 12 + (62).to_bytes(2, "little") + abi_extra)
|
||||
info = self.root / (name + "-1.0.dist-info")
|
||||
info.mkdir(exist_ok=True)
|
||||
(info / "METADATA").write_text("Name: " + name + "\nVersion: 1.0\nRequires-Python: >=3.10\n")
|
||||
(info / "WHEEL").write_text("Wheel-Version: 1.0\nRoot-Is-Purelib: false\nTag: " + tag + "\n")
|
||||
self.write_record(info, native)
|
||||
return info, native
|
||||
|
||||
def write_record(self, info, native):
|
||||
rows = []
|
||||
for path in (native, info / "METADATA", info / "WHEEL"):
|
||||
digest = base64.urlsafe_b64encode(hashlib.sha256(path.read_bytes()).digest()).decode().rstrip("=")
|
||||
rows.append(path.relative_to(self.root).as_posix() + ",sha256=" + digest + "," + str(path.stat().st_size))
|
||||
rows.append((info / "RECORD").relative_to(self.root).as_posix() + ",,")
|
||||
(info / "RECORD").write_text("\n".join(rows) + "\n")
|
||||
|
||||
def test_valid_target_and_abi3(self):
|
||||
self.make_package("cryptography", "cryptography/_rust.abi3.so", "cp311-abi3-manylinux_2_34_x86_64")
|
||||
result = validate_vendor(self.root, self.profile)
|
||||
self.assertEqual(len(result["native_extensions"]), 4)
|
||||
self.assertEqual(self.profile["validation_status"], "candidate")
|
||||
|
||||
def test_wrong_wheel_abi_rejected(self):
|
||||
self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so", "cp313-cp313-manylinux_2_28_x86_64")
|
||||
with self.assertRaisesRegex(RuntimeError, "wheel ABI"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
def test_wrong_extension_abi_rejected(self):
|
||||
old = self.root / "_cffi_backend.cpython-312-x86_64-linux-gnu.so"
|
||||
old.unlink()
|
||||
self.make_package("cffi", "_cffi_backend.cpython-313-x86_64-linux-gnu.so")
|
||||
with self.assertRaisesRegex(RuntimeError, "extension ABI"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
def test_wrong_elf_cpu_rejected(self):
|
||||
info, path = self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so")
|
||||
data = bytearray(path.read_bytes()); data[18:20] = (183).to_bytes(2, "little")
|
||||
path.write_bytes(data); self.write_record(info, path)
|
||||
with self.assertRaisesRegex(RuntimeError, "ELF amd64"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
def test_new_glibc_rejected(self):
|
||||
self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so", abi_extra=b"GLIBC_2.35")
|
||||
with self.assertRaisesRegex(RuntimeError, "glibc 2.34"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
def test_requires_python_rejected(self):
|
||||
info, path = self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so")
|
||||
with (info / "METADATA").open("a") as stream: stream.write("Requires-Python: >=3.13\n")
|
||||
# Replace the original field to avoid duplicate-header ambiguity.
|
||||
(info / "METADATA").write_text("Name: cffi\nVersion: 1.0\nRequires-Python: >=3.13\n")
|
||||
self.write_record(info, path)
|
||||
with self.assertRaisesRegex(RuntimeError, "Requires-Python"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
def test_version_set_change_rejected(self):
|
||||
with self.assertRaisesRegex(RuntimeError, "names/versions"):
|
||||
validate_vendor(self.root, self.profile, {"cffi": "9.9"})
|
||||
|
||||
def test_modified_native_record_rejected(self):
|
||||
with (self.root / "_cffi_backend.cpython-312-x86_64-linux-gnu.so").open("ab") as stream:
|
||||
stream.write(b"altered")
|
||||
with self.assertRaisesRegex(RuntimeError, "RECORD digest"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
def test_missing_wheel_rejected(self):
|
||||
(self.root / "cffi-1.0.dist-info/WHEEL").unlink()
|
||||
with self.assertRaisesRegex(RuntimeError, "WHEEL/RECORD"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
def test_native_record_requires_digest(self):
|
||||
record = self.root / "cffi-1.0.dist-info/RECORD"
|
||||
rows = record.read_text().splitlines()
|
||||
rows[0] = rows[0].split(",")[0] + ",,"
|
||||
record.write_text("\n".join(rows) + "\n")
|
||||
with self.assertRaisesRegex(RuntimeError, "RECORD digest missing"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
def test_pure_metadata_on_native_rejected(self):
|
||||
self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so", "py3-none-any")
|
||||
with self.assertRaisesRegex(RuntimeError, "native WHEEL"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -7,7 +7,7 @@ import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from framebridge_expanded import cli
|
||||
from framebridge_expanded import cli, __version__
|
||||
from framebridge_expanded.config import default_config, save_config
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class CommunityCliContractTests(unittest.TestCase):
|
||||
value=json.loads(text)
|
||||
self.assertEqual(code,0)
|
||||
self.assertEqual(value["schema_version"],1)
|
||||
self.assertEqual(value["version"],"0.3.1")
|
||||
self.assertEqual(value["version"], __version__)
|
||||
self.assertEqual(value["result"]["desired_connection"],"disconnected")
|
||||
self.assertFalse(value["result"]["connection"]["verified"])
|
||||
|
||||
|
||||
@@ -33,6 +33,26 @@ class ConsoleTests(unittest.TestCase):
|
||||
mock.patch.object(Path, "read_text", return_value="tty7\n"):
|
||||
self.assertTrue(console._active_graphical_display({"DISPLAY": ":0", "XDG_SESSION_ID": "19"}))
|
||||
|
||||
def test_desktop_launcher_without_session_environment_uses_verified_own_display(self):
|
||||
with mock.patch.object(console.subprocess, "run", side_effect=[
|
||||
SimpleNamespace(returncode=0, stdout="19\n"), self.desktop()]) as run, \
|
||||
mock.patch.object(Path, "read_text", return_value="tty7\n"):
|
||||
self.assertTrue(console._active_graphical_display({"DISPLAY": ":0"}))
|
||||
self.assertEqual(run.call_args_list[0].args[0], [
|
||||
"loginctl", "show-user", str(os.getuid()), "--property=Display", "--value"])
|
||||
self.assertIn("19", run.call_args_list[1].args[0])
|
||||
|
||||
def test_missing_primary_session_does_not_guess_a_display_session(self):
|
||||
with mock.patch.object(console.subprocess, "run", return_value=
|
||||
SimpleNamespace(returncode=0, stdout="\n")) as run:
|
||||
self.assertFalse(console._active_graphical_display({"DISPLAY": ":0"}))
|
||||
run.assert_called_once()
|
||||
|
||||
def test_invalid_supplied_session_is_not_replaced(self):
|
||||
with mock.patch.object(console.subprocess, "run") as run:
|
||||
self.assertFalse(console._active_graphical_display({"DISPLAY": ":0", "XDG_SESSION_ID": "../bad"}))
|
||||
run.assert_not_called()
|
||||
|
||||
def test_private_virtual_display_is_not_chosen_as_user_desktop(self):
|
||||
with mock.patch.object(console.subprocess, "run", return_value=self.desktop()), \
|
||||
mock.patch.object(Path, "read_text", return_value="tty7\n"):
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import shlex
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from framebridge_expanded import console_desktop
|
||||
from framebridge_expanded.console_ui import framebridge_console as ui
|
||||
|
||||
|
||||
@unittest.skipIf(os.getuid() == 0, "desktop migration requires a normal user")
|
||||
class DesktopMigrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
root = Path(self.temp.name)
|
||||
self.home = root / "home"
|
||||
self.home.mkdir(mode=0o700)
|
||||
self.state = self.home / "profile"
|
||||
self.runtime = root / "runtime"
|
||||
self.runtime.mkdir(mode=0o700)
|
||||
parent = self.runtime / "testhost-tty1"
|
||||
parent.mkdir(mode=0o700)
|
||||
self.sock = socket.socket(socket.AF_UNIX)
|
||||
self.sock.bind(str(parent / "console.sock"))
|
||||
os.chmod(parent / "console.sock", 0o600)
|
||||
self.addCleanup(self.sock.close)
|
||||
self.desktop = self.home / ".local/share/applications/framebridge-console.desktop"
|
||||
self.desktop.parent.mkdir(parents=True, mode=0o700)
|
||||
self.desktop.write_bytes(console_desktop._legacy_entry(self.home))
|
||||
self.wrapper = self.home / ".local/bin/framebridge-console"
|
||||
self.wrapper.parent.mkdir(parents=True, mode=0o700)
|
||||
self.wrapper.write_bytes(b"#!/usr/bin/python3\npass\n")
|
||||
self.old_hash = hashlib.sha256(self.wrapper.read_bytes()).hexdigest()
|
||||
|
||||
def migrate(self, windows="@0|Console\n@1|FB-piloto\n@3|FB-Muffin\n"):
|
||||
def result(args, **kwargs):
|
||||
text = windows if "list-windows" in args else "bash|0|shell\npython3|0|monitor\n"
|
||||
return SimpleNamespace(returncode=0, stdout=text)
|
||||
with mock.patch.object(console_desktop, "LEGACY_WRAPPER_SHA256", self.old_hash), \
|
||||
mock.patch.object(console_desktop.subprocess, "run", side_effect=result) as run:
|
||||
value = console_desktop.migrate_legacy(
|
||||
self.state, home=self.home, node="TestHost", runtime=self.runtime)
|
||||
return value, run
|
||||
|
||||
def test_known_legacy_icon_keeps_existing_socket_and_session(self):
|
||||
before = self.desktop.read_bytes()
|
||||
value, run = self.migrate()
|
||||
self.assertTrue(value["changed"])
|
||||
self.assertFalse(value["runtime_changed"])
|
||||
self.assertEqual((self.state / "console-desktop-legacy.backup").read_bytes(), before)
|
||||
self.assertEqual(self.desktop.read_bytes(), console_desktop._new_entry(self.state))
|
||||
self.assertEqual(self.wrapper.read_bytes(), console_desktop._new_wrapper(self.state))
|
||||
self.assertEqual((self.state / "console-wrapper-legacy.backup").read_bytes(), b"#!/usr/bin/python3\npass\n")
|
||||
self.assertTrue(value["wrapper_changed"])
|
||||
context = console_desktop.legacy_context(self.state, "TestHost", self.runtime)
|
||||
self.assertEqual(context["session"], "TESTHOST-FBTERM")
|
||||
self.assertEqual(context["socket"], self.runtime / "testhost-tty1/console.sock")
|
||||
self.assertEqual(run.call_count, 2)
|
||||
self.assertIn("list-windows", run.call_args_list[0].args[0])
|
||||
self.assertIn("list-panes", run.call_args_list[1].args[0])
|
||||
second, run = self.migrate()
|
||||
self.assertFalse(second["changed"])
|
||||
run.assert_not_called()
|
||||
|
||||
def test_custom_desktop_entry_is_preserved(self):
|
||||
self.desktop.write_text("[Desktop Entry]\nExec=my-custom-console\n")
|
||||
before = self.desktop.read_bytes()
|
||||
value, run = self.migrate()
|
||||
self.assertEqual(value["reason"], "CUSTOM_DESKTOP_ENTRY_PRESERVED")
|
||||
self.assertEqual(self.desktop.read_bytes(), before)
|
||||
self.assertFalse(self.state.exists())
|
||||
run.assert_not_called()
|
||||
|
||||
def test_changed_legacy_wrapper_does_not_get_replaced(self):
|
||||
self.wrapper.write_text("custom owner command\n")
|
||||
before = self.desktop.read_bytes()
|
||||
with self.assertRaisesRegex(ValueError, "LEGACY_WRAPPER_CHANGED"):
|
||||
self.migrate()
|
||||
self.assertEqual(self.desktop.read_bytes(), before)
|
||||
self.assertFalse(self.state.exists())
|
||||
|
||||
def test_ambiguous_console_window_prevents_migration(self):
|
||||
before = self.desktop.read_bytes()
|
||||
with self.assertRaisesRegex(ValueError, "LEGACY_WINDOW_UNVERIFIED"):
|
||||
self.migrate("@0|Console\n@1|Console\n")
|
||||
self.assertEqual(self.desktop.read_bytes(), before)
|
||||
self.assertFalse(self.state.exists())
|
||||
|
||||
def test_symlinked_entry_is_refused_without_touching_target(self):
|
||||
self.desktop.unlink()
|
||||
target = self.home / "owner-file"
|
||||
target.write_text("keep\n")
|
||||
self.desktop.symlink_to(target)
|
||||
with self.assertRaisesRegex(ValueError, "SYMLINK_REFUSED"):
|
||||
self.migrate()
|
||||
self.assertEqual(target.read_text(), "keep\n")
|
||||
|
||||
def test_foreign_profile_cannot_reuse_the_binding(self):
|
||||
self.migrate()
|
||||
record = self.state / console_desktop.RECORD
|
||||
raw = record.read_text().replace(str(self.state), str(self.state) + "-other")
|
||||
record.write_text(raw)
|
||||
with self.assertRaisesRegex(ValueError, "RECORD_INVALID"):
|
||||
console_desktop.legacy_context(self.state, "TestHost", self.runtime)
|
||||
|
||||
|
||||
class ConsoleWindowSelectionTests(unittest.TestCase):
|
||||
def console(self, windows):
|
||||
console = ui.Console(SimpleNamespace(socket="/no-real-socket", session="NODE-FBTERM"))
|
||||
def tmux(*args, **kwargs):
|
||||
if args[0] == "list-sessions":
|
||||
return "NODE-FBTERM|$0"
|
||||
if args[0] == "list-windows":
|
||||
return windows
|
||||
raise AssertionError(args)
|
||||
with mock.patch.object(ui.subprocess, "run", return_value=SimpleNamespace(returncode=0)), \
|
||||
mock.patch.object(console, "tmux", side_effect=tmux):
|
||||
value = console.exists()
|
||||
return console, value
|
||||
|
||||
def test_migrated_wrapper_targets_the_packaged_profile_without_a_shell(self):
|
||||
state = Path("/home/user/profile")
|
||||
with mock.patch("sys.argv", ["framebridge-console"]), mock.patch("os.execv") as execute:
|
||||
exec(compile(console_desktop._new_wrapper(state), "wrapper", "exec"), {})
|
||||
execute.assert_called_once_with("/usr/bin/fb", ["/usr/bin/fb", "--state-dir", str(state), "console"])
|
||||
|
||||
def test_migrated_inside_wrapper_does_not_create_another_viewer(self):
|
||||
state = Path("/home/user/profile")
|
||||
with mock.patch("sys.argv", ["framebridge-console", "--inside"]), mock.patch("os.execv") as execute:
|
||||
exec(compile(console_desktop._new_wrapper(state), "wrapper", "exec"), {})
|
||||
self.assertEqual(execute.call_args.args[1][-3:], ["--inside", "--mode", "gui"])
|
||||
|
||||
def test_monitor_receives_profile_jobs_root_as_an_explicit_argument(self):
|
||||
args = SimpleNamespace(socket="/owned/socket", session="NODE-FBTERM", node="NODE",
|
||||
jobs_root=Path("/profile with space/jobs"))
|
||||
command = shlex.split(ui.Console(args).monitor_command())
|
||||
self.assertEqual(command[-2:], ["--jobs-root", "/profile with space/jobs"])
|
||||
|
||||
def test_desktop_entry_escapes_percent_field_codes(self):
|
||||
entry = console_desktop._new_entry(Path("/home/user/100%complete/profile"))
|
||||
self.assertIn(b"100%%complete", entry)
|
||||
|
||||
def test_desktop_entry_refuses_newline_in_profile_path(self):
|
||||
with self.assertRaisesRegex(ValueError, "STATE_PATH_INVALID"):
|
||||
console_desktop._new_entry(Path("/home/user/profile\nExec=bad"))
|
||||
|
||||
def test_extra_owner_windows_do_not_prevent_console_attachment(self):
|
||||
console, value = self.console("@0|Console\n@1|FB-piloto\n@3|FB-Muffin")
|
||||
self.assertTrue(value)
|
||||
self.assertEqual(console.window, "@0")
|
||||
self.assertEqual(console.session_target, "$0")
|
||||
|
||||
def test_ambiguous_console_window_is_not_guessed(self):
|
||||
with self.assertRaisesRegex(RuntimeError, "absent or ambiguous"):
|
||||
self.console("@0|Console\n@1|Console")
|
||||
|
||||
def test_multiwindow_session_without_console_is_preserved(self):
|
||||
with self.assertRaisesRegex(RuntimeError, "absent or ambiguous"):
|
||||
self.console("@0|Owner work\n@1|Other")
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("tmux"), "tmux is required")
|
||||
class RealConsolePreservationTests(unittest.TestCase):
|
||||
def test_apply_preserves_shell_and_other_windows_in_existing_session(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
sock = str(Path(directory) / "console.sock")
|
||||
def tmux(*args):
|
||||
return subprocess.check_output(["/usr/bin/tmux", "-S", sock, *args], text=True).strip()
|
||||
try:
|
||||
tmux("new-session", "-d", "-s", "TEST-FBTERM", "-n", "Console", "/bin/bash --noprofile --norc")
|
||||
shell = tmux("display-message", "-p", "-t", "=TEST-FBTERM:Console", "#{pane_pid}")
|
||||
tmux("new-window", "-d", "-t", "=TEST-FBTERM", "-n", "FB-piloto", "/bin/bash --noprofile --norc")
|
||||
tmux("new-window", "-d", "-t", "=TEST-FBTERM", "-n", "FB-Muffin", "/bin/bash --noprofile --norc")
|
||||
fmt = "#{window_name}|#{pane_id}|#{pane_pid}"
|
||||
other = tmux("list-panes", "-a", "-F", fmt).splitlines()[1:]
|
||||
jobs = Path(directory) / "profile-jobs"
|
||||
args = SimpleNamespace(socket=sock, session="TEST-FBTERM", node="Test", jobs_root=jobs,
|
||||
layout="horizontal", reflow=False, refresh_monitor=False)
|
||||
ui.Console(args).apply()
|
||||
panes = tmux("list-panes", "-t", "=TEST-FBTERM:Console", "-F", "#{pane_pid}|#{@framebridge_role}")
|
||||
self.assertIn(shell + "|shell", panes)
|
||||
start_commands = tmux("list-panes", "-t", "=TEST-FBTERM:Console", "-F", "#{pane_start_command}")
|
||||
self.assertIn("--jobs-root", start_commands)
|
||||
self.assertIn(str(jobs), start_commands)
|
||||
after = tmux("list-panes", "-a", "-F", fmt).splitlines()
|
||||
for row in other:
|
||||
self.assertIn(row, after)
|
||||
self.assertEqual(tmux("list-windows", "-t", "=TEST-FBTERM", "-F", "#{window_name}"),
|
||||
"Console\nFB-piloto\nFB-Muffin")
|
||||
finally:
|
||||
subprocess.run(["/usr/bin/tmux", "-S", sock, "kill-server"], capture_output=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Synthetic, unprivileged tests; never run sudo, visudo or systemd-creds."""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from framebridge_expanded import credential_helper as helper
|
||||
|
||||
KEY = b"SYNTHETIC-FRAMEBRIDGE-TEST-NOT-A-REAL-KEY"
|
||||
PROFILE = "a" * 64
|
||||
|
||||
|
||||
class CredentialHelperTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.registry = self.root / "registry"
|
||||
self.policies = self.root / "sudoers"
|
||||
self.registry.mkdir(mode=0o700)
|
||||
self.policies.mkdir(mode=0o700)
|
||||
self.owner = SimpleNamespace(pw_uid=1234, pw_gid=1234, pw_name="testowner", pw_dir="/home/testowner")
|
||||
self.calls = []
|
||||
self.generation = 0
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
mock.patch.object(helper, "ROOT", self.registry).start()
|
||||
mock.patch.object(helper, "SUDOERS", self.policies).start()
|
||||
# Functional tests run as an ordinary user. Trust-path guards have
|
||||
# dedicated stat-driven tests below rather than requiring real root.
|
||||
mock.patch.object(helper, "_root_path", side_effect=lambda p, **kw: Path(p)).start()
|
||||
original_fstat = os.fstat
|
||||
def root_stat(fd):
|
||||
values = list(original_fstat(fd)); values[4] = 0
|
||||
return os.stat_result(values)
|
||||
mock.patch.object(helper.os, "fstat", side_effect=root_stat).start()
|
||||
self.run_patcher = mock.patch.object(helper, "_run", side_effect=self.fake_run)
|
||||
self.run = self.run_patcher.start()
|
||||
|
||||
def fake_run(self, args, data=None):
|
||||
self.calls.append((args, data))
|
||||
if args[1] == "encrypt":
|
||||
self.generation += 1
|
||||
return ("SYNTHETIC-CIPHERTEXT-%03d-" % self.generation).encode() * 6
|
||||
if args[1] == "decrypt":
|
||||
return KEY
|
||||
return b""
|
||||
|
||||
def protect(self):
|
||||
cipher = helper.protect(self.owner, PROFILE, KEY)
|
||||
return cipher, hashlib.sha256(cipher).hexdigest()
|
||||
|
||||
def test_protect_has_literal_owner_rule_and_no_plaintext_in_files_or_argv(self):
|
||||
cipher, digest = self.protect()
|
||||
record, policy = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||
self.assertEqual(0o600, stat.S_IMODE(record.stat().st_mode))
|
||||
self.assertEqual(0o440, stat.S_IMODE(policy.stat().st_mode))
|
||||
text = policy.read_text()
|
||||
self.assertIn("testowner ALL=(root) NOLOG_INPUT: NOLOG_OUTPUT: NOSETENV: NOPASSWD:", text)
|
||||
self.assertTrue(text.endswith(" decrypt " + PROFILE + " " + digest + "\n"))
|
||||
self.assertNotIn("*", text)
|
||||
self.assertNotIn("systemd-creds", text)
|
||||
for path in (record, policy):
|
||||
self.assertNotIn(KEY, path.read_bytes())
|
||||
encryption = self.calls[0]
|
||||
self.assertEqual(helper.CREDS, encryption[0][0])
|
||||
self.assertEqual(KEY, encryption[1])
|
||||
self.assertIn("--with-key=host", encryption[0])
|
||||
self.assertFalse(any(a.startswith("--uid") or a == "--user" for a in encryption[0]))
|
||||
self.assertNotIn(KEY.decode(), " ".join(encryption[0]))
|
||||
self.assertEqual(KEY, helper.decrypt(self.owner, PROFILE, digest))
|
||||
|
||||
def test_wrong_owner_or_profile_never_decrypts(self):
|
||||
_, digest = self.protect()
|
||||
self.calls.clear()
|
||||
other = SimpleNamespace(pw_uid=5678, pw_gid=5678, pw_name="another", pw_dir="/home/another")
|
||||
for owner, profile in ((other, PROFILE), (self.owner, "b" * 64)):
|
||||
with self.subTest(owner=owner.pw_uid, profile=profile):
|
||||
with self.assertRaises(Exception):
|
||||
helper.decrypt(owner, profile, digest)
|
||||
self.assertEqual([], self.calls)
|
||||
|
||||
def test_reused_uid_with_different_username_or_home_is_rejected(self):
|
||||
_, digest = self.protect()
|
||||
for field in ("pw_name", "pw_dir"):
|
||||
values = vars(self.owner).copy(); values[field] = "changed"
|
||||
with self.subTest(field=field), self.assertRaises(RuntimeError):
|
||||
helper.decrypt(SimpleNamespace(**values), PROFILE, digest)
|
||||
|
||||
def test_cipher_or_name_or_principal_tampering_is_rejected_before_decrypt(self):
|
||||
_, digest = self.protect()
|
||||
record, _ = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||
original = json.loads(record.read_bytes())
|
||||
for field, value in (("cipher", "AAAA"), ("name", "another-secret"), ("uid", 0), ("profile", "b" * 64)):
|
||||
changed = dict(original); changed[field] = value
|
||||
record.write_text(json.dumps(changed)); self.calls.clear()
|
||||
with self.subTest(field=field), self.assertRaises(RuntimeError):
|
||||
helper.decrypt(self.owner, PROFILE, digest)
|
||||
self.assertEqual([], self.calls)
|
||||
|
||||
def test_symlink_hardlink_and_broad_permissions_are_rejected(self):
|
||||
_, digest = self.protect()
|
||||
record, _ = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||
backup = record.with_suffix(".backup"); record.rename(backup)
|
||||
record.symlink_to(backup)
|
||||
with self.assertRaises(OSError): helper.decrypt(self.owner, PROFILE, digest)
|
||||
record.unlink(); os.link(backup, record)
|
||||
with self.assertRaises(RuntimeError): helper.decrypt(self.owner, PROFILE, digest)
|
||||
record.unlink(); backup.rename(record); record.chmod(0o644)
|
||||
with self.assertRaises(RuntimeError): helper.decrypt(self.owner, PROFILE, digest)
|
||||
|
||||
def test_invalid_policy_does_not_grant_or_leave_a_record(self):
|
||||
def fail_visudo(args, data=None):
|
||||
if args[0] == helper.VISUDO: raise RuntimeError("invalid policy")
|
||||
return self.fake_run(args, data)
|
||||
self.run.side_effect = fail_visudo
|
||||
with self.assertRaises(RuntimeError): self.protect()
|
||||
self.assertEqual([], list(self.registry.iterdir()))
|
||||
self.assertEqual([], list(self.policies.iterdir()))
|
||||
|
||||
def test_rotation_revoke_and_idempotent_partial_cleanup(self):
|
||||
_, first = self.protect(); _, second = self.protect()
|
||||
self.assertNotEqual(first, second)
|
||||
helper.revoke(self.owner, PROFILE, first)
|
||||
helper.revoke(self.owner, PROFILE, first)
|
||||
self.assertEqual(KEY, helper.decrypt(self.owner, PROFILE, second))
|
||||
_, policy = helper._paths(self.owner.pw_uid, PROFILE, second)
|
||||
policy.unlink() # simulate interruption after authorization removal
|
||||
helper.revoke(self.owner, PROFILE, second)
|
||||
self.assertEqual([], list(self.registry.iterdir()))
|
||||
self.assertEqual([], list(self.policies.iterdir()))
|
||||
|
||||
def test_bootstrap_has_only_literal_nolog_commands_and_host_grant_is_one_use(self):
|
||||
for kind in ("host", "user"):
|
||||
helper.authorize(self.owner, PROFILE, kind)
|
||||
path, expected = helper._authorization(self.owner, PROFILE, kind)
|
||||
self.assertEqual(expected, path.read_bytes())
|
||||
self.assertIn(b"NOLOG_INPUT: NOLOG_OUTPUT: NOSETENV: NOPASSWD:", expected)
|
||||
self.assertNotIn(b"*", expected)
|
||||
self.assertNotIn(KEY, expected)
|
||||
if kind == "user":
|
||||
self.assertIn(b"encrypt --uid=1234 --with-key=host --name=chatgpt-runtime-key - -", expected)
|
||||
helper.unauthorize(self.owner, PROFILE, kind, required=True)
|
||||
with self.assertRaises(RuntimeError):
|
||||
helper.unauthorize(self.owner, PROFILE, kind, required=True)
|
||||
|
||||
def test_prune_and_purge_remove_interrupted_authorizations(self):
|
||||
helper.authorize(self.owner, PROFILE, "host")
|
||||
helper.authorize(self.owner, PROFILE, "user")
|
||||
helper.prune(self.owner, PROFILE, set())
|
||||
self.assertEqual([], list(self.policies.iterdir()))
|
||||
helper.authorize(self.owner, PROFILE, "user")
|
||||
helper.purge_all()
|
||||
self.assertEqual([], list(self.policies.iterdir()))
|
||||
|
||||
def test_purge_prevalidates_all_policies_before_deleting_any(self):
|
||||
_, first = self.protect(); _, second = self.protect()
|
||||
record, policy = helper._paths(self.owner.pw_uid, PROFILE, second)
|
||||
policy.chmod(0o600); policy.write_text("unrelated rule\n")
|
||||
with self.assertRaises(RuntimeError): helper.purge_all()
|
||||
self.assertTrue(helper._paths(self.owner.pw_uid, PROFILE, first)[0].exists())
|
||||
self.assertTrue(record.exists())
|
||||
|
||||
def test_purge_handles_orphan_rule_after_interrupted_revoke(self):
|
||||
_, digest = self.protect()
|
||||
record, policy = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||
record.unlink()
|
||||
helper.purge_all()
|
||||
self.assertFalse(policy.exists())
|
||||
|
||||
def test_prune_recovers_lost_protect_receipt_and_preserves_committed_cipher(self):
|
||||
_, current = self.protect(); _, lost_receipt = self.protect()
|
||||
helper.prune(self.owner, PROFILE, {current})
|
||||
self.assertEqual(KEY, helper.decrypt(self.owner, PROFILE, current))
|
||||
self.assertFalse(helper._paths(self.owner.pw_uid, PROFILE, lost_receipt)[0].exists())
|
||||
helper.prune(self.owner, PROFILE, set())
|
||||
self.assertEqual([], list(self.registry.iterdir()))
|
||||
self.assertEqual([], list(self.policies.iterdir()))
|
||||
|
||||
def test_revoke_persists_authorization_removal_before_deleting_cipher(self):
|
||||
_, digest = self.protect()
|
||||
record, policy = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||
observed = []
|
||||
with mock.patch.object(helper, "_sync", side_effect=lambda p: observed.append((p, record.exists(), policy.exists()))):
|
||||
helper.revoke(self.owner, PROFILE, digest)
|
||||
self.assertEqual([(self.policies, True, False), (self.registry, False, False)], observed)
|
||||
|
||||
def test_purge_recovers_only_recognized_root_owned_staging_files(self):
|
||||
_, digest = self.protect()
|
||||
record, _ = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||
temporary = record.with_name("." + record.name + "." + "d" * 24)
|
||||
temporary.write_bytes(b"incomplete-staging-json"); temporary.chmod(0o600)
|
||||
helper.purge_all()
|
||||
self.assertFalse(temporary.exists())
|
||||
self.assertEqual([], list(self.registry.iterdir()))
|
||||
|
||||
def test_staging_hardlink_after_publish_is_recovered_before_reading_record(self):
|
||||
_, digest = self.protect()
|
||||
record, _ = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||
temporary = record.with_name("." + record.name + "." + "d" * 24)
|
||||
os.link(record, temporary)
|
||||
original_lstat = Path.lstat
|
||||
def root_lstat(path):
|
||||
values = list(original_lstat(path)); values[4] = 0
|
||||
return os.stat_result(values)
|
||||
with mock.patch.object(Path, "lstat", root_lstat):
|
||||
helper._cleanup_temporaries()
|
||||
self.assertFalse(temporary.exists())
|
||||
self.assertEqual(KEY, helper.decrypt(self.owner, PROFILE, digest))
|
||||
|
||||
def test_broken_receipt_pipe_rolls_back_published_protect_grant(self):
|
||||
helper.authorize(self.owner, PROFILE, "host")
|
||||
stdout = mock.Mock(); stdout.buffer.write.side_effect = BrokenPipeError("synthetic closed pipe")
|
||||
with mock.patch.object(helper, "_installation"), mock.patch.object(helper, "_caller", return_value=self.owner), mock.patch.object(helper, "_prepare_root"), mock.patch.object(helper, "_read_key", return_value=KEY), mock.patch.object(helper.resource, "setrlimit"), mock.patch.object(helper.os, "umask"), mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", new_callable=io.StringIO) as stderr:
|
||||
self.assertEqual(1, helper.main(["protect", PROFILE]))
|
||||
self.assertNotIn(KEY.decode(), stderr.getvalue())
|
||||
self.assertEqual([".lock"], [p.name for p in self.registry.iterdir()])
|
||||
self.assertEqual([], list(self.policies.iterdir()))
|
||||
|
||||
def test_open_stdin_has_a_deadline_and_excess_input_is_bounded(self):
|
||||
with mock.patch("sys.stdin", SimpleNamespace(fileno=lambda: 17)), mock.patch.object(helper.select, "select", return_value=([], [], [])), mock.patch.object(helper.os, "read") as read:
|
||||
with self.assertRaises(RuntimeError): helper._read_key()
|
||||
read.assert_not_called()
|
||||
with mock.patch("sys.stdin", SimpleNamespace(fileno=lambda: 17)), mock.patch.object(helper.select, "select", return_value=([17], [], [])), mock.patch.object(helper.os, "read", return_value=b"x" * (helper.MAX_KEY + 1)) as read:
|
||||
with self.assertRaises(RuntimeError): helper._read_key()
|
||||
read.assert_called_once_with(17, helper.MAX_KEY + 1)
|
||||
|
||||
def test_ids_reject_paths_flags_and_extra_arguments(self):
|
||||
for value in ("../secret", "--uid=0", "A" * 64, "a" * 63, PROFILE + "\n"):
|
||||
with self.subTest(value=value), self.assertRaises(RuntimeError):
|
||||
helper.protect(self.owner, value, KEY)
|
||||
for key in (b"", b"space key", b"\x00", b"a" * (helper.MAX_KEY + 1)):
|
||||
with self.subTest(size=len(key)), self.assertRaises(RuntimeError):
|
||||
helper.protect(self.owner, PROFILE, key)
|
||||
self.assertEqual([], self.calls)
|
||||
|
||||
def test_caller_is_derived_from_sudo_identity_not_a_uid_argument(self):
|
||||
with mock.patch.object(helper.pwd, "getpwuid", return_value=self.owner):
|
||||
with mock.patch.dict(os.environ, {"SUDO_UID": "1234", "SUDO_GID": "1234", "SUDO_USER": "testowner"}, clear=True):
|
||||
self.assertEqual(self.owner, helper._caller())
|
||||
for bad in ({}, {"SUDO_UID": "0"}, {"SUDO_UID": "01234"}, {"SUDO_UID": "1234", "SUDO_GID": "1234", "SUDO_USER": "another"}):
|
||||
with mock.patch.dict(os.environ, bad, clear=True), self.assertRaises(RuntimeError):
|
||||
helper._caller()
|
||||
|
||||
def test_subprocess_has_fixed_environment_and_no_shell(self):
|
||||
self.run_patcher.stop()
|
||||
with mock.patch.object(helper.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, b"cipher", b"")) as run:
|
||||
self.assertEqual(b"cipher", helper._run([helper.CREDS, "encrypt", "-", "-"], KEY))
|
||||
_, kwargs = run.call_args
|
||||
self.assertEqual(helper.ENV, kwargs["env"])
|
||||
self.assertEqual("/", kwargs["cwd"])
|
||||
self.assertTrue(kwargs["close_fds"])
|
||||
self.assertNotIn("shell", kwargs)
|
||||
self.assertNotIn(KEY.decode(), repr(kwargs["env"]))
|
||||
|
||||
def test_main_redacts_failures_and_rejects_extra_arguments(self):
|
||||
with mock.patch.object(helper, "_installation"), mock.patch.object(helper.resource, "setrlimit"), mock.patch.object(helper.os, "umask"), mock.patch("sys.stderr", new_callable=io.StringIO) as stderr, mock.patch("sys.stdout", new_callable=io.StringIO) as stdout:
|
||||
self.assertEqual(1, helper.main(["decrypt", PROFILE, "b" * 64, "--uid=0"]))
|
||||
self.assertEqual("", stdout.getvalue())
|
||||
self.assertNotIn(KEY.decode(), stderr.getvalue())
|
||||
|
||||
|
||||
class CredentialTrustGuardTests(unittest.TestCase):
|
||||
def test_root_path_rejects_each_untrusted_component(self):
|
||||
target = Path("/rootowned/helper")
|
||||
def info(mode, uid=0, links=1):
|
||||
return SimpleNamespace(st_mode=mode, st_uid=uid, st_nlink=links)
|
||||
for bad in (info(stat.S_IFDIR | 0o777), info(stat.S_IFDIR | 0o755, uid=1000), info(stat.S_IFLNK | 0o777)):
|
||||
def fake(path):
|
||||
if path == Path("/rootowned"): return bad
|
||||
return info((stat.S_IFREG | 0o755) if path == target else (stat.S_IFDIR | 0o755))
|
||||
with mock.patch.object(Path, "lstat", fake), self.assertRaises(RuntimeError):
|
||||
helper._root_path(target)
|
||||
|
||||
def test_installed_helper_requires_isolated_no_site_interpreter(self):
|
||||
with mock.patch.object(helper.os, "geteuid", return_value=0):
|
||||
for isolated, no_site in ((0, 1), (1, 0)):
|
||||
with mock.patch.object(helper.sys, "flags", SimpleNamespace(isolated=isolated, no_site=no_site)), self.assertRaises(RuntimeError):
|
||||
helper._installation()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,146 @@
|
||||
"""A stalled unprivileged stdout reader must not retain the registry lock.
|
||||
|
||||
The subprocess disables installation/caller/crypto operations and uses only its
|
||||
own temporary directory. No sudo, host files, accounts or credentials are used.
|
||||
"""
|
||||
import array
|
||||
import fcntl
|
||||
import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import termios
|
||||
import time
|
||||
import unittest
|
||||
from framebridge_expanded import credential_helper
|
||||
|
||||
CHILD = r"""
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
spec = importlib.util.spec_from_file_location("pipe_canary_helper", sys.argv[1])
|
||||
helper = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(helper)
|
||||
helper.ROOT = Path(sys.argv[2])
|
||||
helper._installation = lambda: None
|
||||
helper._prepare_root = lambda: None
|
||||
helper._cleanup_temporaries = lambda: None
|
||||
helper._caller = lambda: SimpleNamespace(pw_uid=12345, pw_name="synthetic", pw_dir="/synthetic-only")
|
||||
helper.decrypt = lambda *args: b"X" * 16384
|
||||
# The only file is our own temporary lock; simulate its expected root metadata.
|
||||
actual_fstat = helper.os.fstat
|
||||
helper.os.fstat = lambda fd: SimpleNamespace(st_mode=actual_fstat(fd).st_mode,
|
||||
st_uid=0, st_nlink=actual_fstat(fd).st_nlink)
|
||||
raise SystemExit(helper.main(["decrypt", "a" * 64, "b" * 64]))
|
||||
"""
|
||||
|
||||
|
||||
PROTECT_CHILD = CHILD.replace(
|
||||
'raise SystemExit(helper.main(["decrypt", "a" * 64, "b" * 64]))',
|
||||
'\n'.join([
|
||||
'helper.INPUT_TIMEOUT = 0.1',
|
||||
'helper.unauthorize = lambda *args, **kwargs: None',
|
||||
'helper.protect = lambda *args: b"SYNTHETIC-CIPHER-ONLY" * 8',
|
||||
'raise SystemExit(helper.main(["protect", "a" * 64]))',
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
class CredentialHelperPipeTests(unittest.TestCase):
|
||||
def test_open_stdin_without_eof_times_out_and_releases_registry_lock(self):
|
||||
with tempfile.TemporaryDirectory(prefix="framebridge-input-canary-") as directory:
|
||||
child = None
|
||||
lock_fd = None
|
||||
try:
|
||||
started = time.monotonic()
|
||||
child = subprocess.Popen([sys.executable, "-I", "-S", "-c", PROTECT_CHILD,
|
||||
str(Path(credential_helper.__file__).resolve()), directory],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, close_fds=True)
|
||||
self.assertEqual(1, child.wait(timeout=1), "Protect must reject an input stream without EOF")
|
||||
self.assertLess(time.monotonic() - started, 1, "Synthetic input timeout exceeded its bound")
|
||||
# Do not close stdin or communicate(): both would supply EOF and
|
||||
# could hide an unbounded read or a leaked registry lock.
|
||||
self.assertFalse(child.stdin.closed)
|
||||
lock_fd = os.open(Path(directory) / ".lock", os.O_RDWR)
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
self.fail("Protect retained the registry lock after its input deadline")
|
||||
finally:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
self.assertEqual(b"", child.stdout.read())
|
||||
self.assertIn(b"protected credential operation failed", child.stderr.read())
|
||||
finally:
|
||||
if child is not None:
|
||||
if child.poll() is None:
|
||||
child.kill()
|
||||
child.wait(timeout=3)
|
||||
for stream in (child.stdin, child.stdout, child.stderr):
|
||||
stream.close()
|
||||
if lock_fd is not None:
|
||||
os.close(lock_fd)
|
||||
|
||||
@unittest.skipUnless(hasattr(fcntl, "F_SETPIPE_SZ"), "Linux pipe-size controls required")
|
||||
def test_stalled_stdout_reader_does_not_hold_global_registry_lock(self):
|
||||
with tempfile.TemporaryDirectory(prefix="framebridge-pipe-canary-") as directory:
|
||||
read_fd, write_fd = os.pipe()
|
||||
child = None
|
||||
lock_fd = None
|
||||
try:
|
||||
capacity = fcntl.fcntl(write_fd, fcntl.F_SETPIPE_SZ, 4096)
|
||||
child = subprocess.Popen([sys.executable, "-I", "-S", "-c", CHILD,
|
||||
str(Path(credential_helper.__file__).resolve()), directory],
|
||||
stdin=subprocess.DEVNULL, stdout=write_fd,
|
||||
stderr=subprocess.PIPE, close_fds=True)
|
||||
os.close(write_fd)
|
||||
write_fd = None
|
||||
deadline = time.monotonic() + 5
|
||||
buffered = array.array("i", [0])
|
||||
while time.monotonic() < deadline:
|
||||
fcntl.ioctl(read_fd, termios.FIONREAD, buffered, True)
|
||||
if buffered[0] == capacity:
|
||||
break
|
||||
if child.poll() is not None:
|
||||
self.fail("Synthetic helper exited before filling the canary pipe: " + child.stderr.read().decode())
|
||||
time.sleep(0.01)
|
||||
self.assertEqual(capacity, buffered[0], "Synthetic pipe did not fill")
|
||||
self.assertIsNone(child.poll(), "Helper should be waiting for the synthetic stdout reader")
|
||||
lock_fd = os.open(Path(directory) / ".lock", os.O_RDWR)
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
self.fail("Helper retained the registry lock while stdout was blocked")
|
||||
finally:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
received = bytearray()
|
||||
deadline = time.monotonic() + 5
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
self.assertGreater(remaining, 0, "Synthetic helper pipe did not finish")
|
||||
ready, _, _ = select.select([read_fd], [], [], remaining)
|
||||
self.assertTrue(ready, "Synthetic helper pipe timed out")
|
||||
block = os.read(read_fd, 4096)
|
||||
if not block:
|
||||
break
|
||||
received.extend(block)
|
||||
self.assertEqual(b"X" * 16384, bytes(received))
|
||||
self.assertEqual(0, child.wait(timeout=3), child.stderr.read().decode())
|
||||
finally:
|
||||
if child is not None:
|
||||
if child.poll() is None:
|
||||
child.kill()
|
||||
child.wait(timeout=3)
|
||||
child.stderr.close()
|
||||
os.close(read_fd)
|
||||
if write_fd is not None:
|
||||
os.close(write_fd)
|
||||
if lock_fd is not None:
|
||||
os.close(lock_fd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,37 @@
|
||||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "expanded"))
|
||||
from framebridge_expanded import cli
|
||||
|
||||
class DoctorPlatformOutputTests(unittest.TestCase):
|
||||
def render(self, compatibility, ok):
|
||||
result = {"ok": ok, "compatible_debian_13_amd64": False,
|
||||
"platform_compatibility": compatibility, "display": "unavailable"}
|
||||
runtime = mock.Mock()
|
||||
runtime.doctor.return_value = result
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(cli, "_module", return_value=runtime), contextlib.redirect_stdout(output):
|
||||
code = cli._doctor_command(argparse.Namespace(json=False), Path("/unused-test-state"))
|
||||
return code, output.getvalue()
|
||||
|
||||
def test_supported_ubuntu_uses_current_profile_not_legacy_debian_flag(self):
|
||||
code, text = self.render({"ok": True, "profile_id": "ubuntu24.04-amd64-py312", "issues": []}, True)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("Platform: compatible", text)
|
||||
self.assertIn("ubuntu24.04-amd64-py312", text)
|
||||
self.assertNotIn("Debian 13 amd64:", text)
|
||||
|
||||
def test_incompatible_abi_is_explained_in_human_diagnostics(self):
|
||||
code, text = self.render({"ok": False, "profile_id": "mint22-amd64-py312",
|
||||
"issues": ["Installed artifact profile/ABI does not match this operating system and interpreter."]}, False)
|
||||
self.assertNotEqual(code, 0)
|
||||
self.assertIn("not verified compatible", text)
|
||||
self.assertIn("profile/ABI does not match", text)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Portable registration integration through the bundled FastMCP dispatcher.
|
||||
|
||||
Executors are bounded fixtures. No installed adapter, desktop or Fleet node is
|
||||
contacted; these checks establish dispatch and identity wiring, not acceptance.
|
||||
"""
|
||||
import ast
|
||||
import asyncio
|
||||
import base64
|
||||
from functools import partial, wraps
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
import uuid
|
||||
|
||||
WORK = Path(__file__).resolve().parents[1]
|
||||
VENDOR = WORK / "sources/original-geranas/FrameBridge-Fleet-Hub/vendor"
|
||||
sys.path.insert(0, str(WORK / "expanded"))
|
||||
sys.path.insert(0, str(VENDOR))
|
||||
import anyio
|
||||
from mcp.server.fastmcp import FastMCP, Image
|
||||
from mcp.types import ToolAnnotations
|
||||
from framebridge_expanded import tty_mcp
|
||||
from framebridge_expanded.real_screen.screen_frontdoor import register_framebridge_screen_tools
|
||||
from framebridge_expanded.real_screen.screen_presentation import register_screen_presentation
|
||||
from test_screen_frontdoor import Executor, NODE_ID, SESSION_ID
|
||||
|
||||
PORTABLE = WORK / "portable/front_door_mcp.py"
|
||||
|
||||
|
||||
class ChunkExecutor(Executor):
|
||||
"""Honor the typed route's optional larger chunk while retaining bindings."""
|
||||
def __call__(self, **kwargs):
|
||||
outer = super().__call__(**kwargs)
|
||||
request = json.loads(base64.b64decode(shlex.split(kwargs["script"])[-1]))
|
||||
if request["op"] == "read_chunk" and request.get("chunk_bytes"):
|
||||
size, index = request["chunk_bytes"], request["index"]
|
||||
result = json.loads(outer["stdout"])
|
||||
result["result"].update(
|
||||
total_chunks=math.ceil(len(self.data) / size),
|
||||
data=base64.b64encode(self.data[index * size:(index + 1) * size]).decode(),
|
||||
more=(index + 1) * size < len(self.data),
|
||||
)
|
||||
outer["stdout"] = json.dumps(result)
|
||||
return outer
|
||||
|
||||
|
||||
def portable(*, execute=None, screen_execute=None, threaded=True):
|
||||
"""Load the actual registration statements without bootstrap/profile effects."""
|
||||
tree = ast.parse(PORTABLE.read_text())
|
||||
names = {"_BlockingToolRegistrar", "_registered_operations", "framebridge_status",
|
||||
"framebridge_exec", "_screen_exec_request"}
|
||||
assignments = {"_blocking_tools", "_real_screen_client"}
|
||||
registrations = {"register_screen_presentation", "register_tty_tools"}
|
||||
selected = []
|
||||
for node in tree.body:
|
||||
if isinstance(node, (ast.FunctionDef, ast.ClassDef)) and node.name in names:
|
||||
selected.append(node)
|
||||
elif isinstance(node, ast.Assign) and any(
|
||||
isinstance(target, ast.Name) and target.id in assignments for target in node.targets):
|
||||
if not threaded and node.targets[0].id == "_blocking_tools":
|
||||
node.value = ast.Name(id="mcp", ctx=ast.Load())
|
||||
selected.append(node)
|
||||
elif (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
|
||||
and isinstance(node.value.func, ast.Name) and node.value.func.id in registrations):
|
||||
selected.append(node)
|
||||
mcp = FastMCP("dispatch fixture")
|
||||
execute = execute or (lambda *args: {"ok": True})
|
||||
screen_execute = screen_execute or (lambda *args: {"ok": False})
|
||||
namespace = dict(mcp=mcp, anyio=anyio, partial=partial, wraps=wraps, inspect=inspect,
|
||||
ToolAnnotations=ToolAnnotations, Image=Image, socket=socket,
|
||||
ADAPTER_SOCKET=Path("/nonexistent/framebridge-test.sock"),
|
||||
PRODUCT_VERSION="test", GUIDE_SCHEMA=1,
|
||||
_framebridge_exec_geranas_impl=execute, expanded_execute_node=execute,
|
||||
expanded_execute_screen_node=screen_execute,
|
||||
register_framebridge_screen_tools=register_framebridge_screen_tools,
|
||||
resolve_profile_node=lambda target: target.upper(),
|
||||
register_screen_presentation=register_screen_presentation,
|
||||
register_tty_tools=tty_mcp.register_tools)
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=selected, type_ignores=[])),
|
||||
str(PORTABLE), "exec"), namespace)
|
||||
return mcp, namespace
|
||||
|
||||
|
||||
def structured(reply):
|
||||
if isinstance(reply, tuple):
|
||||
content, value = reply
|
||||
if value is not None:
|
||||
return value
|
||||
reply = content
|
||||
if isinstance(reply, dict):
|
||||
return reply
|
||||
return json.loads(next(item.text for item in reply if item.type == "text"))
|
||||
|
||||
|
||||
async def until_set(event):
|
||||
async def poll():
|
||||
while not event.is_set():
|
||||
await asyncio.sleep(.001)
|
||||
await asyncio.wait_for(poll(), 1)
|
||||
|
||||
|
||||
class DispatchTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_actual_sdk_returns_status_while_exec_and_tty_capture_are_running(self):
|
||||
exec_started, capture_started, release = (threading.Event() for _ in range(3))
|
||||
fixture = ChunkExecutor()
|
||||
calls = []
|
||||
|
||||
def execute(*args):
|
||||
calls.append(args)
|
||||
exec_started.set()
|
||||
release.wait(2)
|
||||
return {"ok": True, "marker": args[0]}
|
||||
|
||||
def screen(alias, script, authorized, timeout_s, job_id, sha256, include_stdout):
|
||||
capture_started.set()
|
||||
release.wait(2)
|
||||
return fixture(target=alias, script=script, authorized=authorized,
|
||||
timeout_s=timeout_s, job_id=job_id, sha256=sha256,
|
||||
include_stdout=include_stdout)
|
||||
|
||||
mcp, _ = portable(execute=execute, screen_execute=screen)
|
||||
slow = asyncio.create_task(mcp.call_tool("framebridge_exec", {
|
||||
"script": "bounded-marker", "authorized": True}))
|
||||
capture = asyncio.create_task(mcp.call_tool("cua_tty_observe", {
|
||||
"session_id": SESSION_ID, "authorized": True}))
|
||||
try:
|
||||
await until_set(exec_started)
|
||||
await until_set(capture_started)
|
||||
status = structured(await asyncio.wait_for(mcp.call_tool("framebridge_status", {}), .3))
|
||||
self.assertTrue(status["ok"])
|
||||
self.assertFalse(slow.done())
|
||||
self.assertFalse(capture.done())
|
||||
finally:
|
||||
release.set()
|
||||
exec_result, capture_result = await asyncio.gather(slow, capture)
|
||||
self.assertEqual(structured(exec_result)["marker"], "bounded-marker")
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertTrue(any(item.type == "image" for item in capture_result))
|
||||
|
||||
async def test_fast_exec_response_is_delivered_before_slow_exec_completes(self):
|
||||
started, release = threading.Event(), threading.Event()
|
||||
calls = []
|
||||
|
||||
def execute(*args):
|
||||
marker = args[0]
|
||||
calls.append(marker)
|
||||
if marker == "slow":
|
||||
started.set()
|
||||
release.wait(2)
|
||||
return {"ok": True, "marker": marker}
|
||||
|
||||
mcp, _ = portable(execute=execute)
|
||||
slow = asyncio.create_task(mcp.call_tool("framebridge_exec", {
|
||||
"script": "slow", "authorized": True}))
|
||||
try:
|
||||
await until_set(started)
|
||||
fast = structured(await asyncio.wait_for(mcp.call_tool("framebridge_exec", {
|
||||
"script": "fast", "authorized": True}), .3))
|
||||
self.assertEqual(fast["marker"], "fast")
|
||||
self.assertFalse(slow.done())
|
||||
finally:
|
||||
release.set()
|
||||
slow_result = await slow
|
||||
self.assertEqual(structured(slow_result)["marker"], "slow")
|
||||
self.assertCountEqual(calls, ["slow", "fast"])
|
||||
|
||||
async def test_full_portable_import_registers_tools_without_bootstrap_side_effects(self):
|
||||
import importlib.util
|
||||
from framebridge_expanded import fleet
|
||||
spec = importlib.util.spec_from_file_location("frontdoor_dispatch_test", PORTABLE)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with mock.patch.object(fleet, "bootstrap_original") as bootstrap:
|
||||
spec.loader.exec_module(module)
|
||||
bootstrap.assert_called_once_with()
|
||||
names = {tool.name for tool in await module.mcp.list_tools()}
|
||||
self.assertTrue(set(tty_mcp.TOOL_NAMES) <= names)
|
||||
self.assertTrue({"framebridge_exec", "framebridge_cua", "framebridge_screen_show"} <= names)
|
||||
self.assertFalse(inspect.iscoroutinefunction(module.framebridge_exec))
|
||||
|
||||
async def test_schemas_metadata_and_internal_sync_callbacks_are_preserved(self):
|
||||
mcp, namespace = portable()
|
||||
baseline, _ = portable(threaded=False)
|
||||
actual = {tool.name: tool.model_dump() for tool in await mcp.list_tools()}
|
||||
expected = {tool.name: tool.model_dump() for tool in await baseline.list_tools()}
|
||||
self.assertEqual(actual, expected)
|
||||
self.assertFalse(inspect.iscoroutinefunction(namespace["framebridge_exec"]))
|
||||
self.assertFalse(inspect.iscoroutinefunction(namespace["_screen_exec_request"]))
|
||||
self.assertIsInstance(namespace["framebridge_exec"]("marker", True), dict)
|
||||
|
||||
async def test_local_and_remote_tty_capture_use_identity_producing_transport(self):
|
||||
for target in ("LOCAL", "WORKER_1"):
|
||||
with self.subTest(target=target):
|
||||
fixture, calls = ChunkExecutor(), []
|
||||
|
||||
def screen(alias, script, authorized, timeout_s, job_id, sha256, include_stdout):
|
||||
calls.append(alias)
|
||||
return fixture(target=alias, script=script, authorized=authorized,
|
||||
timeout_s=timeout_s, job_id=job_id, sha256=sha256,
|
||||
include_stdout=include_stdout)
|
||||
|
||||
legacy = mock.Mock(side_effect=AssertionError("legacy EXEC must not capture"))
|
||||
mcp, _ = portable(execute=legacy, screen_execute=screen)
|
||||
result = await mcp.call_tool("cua_tty_observe", {
|
||||
"session_id": SESSION_ID, "authorized": True, "target": target})
|
||||
self.assertTrue(any(item.type == "image" for item in result))
|
||||
self.assertEqual(set(calls), {target})
|
||||
self.assertGreaterEqual(len(calls), 2)
|
||||
legacy.assert_not_called()
|
||||
|
||||
async def test_missing_uuid_and_wrong_reply_binding_still_fail_closed(self):
|
||||
for corruption, expected in (("uuid", "SCREEN_EXEC_NODE_ID_INVALID"),
|
||||
("hash", "SCREEN_EXEC_BINDING_INVALID")):
|
||||
with self.subTest(corruption=corruption):
|
||||
fixture = ChunkExecutor()
|
||||
|
||||
def screen(alias, script, authorized, timeout_s, job_id, sha256, include_stdout):
|
||||
result = fixture(target=alias, script=script, authorized=authorized,
|
||||
timeout_s=timeout_s, job_id=job_id, sha256=sha256,
|
||||
include_stdout=include_stdout)
|
||||
if corruption == "uuid":
|
||||
result.pop("node_id")
|
||||
else:
|
||||
result["mcp_script_sha256"] = "0" * 64
|
||||
return result
|
||||
|
||||
mcp, _ = portable(screen_execute=screen)
|
||||
result = structured(await mcp.call_tool("cua_tty_observe", {
|
||||
"session_id": SESSION_ID, "authorized": True}))
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["error"], expected)
|
||||
|
||||
async def test_other_tty_rpcs_also_leave_status_responsive(self):
|
||||
started, release = threading.Event(), threading.Event()
|
||||
mcp, _ = portable()
|
||||
|
||||
def request(*args):
|
||||
started.set()
|
||||
release.wait(2)
|
||||
return {"ok": True}
|
||||
|
||||
with mock.patch.object(tty_mcp, "_remote_request", side_effect=request):
|
||||
slow = asyncio.create_task(mcp.call_tool("cua_tty_status", {
|
||||
"authorized": True, "target": "WORKER_1"}))
|
||||
try:
|
||||
await until_set(started)
|
||||
self.assertTrue(structured(await asyncio.wait_for(
|
||||
mcp.call_tool("framebridge_status", {}), .3))["ok"])
|
||||
self.assertFalse(slow.done())
|
||||
finally:
|
||||
release.set()
|
||||
await slow
|
||||
|
||||
async def test_anyio_cancellation_waits_for_single_worker_without_replay(self):
|
||||
started, release, finished = (threading.Event() for _ in range(3))
|
||||
mcp, namespace = portable()
|
||||
calls, scopes = [], []
|
||||
|
||||
@namespace["_blocking_tools"].tool()
|
||||
def bounded_operation() -> dict:
|
||||
calls.append(1)
|
||||
started.set()
|
||||
release.wait(2)
|
||||
finished.set()
|
||||
return {"ok": True}
|
||||
|
||||
async def dispatch():
|
||||
with anyio.CancelScope() as scope:
|
||||
scopes.append(scope)
|
||||
await mcp.call_tool("bounded_operation", {})
|
||||
|
||||
async with anyio.create_task_group() as group:
|
||||
group.start_soon(dispatch)
|
||||
await until_set(started)
|
||||
scopes[0].cancel()
|
||||
await anyio.sleep(.02)
|
||||
self.assertFalse(finished.is_set())
|
||||
release.set()
|
||||
self.assertTrue(finished.is_set())
|
||||
self.assertEqual(calls, [1])
|
||||
|
||||
|
||||
class AutomaticJobTests(unittest.TestCase):
|
||||
def test_identical_concurrent_auto_jobs_are_distinct_and_explicit_ids_unchanged(self):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
tree = ast.parse(PORTABLE.read_text())
|
||||
function = next(node for node in tree.body if isinstance(node, ast.FunctionDef)
|
||||
and node.name == "_framebridge_exec_geranas_impl")
|
||||
namespace = dict(hashlib=hashlib, time=time, uuid=uuid,
|
||||
JOB_RE=re.compile(r"^[A-Za-z0-9._-]{1,80}$"),
|
||||
adapter_request=lambda payload: {"ok": True, "job_id": payload["job_id"]},
|
||||
sanitize_text=lambda text: text)
|
||||
exec(compile(ast.Module(body=[function], type_ignores=[]), str(PORTABLE), "exec"), namespace)
|
||||
execute = namespace["_framebridge_exec_geranas_impl"]
|
||||
with mock.patch.object(time, "strftime", return_value="20261028-000000"):
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
results = list(pool.map(lambda _: execute("same-command", True), range(16)))
|
||||
self.assertEqual(len({result["job_id"] for result in results}), 16)
|
||||
self.assertTrue(all(namespace["JOB_RE"].fullmatch(result["job_id"]) for result in results))
|
||||
first = execute("same-command", True, job_id="owner-idempotent-id")
|
||||
second = execute("same-command", True, job_id="owner-idempotent-id")
|
||||
self.assertEqual(first["job_id"], "owner-idempotent-id")
|
||||
self.assertEqual(first["job_id"], second["job_id"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Deterministic worker initialization races, using only in-memory fixtures."""
|
||||
import ast
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
WORK = Path(__file__).resolve().parents[1]
|
||||
EXPANDED = WORK / "expanded/framebridge_expanded"
|
||||
|
||||
|
||||
def definitions(module, names, namespace):
|
||||
tree = ast.parse((EXPANDED / module).read_text())
|
||||
selected = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in names]
|
||||
exec(compile(ast.Module(body=selected, type_ignores=[]), str(EXPANDED / module), "exec"), namespace)
|
||||
return namespace
|
||||
|
||||
|
||||
class ObservedRLock:
|
||||
"""Signal when the competing worker reaches the lock, without timing guesses."""
|
||||
def __init__(self):
|
||||
self.lock = threading.RLock()
|
||||
self.waiting = threading.Event()
|
||||
self.watch = None
|
||||
|
||||
def __enter__(self):
|
||||
if threading.get_ident() == self.watch:
|
||||
self.waiting.set()
|
||||
self.lock.acquire()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.lock.release()
|
||||
|
||||
def watched(self, function):
|
||||
self.watch = threading.get_ident()
|
||||
return function()
|
||||
|
||||
|
||||
class WorkerInitializationTests(unittest.TestCase):
|
||||
def test_runtime_publishes_complete_module_once_to_competing_workers(self):
|
||||
started, release = threading.Event(), threading.Event()
|
||||
lock, module = ObservedRLock(), SimpleNamespace()
|
||||
loads = []
|
||||
|
||||
def initialize(target):
|
||||
loads.append(target)
|
||||
started.set()
|
||||
if not release.wait(2):
|
||||
raise AssertionError("fixture load was not released")
|
||||
target.settings = "complete"
|
||||
|
||||
loader = SimpleNamespace(name="fixture", exec_module=initialize)
|
||||
namespace = definitions("runtime.py", {"_legacy"}, dict(
|
||||
_LEGACY=None, _LEGACY_LOCK=lock, PREFIX=Path("/fixture"),
|
||||
importlib=SimpleNamespace(
|
||||
machinery=SimpleNamespace(SourceFileLoader=lambda *args: loader),
|
||||
util=SimpleNamespace(spec_from_loader=lambda *args: object(),
|
||||
module_from_spec=lambda spec: module))))
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
first = pool.submit(namespace["_legacy"])
|
||||
try:
|
||||
self.assertTrue(started.wait(1))
|
||||
self.assertIsNone(namespace["_LEGACY"])
|
||||
second = pool.submit(lock.watched, namespace["_legacy"])
|
||||
self.assertTrue(lock.waiting.wait(1))
|
||||
self.assertFalse(second.done())
|
||||
finally:
|
||||
release.set()
|
||||
self.assertIs(first.result(1), module)
|
||||
self.assertIs(second.result(1), module)
|
||||
self.assertEqual(module.settings, "complete")
|
||||
self.assertEqual(loads, [module])
|
||||
|
||||
def test_runtime_failed_initialization_remains_retryable(self):
|
||||
attempts = []
|
||||
module = SimpleNamespace()
|
||||
|
||||
def initialize(target):
|
||||
attempts.append(target)
|
||||
if len(attempts) == 1:
|
||||
raise RuntimeError("fixture failure")
|
||||
target.settings = "complete"
|
||||
|
||||
loader = SimpleNamespace(name="fixture", exec_module=initialize)
|
||||
namespace = definitions("runtime.py", {"_legacy"}, dict(
|
||||
_LEGACY=None, _LEGACY_LOCK=threading.RLock(), PREFIX=Path("/fixture"),
|
||||
importlib=SimpleNamespace(
|
||||
machinery=SimpleNamespace(SourceFileLoader=lambda *args: loader),
|
||||
util=SimpleNamespace(spec_from_loader=lambda *args: object(),
|
||||
module_from_spec=lambda spec: module))))
|
||||
with self.assertRaisesRegex(RuntimeError, "fixture failure"):
|
||||
namespace["_legacy"]()
|
||||
self.assertIsNone(namespace["_LEGACY"])
|
||||
self.assertIs(namespace["_legacy"](), module)
|
||||
self.assertEqual(len(attempts), 2)
|
||||
self.assertEqual(module.settings, "complete")
|
||||
|
||||
def test_fleet_bootstrap_serializes_shared_path_check_remove_and_append(self):
|
||||
started, release = threading.Event(), threading.Event()
|
||||
lock = ObservedRLock()
|
||||
broker, fleet = Path("/fixture/broker"), Path("/fixture/fleet")
|
||||
|
||||
class Paths(list):
|
||||
def remove(self, value):
|
||||
if not started.is_set():
|
||||
started.set()
|
||||
if not release.wait(2):
|
||||
raise AssertionError("fixture path update was not released")
|
||||
super().remove(value)
|
||||
|
||||
paths = Paths([str(broker), str(fleet)])
|
||||
package = SimpleNamespace(__file__=str(broker / "framebridge_linux_chatgpt/__init__.py"),
|
||||
__path__=[])
|
||||
namespace = definitions("fleet.py", {"bootstrap_original"}, dict(
|
||||
_BOOTSTRAP_LOCK=lock, original_paths=lambda: (broker, fleet), Path=Path,
|
||||
sys=SimpleNamespace(path=paths),
|
||||
_require=lambda valid, code: self.assertTrue(valid, code)))
|
||||
with mock.patch.dict(sys.modules, {"framebridge_linux_chatgpt": package}):
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
first = pool.submit(namespace["bootstrap_original"])
|
||||
try:
|
||||
self.assertTrue(started.wait(1))
|
||||
second = pool.submit(lock.watched, namespace["bootstrap_original"])
|
||||
self.assertTrue(lock.waiting.wait(1))
|
||||
self.assertFalse(second.done())
|
||||
finally:
|
||||
release.set()
|
||||
first.result(1)
|
||||
second.result(1)
|
||||
self.assertEqual(paths.count(str(broker)), 1)
|
||||
self.assertEqual(paths.count(str(fleet)), 1)
|
||||
self.assertCountEqual(package.__path__, [str(root / "framebridge_linux_chatgpt")
|
||||
for root in (broker, fleet)])
|
||||
|
||||
|
||||
def broker_namespace(self, config):
|
||||
def validate(url):
|
||||
if url == "invalid-fixture":
|
||||
raise ValueError("fixture endpoint rejected")
|
||||
return url
|
||||
|
||||
return definitions("broker.py", {"configure", "_settings"}, dict(
|
||||
_state=None, _url=None, _control_path=None,
|
||||
_SETTINGS_LOCK=ObservedRLock(), Path=Path,
|
||||
state_path=lambda state=None: Path(state or "/fixture/default"),
|
||||
_config=config, validate_endpoint=validate))
|
||||
|
||||
def test_broker_initial_settings_are_published_as_one_complete_profile(self):
|
||||
started, release = threading.Event(), threading.Event()
|
||||
loads = []
|
||||
|
||||
def config(state):
|
||||
loads.append(state)
|
||||
started.set()
|
||||
if not release.wait(2):
|
||||
raise AssertionError("fixture config was not released")
|
||||
return {"ports": {"broker": 23456}}
|
||||
|
||||
namespace = self.broker_namespace(config)
|
||||
lock = namespace["_SETTINGS_LOCK"]
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
first = pool.submit(namespace["_settings"])
|
||||
try:
|
||||
self.assertTrue(started.wait(1))
|
||||
self.assertEqual(tuple(namespace[key] for key in ("_state", "_url", "_control_path")),
|
||||
(None, None, None))
|
||||
second = pool.submit(lock.watched, namespace["_settings"])
|
||||
self.assertTrue(lock.waiting.wait(1))
|
||||
self.assertFalse(second.done())
|
||||
finally:
|
||||
release.set()
|
||||
expected = (Path("/fixture/default"), "http://127.0.0.1:23456",
|
||||
Path("/fixture/default/broker/control.key"))
|
||||
self.assertEqual(first.result(1), expected)
|
||||
self.assertEqual(second.result(1), expected)
|
||||
self.assertEqual(loads, [Path("/fixture/default")])
|
||||
|
||||
def test_broker_explicit_profile_change_cannot_expose_mixed_endpoint_or_key_path(self):
|
||||
started, release = threading.Event(), threading.Event()
|
||||
|
||||
def config(state):
|
||||
if state == Path("/fixture/next"):
|
||||
started.set()
|
||||
if not release.wait(2):
|
||||
raise AssertionError("fixture config was not released")
|
||||
return {"ports": {"broker": 23456}}
|
||||
|
||||
namespace = self.broker_namespace(config)
|
||||
previous = namespace["_settings"]()
|
||||
lock = namespace["_SETTINGS_LOCK"]
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
first = pool.submit(namespace["configure"], "https://fixture.example:24443",
|
||||
"/fixture/next", "/fixture/next/explicit-control.key")
|
||||
try:
|
||||
self.assertTrue(started.wait(1))
|
||||
self.assertEqual(tuple(namespace[key] for key in ("_state", "_url", "_control_path")),
|
||||
previous)
|
||||
second = pool.submit(lock.watched, namespace["_settings"])
|
||||
self.assertTrue(lock.waiting.wait(1))
|
||||
self.assertFalse(second.done())
|
||||
finally:
|
||||
release.set()
|
||||
first.result(1)
|
||||
self.assertEqual(second.result(1), (Path("/fixture/next"),
|
||||
"https://fixture.example:24443", Path("/fixture/next/explicit-control.key")))
|
||||
|
||||
def test_broker_failed_validation_publishes_nothing_and_can_retry(self):
|
||||
namespace = self.broker_namespace(lambda state: {})
|
||||
with self.assertRaisesRegex(ValueError, "fixture endpoint rejected"):
|
||||
namespace["configure"]("invalid-fixture", "/fixture/rejected")
|
||||
self.assertEqual(tuple(namespace[key] for key in ("_state", "_url", "_control_path")),
|
||||
(None, None, None))
|
||||
previous = namespace["_settings"]()
|
||||
with self.assertRaisesRegex(ValueError, "fixture endpoint rejected"):
|
||||
namespace["configure"]("invalid-fixture", "/fixture/rejected")
|
||||
self.assertEqual(namespace["_settings"](), previous)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Actual HTTP MCP startup is independent of ambient WebSocket libraries."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from framebridge_expanded import __version__
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class HttpMcpTransportTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix='fb-http-transport-')
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.directory = Path(self.temporary.name)
|
||||
self.ambient = self.directory/'ambient'
|
||||
package = self.ambient/'websockets'
|
||||
package.mkdir(parents=True)
|
||||
self.marker = self.directory/'unwanted-websocket-import'
|
||||
(package/'__init__.py').write_text(
|
||||
"import os\nfrom pathlib import Path\n"
|
||||
"Path(os.environ['FB_TEST_WS_MARKER']).write_text('imported')\n"
|
||||
"raise RuntimeError('INCOMPATIBLE_AMBIENT_WEBSOCKETS')\n")
|
||||
home = self.directory/'home'
|
||||
home.mkdir(mode=0o700)
|
||||
self.env = dict(os.environ, HOME=str(home), PYTHONNOUSERSITE='1',
|
||||
PYTHONDONTWRITEBYTECODE='1',
|
||||
FB_TEST_WS_MARKER=str(self.marker),
|
||||
FRAMEBRIDGE_EXPANDED_STATE=str(home/'state'),
|
||||
FRAMEBRIDGE_STATE_DIR=str(home/'state'),
|
||||
FRAMEBRIDGE_TOOL_ROOT=str(home/'adapter'),
|
||||
FRAMEBRIDGE_BROKER_SOURCE=str(SOURCE/'sources/original-geranas/FrameBridge-Broker-RC1/src'),
|
||||
FRAMEBRIDGE_FLEET_SOURCE=str(SOURCE/'sources/original-geranas/FrameBridge-Fleet-Hub/app'))
|
||||
# Match the child search path exported by runtime.environment(), with an
|
||||
# intentionally incompatible optional library taking precedence.
|
||||
self.env['PYTHONPATH'] = os.pathsep.join(map(str, [
|
||||
self.ambient, SOURCE/'expanded', SOURCE/'portable',
|
||||
SOURCE/'sources/original-geranas/FrameBridge-Fleet-Hub/vendor']))
|
||||
|
||||
def test_default_websocket_detection_would_load_the_incompatible_library(self):
|
||||
code = "from mcp.server.fastmcp import FastMCP; import uvicorn; uvicorn.Config(FastMCP('negative-control').streamable_http_app()).load()"
|
||||
result = subprocess.run([sys.executable, '-c', code], env=self.env,
|
||||
capture_output=True, text=True, timeout=15)
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn('INCOMPATIBLE_AMBIENT_WEBSOCKETS', result.stderr)
|
||||
self.assertTrue(self.marker.exists())
|
||||
|
||||
def start(self, name):
|
||||
with socket.socket() as reservation:
|
||||
reservation.bind(('127.0.0.1', 0))
|
||||
self.port = reservation.getsockname()[1]
|
||||
if name == 'fleet':
|
||||
command = [sys.executable, '-m', 'framebridge_expanded.fleet']
|
||||
self.env['FRAMEBRIDGE_FLEET_HUB_PORT'] = str(self.port)
|
||||
else:
|
||||
command = [sys.executable, str(SOURCE/'portable/front_door_mcp.py')]
|
||||
self.env['FRAMEBRIDGE_FRONT_DOOR_PORT'] = str(self.port)
|
||||
log = (self.directory/(name+'.log')).open('w')
|
||||
self.process = subprocess.Popen(command, env=self.env, cwd=self.directory,
|
||||
stdin=subprocess.DEVNULL, stdout=log,
|
||||
stderr=log, start_new_session=True)
|
||||
log.close()
|
||||
self.addCleanup(self.stop)
|
||||
self.session = None
|
||||
self.protocol = None
|
||||
deadline = time.monotonic()+12
|
||||
while time.monotonic() < deadline:
|
||||
if self.process.poll() is not None:
|
||||
self.fail('HTTP component exited: '+(self.directory/(name+'.log')).read_text()[-2000:])
|
||||
try:
|
||||
reply, headers, _ = self.rpc({'jsonrpc':'2.0', 'id':1,
|
||||
'method':'initialize', 'params':{'protocolVersion':'2025-11-25',
|
||||
'capabilities':{}, 'clientInfo':{'name':'framebridge-http-qa','version':'1'}}})
|
||||
break
|
||||
except urllib.error.URLError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
self.fail('HTTP component did not become ready')
|
||||
self.assertIn('result', reply)
|
||||
self.assertIn('serverInfo', reply['result'])
|
||||
self.assertTrue(headers.get('Content-Type', '').startswith('text/event-stream'))
|
||||
self.session = headers.get('Mcp-Session-Id')
|
||||
self.assertTrue(self.session)
|
||||
self.protocol = reply['result']['protocolVersion']
|
||||
_, _, code = self.rpc({'jsonrpc':'2.0', 'method':'notifications/initialized'})
|
||||
self.assertEqual(202, code)
|
||||
|
||||
def stop(self):
|
||||
if self.process.poll() is None:
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
self.process.wait(timeout=5)
|
||||
|
||||
def rpc(self, value):
|
||||
headers = {'Content-Type':'application/json',
|
||||
'Accept':'application/json, text/event-stream'}
|
||||
if self.session:
|
||||
headers['Mcp-Session-Id'] = self.session
|
||||
if self.protocol:
|
||||
headers['Mcp-Protocol-Version'] = self.protocol
|
||||
request = urllib.request.Request('http://127.0.0.1:'+str(self.port)+'/mcp',
|
||||
data=json.dumps(value).encode(), headers=headers, method='POST')
|
||||
with urllib.request.urlopen(request, timeout=3) as response:
|
||||
raw = response.read().decode()
|
||||
if raw and response.headers.get('Content-Type','').startswith('text/event-stream'):
|
||||
raw = next(line[5:].strip() for line in raw.splitlines() if line.startswith('data:'))
|
||||
return json.loads(raw) if raw else None, response.headers, response.status
|
||||
|
||||
def test_fleet_initializes_and_lists_tools_without_websocket_import(self):
|
||||
self.start('fleet')
|
||||
result, _, status = self.rpc({'jsonrpc':'2.0','id':2,'method':'tools/list'})
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual(6, len(result['result']['tools']))
|
||||
self.assertFalse(self.marker.exists())
|
||||
|
||||
def test_actual_frontdoor_help_and_execution_denial_over_streamable_http(self):
|
||||
self.start('frontdoor')
|
||||
result, _, status = self.rpc({'jsonrpc':'2.0','id':2,'method':'tools/list'})
|
||||
self.assertEqual(200, status)
|
||||
names = {tool['name'] for tool in result['result']['tools']}
|
||||
self.assertTrue({'framebridge_help','framebridge_exec'}.issubset(names))
|
||||
reply, _, _ = self.rpc({'jsonrpc':'2.0','id':3,'method':'tools/call',
|
||||
'params':{'name':'framebridge_help','arguments':{'topic':'start'}}})
|
||||
self.assertIn(__version__, json.dumps(reply['result']))
|
||||
denied, _, _ = self.rpc({'jsonrpc':'2.0','id':4,'method':'tools/call',
|
||||
'params':{'name':'framebridge_exec',
|
||||
'arguments':{'script':'true','authorized':False}}})
|
||||
self.assertIn('AUTHORIZED_TRUE_REQUIRED', json.dumps(denied['result']))
|
||||
self.assertFalse((Path(self.env['FRAMEBRIDGE_TOOL_ROOT'])/'adapter.sock').exists())
|
||||
self.assertFalse(self.marker.exists())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Candidate profile/ABI selection; tests use inert native-file fixtures."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import stat
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from framebridge_expanded import compatibility as compat
|
||||
from framebridge_expanded import runtime, uninstall
|
||||
from framebridge_expanded import display_context, network
|
||||
|
||||
|
||||
class LinuxCompatibilityTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.prefix = Path(self.temp.name)
|
||||
|
||||
def artifact(self, profile_id):
|
||||
_distro, _release, minor, abi = compat.PROFILES[profile_id]
|
||||
vendor = self.prefix / compat.VENDOR_RELATIVE
|
||||
vendor.mkdir(parents=True, exist_ok=True)
|
||||
for old in vendor.rglob("*.so"):
|
||||
old.unlink()
|
||||
# These inert files test selection only; package tests inspect real ELF.
|
||||
suffix = ".cpython-" + abi[2:] + "-x86_64-linux-gnu.so"
|
||||
for rel in ["_cffi_backend", "pydantic_core/_pydantic_core", "rpds/rpds"]:
|
||||
path = vendor / (rel + suffix)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"synthetic ABI filename fixture, never imported")
|
||||
manifest = dict(schema=1, profile_id=profile_id, architecture="amd64",
|
||||
python_minor=minor, python_abi=abi, validation_status="candidate")
|
||||
(self.prefix / "release-profile.json").write_text(json.dumps(manifest))
|
||||
return manifest
|
||||
|
||||
def check(self, distro, version, python, **options):
|
||||
return compat.assess(self.prefix, os_release={"ID": distro, "VERSION_ID": version},
|
||||
machine=options.pop("machine", "x86_64"), python_version=python,
|
||||
libc=options.pop("libc", ("glibc", "2.39")), **options)
|
||||
|
||||
def test_explicit_candidate_profiles_require_matching_artifacts(self):
|
||||
targets = [("debian13-amd64-py313", "debian", "13", (3, 13)),
|
||||
("ubuntu24.04-amd64-py312", "ubuntu", "24.04", (3, 12)),
|
||||
("ubuntu26.04-amd64-py314", "ubuntu", "26.04", (3, 14)),
|
||||
("mint22-amd64-py312", "linuxmint", "22.3", (3, 12)),
|
||||
("lmde7-amd64-py313", "linuxmint", "7", (3, 13))]
|
||||
for name, distro, version, python in targets:
|
||||
with self.subTest(name=name):
|
||||
self.artifact(name)
|
||||
result = self.check(distro, version, python)
|
||||
self.assertTrue(result["ok"], result)
|
||||
self.assertEqual(name, result["profile_id"])
|
||||
self.assertEqual("candidate", result["validation_status"])
|
||||
self.assertTrue(result["warnings"])
|
||||
|
||||
def test_version_and_derivative_identity_do_not_silently_opt_in(self):
|
||||
self.artifact("ubuntu24.04-amd64-py312")
|
||||
for distro, version in [("ubuntu", "22.04"), ("ubuntu", "24.10"),
|
||||
("linuxmint", "21.3"), ("debian", "12"), ("pop", "24.04")]:
|
||||
with self.subTest(distro=distro, version=version):
|
||||
self.assertFalse(self.check(distro, version, (3, 12))["ok"])
|
||||
self.assertIsNone(compat.profile_for({"ID": "other", "ID_LIKE": "ubuntu", "VERSION_ID": "24.04"}))
|
||||
|
||||
def test_interpreter_artifact_and_cpu_must_all_match(self):
|
||||
self.artifact("ubuntu24.04-amd64-py312")
|
||||
self.assertFalse(self.check("ubuntu", "24.04", (3, 13))["ok"])
|
||||
self.assertFalse(self.check("linuxmint", "22.1", (3, 12))["ok"])
|
||||
self.assertFalse(self.check("ubuntu", "24.04", (3, 12), machine="aarch64")["ok"])
|
||||
|
||||
def test_nonstandard_interpreter_abi_is_rejected(self):
|
||||
self.artifact("ubuntu24.04-amd64-py312")
|
||||
self.assertFalse(self.check("ubuntu", "24.04", (3, 12), implementation="PyPy")["ok"])
|
||||
self.assertFalse(self.check("ubuntu", "24.04", (3, 12), abi_flags="t")["ok"])
|
||||
|
||||
def test_mixed_native_abi_and_missing_extensions_are_rejected(self):
|
||||
self.artifact("ubuntu24.04-amd64-py312")
|
||||
vendor = self.prefix / compat.VENDOR_RELATIVE
|
||||
path = next(vendor.glob("_cffi_backend*.so"))
|
||||
path.rename(path.with_name(path.name.replace("cpython-312", "cpython-313")))
|
||||
result = self.check("ubuntu", "24.04", (3, 12))
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertTrue(any("native extension does not match" in issue for issue in result["issues"]))
|
||||
self.artifact("ubuntu24.04-amd64-py312")
|
||||
path = next(vendor.glob("_cffi_backend*.so"))
|
||||
path.rename(path.with_name(path.name.replace("cpython-312", "cpython-312t")))
|
||||
self.assertFalse(self.check("ubuntu", "24.04", (3, 12))["ok"])
|
||||
self.artifact("ubuntu24.04-amd64-py312")
|
||||
next((vendor / "rpds").glob("*.so")).unlink()
|
||||
self.assertFalse(self.check("ubuntu", "24.04", (3, 12))["ok"])
|
||||
|
||||
def test_libc_floor_and_libc_family_are_checked(self):
|
||||
self.artifact("ubuntu24.04-amd64-py312")
|
||||
for libc in [("glibc", "2.33"), ("musl", "1.2.5"), ("", "")]:
|
||||
with self.subTest(libc=libc):
|
||||
self.assertFalse(self.check("ubuntu", "24.04", (3, 12), libc=libc)["ok"])
|
||||
self.assertTrue(self.check("ubuntu", "24.04", (3, 12), libc=("glibc", "2.34"))["ok"])
|
||||
|
||||
def test_invalid_or_inconsistent_manifest_fails_closed(self):
|
||||
original = self.artifact("ubuntu24.04-amd64-py312")
|
||||
path = self.prefix / "release-profile.json"
|
||||
for value in ["{", "[]", json.dumps({**original, "schema": True}),
|
||||
json.dumps({**original, "python_abi": "cp313"}),
|
||||
json.dumps({**original, "validation_status": "unknown"})]:
|
||||
with self.subTest(value=value):
|
||||
path.write_text(value)
|
||||
result = self.check("ubuntu", "24.04", (3, 12))
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertTrue(any("Invalid release-profile" in issue for issue in result["issues"]))
|
||||
|
||||
def test_missing_manifest_cannot_silently_relabel_legacy_vendor(self):
|
||||
self.artifact("debian13-amd64-py313")
|
||||
(self.prefix / "release-profile.json").unlink()
|
||||
self.assertFalse(self.check("linuxmint", "22.1", (3, 13))["ok"])
|
||||
self.assertTrue(self.check("debian", "13", (3, 13))["ok"])
|
||||
with patch.object(compat, "INSTALLED_PREFIX", self.prefix):
|
||||
self.assertFalse(self.check("debian", "13", (3, 13))["ok"])
|
||||
|
||||
def test_wayland_diagnostic_never_claims_native_desktop_control(self):
|
||||
result = compat.display_support({"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"}, {"kind": "text"})
|
||||
self.assertFalse(result["native_wayland_cua"])
|
||||
self.assertFalse(result["physical_x11_verified"])
|
||||
self.assertIn("XWayland", result["notes"][0])
|
||||
virtual = compat.display_support({"WAYLAND_DISPLAY": "wayland-0"}, {"kind": "virtual-x11"})
|
||||
self.assertTrue(virtual["private_virtual_x11"])
|
||||
self.assertFalse(virtual["native_wayland_cua"])
|
||||
|
||||
def test_doctor_uses_profile_result_and_start_stops_before_creating_files(self):
|
||||
rejected = dict(ok=False, profile_id="ubuntu24.04-amd64-py312", issues=["ABI mismatch"], warnings=[])
|
||||
with patch.object(runtime, "state_path", return_value=self.prefix), \
|
||||
patch.object(runtime, "load_config", return_value={"role": "node", "headless": "auto"}), \
|
||||
patch.object(runtime, "environment", return_value={"XDG_SESSION_TYPE": "wayland"}), \
|
||||
patch.object(runtime.shutil, "which", return_value="/fixture/command"), \
|
||||
patch.object(compat, "assess", return_value=rejected), \
|
||||
patch.object(display_context, "missing_graphical_commands", return_value=[]), \
|
||||
patch.object(display_context, "describe", return_value={"kind": "text"}), \
|
||||
patch.object(network, "inspect", return_value={}), \
|
||||
patch.object(runtime, "status", return_value={"running": False}):
|
||||
doctor = runtime.doctor(self.prefix)
|
||||
self.assertFalse(doctor["ok"])
|
||||
self.assertIn("ABI mismatch", doctor["issues"])
|
||||
self.assertFalse(doctor["compatible_debian_13_amd64"])
|
||||
self.assertEqual("wayland", doctor["display_support"]["session_type"])
|
||||
self.assertFalse(runtime.start(self.prefix)["ok"])
|
||||
self.assertFalse((self.prefix / "logs").exists())
|
||||
|
||||
|
||||
class ManagedInterpreterValidationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.state = "/home/fixture/profile"
|
||||
self.unit = self.root / ("framebridge-autostart-" + hashlib.sha256(self.state.encode()).hexdigest()[:16] + ".service")
|
||||
|
||||
def unit_text(self, interpreter):
|
||||
return '\n'.join(["# FrameBridge managed autostart v1", 'Environment="PYTHONPATH=' + uninstall.MODULE_ROOT + '"',
|
||||
"ExecStart=" + interpreter + " -B -m framebridge_expanded.autostart _run --state " + self.state])
|
||||
|
||||
def test_matching_minor_system_interpreters_preserve_cleanup(self):
|
||||
self.assertEqual(compat.SYSTEM_PYTHONS, uninstall.SYSTEM_PYTHONS)
|
||||
for interpreter in sorted(compat.SYSTEM_PYTHONS):
|
||||
with self.subTest(interpreter=interpreter):
|
||||
self.unit.write_text(self.unit_text(interpreter))
|
||||
self.assertTrue(uninstall.managed_unit(self.unit, os.getuid()))
|
||||
|
||||
def test_arbitrary_interpreters_duplicate_commands_and_links_remain_rejected(self):
|
||||
for interpreter in ["/tmp/python3.12", "/usr/bin/python3.11", "/usr/local/bin/python3.14"]:
|
||||
self.unit.write_text(self.unit_text(interpreter))
|
||||
self.assertFalse(uninstall.managed_unit(self.unit, os.getuid()))
|
||||
self.unit.write_text(self.unit_text("/usr/bin/python3.12") + "\nExecStart=/bin/true")
|
||||
self.assertFalse(uninstall.managed_unit(self.unit, os.getuid()))
|
||||
real = self.root / "real"
|
||||
real.write_text(self.unit_text("/usr/bin/python3.12"))
|
||||
self.unit.unlink()
|
||||
self.unit.symlink_to(real)
|
||||
self.assertFalse(uninstall.managed_unit(self.unit, os.getuid()))
|
||||
|
||||
|
||||
class RootHelperCleanupTests(unittest.TestCase):
|
||||
def test_root_purge_uses_only_fixed_helper_and_bounded_environment(self):
|
||||
calls = []
|
||||
def run(args, **options):
|
||||
calls.append((args, options))
|
||||
return SimpleNamespace(returncode=0)
|
||||
with patch.object(uninstall.os, "getuid", return_value=0), \
|
||||
patch.object(uninstall.os.path, "lexists", return_value=True), \
|
||||
patch.object(uninstall, "_trusted_root_file", return_value=True):
|
||||
uninstall.purge_credential_helpers(run=run)
|
||||
self.assertEqual([str(uninstall.CREDENTIAL_HELPER), "purge-all"], calls[0][0])
|
||||
self.assertEqual({"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"}, calls[0][1]["env"])
|
||||
self.assertEqual(30, calls[0][1]["timeout"])
|
||||
|
||||
def test_nonroot_or_unsafe_helper_never_executes(self):
|
||||
calls = []
|
||||
with patch.object(uninstall.os, "getuid", return_value=1000):
|
||||
with self.assertRaises(RuntimeError):
|
||||
uninstall.purge_credential_helpers(run=lambda *a, **kw: calls.append(a))
|
||||
with patch.object(uninstall.os, "getuid", return_value=0), \
|
||||
patch.object(uninstall.os.path, "lexists", return_value=True), \
|
||||
patch.object(uninstall, "_trusted_root_file", return_value=False):
|
||||
with self.assertRaises(RuntimeError):
|
||||
uninstall.purge_credential_helpers(run=lambda *a, **kw: calls.append(a))
|
||||
self.assertEqual([], calls)
|
||||
|
||||
def test_failure_to_purge_pauses_removal(self):
|
||||
with patch.object(uninstall.os, "getuid", return_value=0), \
|
||||
patch.object(uninstall.os.path, "lexists", return_value=True), \
|
||||
patch.object(uninstall, "_trusted_root_file", return_value=True):
|
||||
with self.assertRaises(RuntimeError):
|
||||
uninstall.purge_credential_helpers(run=lambda *a, **kw: SimpleNamespace(returncode=1))
|
||||
|
||||
def test_fixed_helper_trust_rejects_links_nonroot_and_writable_parents(self):
|
||||
target = uninstall.CREDENTIAL_HELPER
|
||||
for mode, owner in [(stat.S_IFLNK | 0o777, 0), (stat.S_IFREG | 0o755, 1000),
|
||||
(stat.S_IFREG | 0o777, 0)]:
|
||||
with self.subTest(mode=mode, owner=owner), \
|
||||
patch.object(Path, "lstat", return_value=SimpleNamespace(st_mode=mode, st_uid=owner)):
|
||||
self.assertFalse(uninstall._trusted_root_file(target))
|
||||
def info(path):
|
||||
return SimpleNamespace(st_uid=0, st_mode=(stat.S_IFREG | 0o755) if path == target else (stat.S_IFDIR | 0o775))
|
||||
with patch.object(Path, "lstat", autospec=True, side_effect=info):
|
||||
self.assertFalse(uninstall._trusted_root_file(target))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -63,7 +63,7 @@ class NativeDispatchTests(unittest.TestCase):
|
||||
|
||||
def test_postflight_failure_after_input_retains_uncertain_outcome(self):
|
||||
calls = []
|
||||
cua = self.fake(calls, lambda engine: engine._run(["xdotool", "type", "x"]))
|
||||
cua = self.fake(calls, lambda engine: engine._run(["xdotool", "key", "x"]))
|
||||
state = nh.install_cua_guard(cua, env={}, checker=self.checker(3))
|
||||
with self.assertRaises(CuaError):
|
||||
cua.handle_cua_message({})
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Native-host TYPE interception tests with an entirely fake CUA/display.
|
||||
|
||||
No captured code, browser, display, library loader, or native input is used.
|
||||
"""
|
||||
import importlib.util
|
||||
import ast
|
||||
import ctypes as C
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import subprocess
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
|
||||
PACKAGE = "_framebridge_native_host_x11_test"
|
||||
ROOT = Path(__file__).parent
|
||||
if not (ROOT / "x11_text.py").is_file():
|
||||
ROOT = ROOT.parent / "expanded/framebridge_expanded"
|
||||
package = ModuleType(PACKAGE)
|
||||
package.__path__ = [str(ROOT)]
|
||||
display = ModuleType(PACKAGE + ".display_context")
|
||||
|
||||
|
||||
class DisplayContextError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
display.DisplayContextError = DisplayContextError
|
||||
display.check = lambda env: None
|
||||
display.describe = lambda env: {"identity": "fixture-owner"}
|
||||
sys.modules[PACKAGE] = package
|
||||
sys.modules[display.__name__] = display
|
||||
text_spec = importlib.util.spec_from_file_location(PACKAGE + ".x11_text", ROOT / "x11_text.py")
|
||||
text_module = importlib.util.module_from_spec(text_spec)
|
||||
sys.modules[text_spec.name] = text_module
|
||||
text_spec.loader.exec_module(text_module)
|
||||
spec = importlib.util.spec_from_file_location(PACKAGE + ".native_host", ROOT / "native_host.py")
|
||||
native_host = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = native_host
|
||||
spec.loader.exec_module(native_host)
|
||||
|
||||
|
||||
class CuaTransportError(RuntimeError):
|
||||
def __init__(self, code, detail=None):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class FakeCua:
|
||||
CuaTransportError = CuaTransportError
|
||||
|
||||
def __init__(self, *, with_base=False):
|
||||
self.calls = []
|
||||
self.context_calls = []
|
||||
self.context = {"before_id": 101, "width": 1920, "height": 1080}
|
||||
self.argv = None
|
||||
self.mutate_context_after_capture = False
|
||||
self.token = object()
|
||||
if with_base:
|
||||
self._run_v3_base = self._primitive
|
||||
|
||||
def _primitive(self, argv, timeout=15):
|
||||
self.calls.append((list(argv), timeout))
|
||||
return self.token
|
||||
|
||||
def _run(self, argv, timeout=15):
|
||||
if hasattr(self, "_run_v3_base"):
|
||||
return self._run_v3_base(argv, timeout=timeout)
|
||||
return self._primitive(argv, timeout=timeout)
|
||||
|
||||
def _action_context(self, message, *args, **kwargs):
|
||||
self.context_calls.append((message, args, kwargs))
|
||||
return self.context
|
||||
|
||||
def handle_cua_message(self, message):
|
||||
self._action_context(message)
|
||||
if self.mutate_context_after_capture:
|
||||
self.context.update(before_id=999, width=800, height=600)
|
||||
argv = self.argv
|
||||
if argv is None:
|
||||
if message.get("action") == "TYPE":
|
||||
argv = ["xdotool", "type", "--clearmodifiers", "--delay", "10", "--", message["text"]]
|
||||
else:
|
||||
argv = ["xdotool", "getactivewindow"]
|
||||
return self._run(argv, timeout=7)
|
||||
|
||||
|
||||
class NativeHostX11Tests(unittest.TestCase):
|
||||
def install(self, cua=None, sender=None, checker=None):
|
||||
cua = cua or FakeCua()
|
||||
sender = sender or Mock(return_value=None)
|
||||
checker = checker or Mock()
|
||||
state = native_host.install_cua_guard(cua, env={"DISPLAY": ":fixture"},
|
||||
checker=checker, text_sender=sender)
|
||||
return cua, sender, checker, state
|
||||
|
||||
def test_exact_type_intercept_carries_original_context_and_timeout(self):
|
||||
cua, sender, _, state = self.install()
|
||||
result = cua.handle_cua_message({"action": "TYPE", "text": "-S /run/test"})
|
||||
self.assertIsInstance(result, subprocess.CompletedProcess)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertEqual(result.stdout, "")
|
||||
self.assertEqual(result.stderr, "")
|
||||
self.assertEqual(result.args, ["xdotool", "type", "--clearmodifiers", "--delay", "10", "--", "-S /run/test"])
|
||||
self.assertEqual(cua.calls, [])
|
||||
self.assertEqual(len(cua.context_calls), 1)
|
||||
args, kwargs = sender.call_args
|
||||
self.assertEqual(args, ("-S /run/test",))
|
||||
self.assertEqual(kwargs["expected_window"], 101)
|
||||
self.assertEqual(kwargs["expected_geometry"], (1920, 1080))
|
||||
self.assertEqual(kwargs["timeout"], 7)
|
||||
self.assertTrue(callable(kwargs["before_input"]))
|
||||
self.assertFalse(state["input_started"])
|
||||
|
||||
def test_captured_context_is_a_snapshot_not_a_mutable_alias(self):
|
||||
cua = FakeCua()
|
||||
cua.mutate_context_after_capture = True
|
||||
cua, sender, _, _ = self.install(cua)
|
||||
cua.handle_cua_message({"action": "TYPE", "text": "/"})
|
||||
self.assertEqual(sender.call_args.kwargs["expected_window"], 101)
|
||||
self.assertEqual(sender.call_args.kwargs["expected_geometry"], (1920, 1080))
|
||||
|
||||
def test_intercepts_when_captured_runner_has_a_base_runner(self):
|
||||
cua, sender, _, _ = self.install(FakeCua(with_base=True))
|
||||
cua.handle_cua_message({"action": "TYPE", "text": "/"})
|
||||
sender.assert_called_once()
|
||||
self.assertEqual(cua.calls, [])
|
||||
|
||||
def test_unexpected_type_arguments_fail_closed(self):
|
||||
forms = [
|
||||
["xdotool", "type", "--delay", "10", "--", "/"],
|
||||
["xdotool", "type", "--clearmodifiers", "--delay", "0", "--", "/"],
|
||||
["xdotool", "type", "--clearmodifiers", "--delay", "10", "--", "/", "extra"],
|
||||
["xdotool", "type", "--window", "999", "--", "/"],
|
||||
["xdotool", "TYPE", "--clearmodifiers", "--delay", "10", "--", "/"],
|
||||
]
|
||||
for argv in forms:
|
||||
with self.subTest(argv=argv):
|
||||
cua = FakeCua()
|
||||
cua.argv = argv
|
||||
cua, sender, _, state = self.install(cua)
|
||||
with self.assertRaises(CuaTransportError):
|
||||
cua.handle_cua_message({"action": "TYPE", "text": "/"})
|
||||
self.assertEqual(cua.calls, [])
|
||||
sender.assert_not_called()
|
||||
self.assertFalse(state["input_started"])
|
||||
|
||||
def test_missing_original_context_rejects_before_sender_or_runner(self):
|
||||
cua = FakeCua()
|
||||
cua.context = {}
|
||||
cua, sender, _, state = self.install(cua)
|
||||
with self.assertRaises(CuaTransportError):
|
||||
cua.handle_cua_message({"action": "TYPE", "text": "/"})
|
||||
sender.assert_not_called()
|
||||
self.assertEqual(cua.calls, [])
|
||||
self.assertFalse(state["input_started"])
|
||||
|
||||
def test_non_type_query_passes_through_and_stays_read_only(self):
|
||||
cua, sender, _, state = self.install()
|
||||
result = cua.handle_cua_message({"action": "QUERY"})
|
||||
self.assertIs(result, cua.token)
|
||||
self.assertEqual(cua.calls, [(["xdotool", "getactivewindow"], 7)])
|
||||
sender.assert_not_called()
|
||||
self.assertFalse(state["input_started"])
|
||||
|
||||
def test_non_type_query_with_invalid_action_context_still_passes_through(self):
|
||||
cua = FakeCua()
|
||||
cua.context = {"unrelated_query_context": True}
|
||||
cua, sender, _, state = self.install(cua)
|
||||
self.assertIs(cua.handle_cua_message({"action": "QUERY"}), cua.token)
|
||||
self.assertEqual(cua.calls, [(["xdotool", "getactivewindow"], 7)])
|
||||
sender.assert_not_called()
|
||||
self.assertFalse(state["input_started"])
|
||||
|
||||
def test_search_literal_type_is_a_read_only_query(self):
|
||||
cua = FakeCua()
|
||||
cua.context = {"query_context": True}
|
||||
cua.argv = ["xdotool", "search", "--name", "type"]
|
||||
cua, sender, _, state = self.install(cua)
|
||||
self.assertIs(cua.handle_cua_message({"action": "QUERY"}), cua.token)
|
||||
self.assertEqual(cua.calls, [(["xdotool", "search", "--name", "type"], 7)])
|
||||
sender.assert_not_called()
|
||||
self.assertFalse(state["input_started"])
|
||||
|
||||
def test_clipboard_context_passes_through_without_clipboard_access(self):
|
||||
cua = FakeCua()
|
||||
cua.context = {"clipboard_context_is_not_type_keys_context": True}
|
||||
cua.argv = ["xdotool", "key", "--clearmodifiers", "ctrl+v"]
|
||||
cua, sender, _, state = self.install(cua)
|
||||
result = cua.handle_cua_message({"action": "TYPE", "type_mode": "CLIPBOARD_EXACT", "text": "fixture"})
|
||||
self.assertIs(result, cua.token)
|
||||
self.assertEqual(cua.calls, [(["xdotool", "key", "--clearmodifiers", "ctrl+v"], 7)])
|
||||
sender.assert_not_called()
|
||||
self.assertTrue(state["input_started"])
|
||||
|
||||
def test_actual_frozen_type_consumes_completed_process_contract(self):
|
||||
candidates = [Path(__file__).with_name("frozen_type_fixture.py")]
|
||||
for parent in [ROOT, *ROOT.parents]:
|
||||
candidates.append(parent / "sources/original-asus/framebridge-native/host/framebridge_cua_linux.py")
|
||||
source = next((path for path in candidates if path.is_file()), None)
|
||||
if source is None:
|
||||
self.skipTest("captured original _type source is not available in this checkout")
|
||||
tree = ast.parse(source.read_text())
|
||||
definitions = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "_type"]
|
||||
self.assertTrue(definitions, str(source))
|
||||
cua = FakeCua()
|
||||
namespace = {"CuaTransportError": CuaTransportError,
|
||||
"_run": lambda argv, timeout: cua._run(argv, timeout=timeout),
|
||||
"_action_context": lambda message: cua._action_context(message),
|
||||
"_evidence_lines": lambda context: []}
|
||||
exec(compile(ast.Module(body=[definitions[-1]], type_ignores=[]), str(source), "exec"), namespace)
|
||||
cua.handle_cua_message = namespace["_type"]
|
||||
cua, sender, _, state = self.install(cua)
|
||||
result = cua.handle_cua_message({"action": "TYPE", "text": "-S /run/fixture"})
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["action"], "TYPE")
|
||||
self.assertIn("RESULTADO_FINAL=CUA_TYPE_PASS", result["_frame_output"])
|
||||
self.assertEqual(sender.call_args.kwargs["timeout"], 20)
|
||||
self.assertEqual(cua.calls, [])
|
||||
|
||||
def test_non_type_input_keeps_existing_input_tracking(self):
|
||||
cua = FakeCua()
|
||||
cua.argv = ["xdotool", "key", "Return"]
|
||||
cua, sender, _, state = self.install(cua)
|
||||
self.assertIs(cua.handle_cua_message({"action": "KEY"}), cua.token)
|
||||
self.assertEqual(cua.calls, [(["xdotool", "key", "Return"], 7)])
|
||||
sender.assert_not_called()
|
||||
self.assertTrue(state["input_started"])
|
||||
|
||||
def test_sender_preflight_failure_reports_no_input(self):
|
||||
# Use the same error class the native sender exports, without loading
|
||||
# or instantiating its native backend.
|
||||
sender = Mock(side_effect=text_module.TextError("TYPE_TEXT_UNREPRESENTABLE_IN_CURRENT_GROUP"))
|
||||
cua, _, _, state = self.install(sender=sender)
|
||||
with self.assertRaises(CuaTransportError):
|
||||
cua.handle_cua_message({"action": "TYPE", "text": "\u2603"})
|
||||
self.assertFalse(state["input_started"])
|
||||
self.assertEqual(cua.calls, [])
|
||||
response = native_host.decorate_response({"executed": False}, state, {})
|
||||
self.assertFalse(response["input_may_have_been_sent"])
|
||||
self.assertFalse(response["do_not_replay"])
|
||||
self.assertFalse(response["performed"])
|
||||
|
||||
def test_input_started_only_when_sender_calls_guard(self):
|
||||
observed = []
|
||||
holder = {}
|
||||
|
||||
def sender(text, **kwargs):
|
||||
observed.append(holder["state"]["input_started"])
|
||||
kwargs["before_input"]()
|
||||
observed.append(holder["state"]["input_started"])
|
||||
|
||||
cua, _, _, state = self.install(sender=sender)
|
||||
holder["state"] = state
|
||||
cua.handle_cua_message({"action": "TYPE", "text": "a"})
|
||||
self.assertEqual(observed, [False, True])
|
||||
self.assertEqual(cua.calls, [])
|
||||
|
||||
def test_authority_failure_inside_sender_guard_sends_no_input(self):
|
||||
gate = {"sender_active": False}
|
||||
|
||||
def checker(env):
|
||||
if gate["sender_active"]:
|
||||
raise DisplayContextError("IDENTITY_CHANGED")
|
||||
|
||||
def sender(text, **kwargs):
|
||||
gate["sender_active"] = True
|
||||
kwargs["before_input"]()
|
||||
cua, _, _, state = self.install(sender=sender, checker=checker)
|
||||
with self.assertRaises(CuaTransportError):
|
||||
cua.handle_cua_message({"action": "TYPE", "text": "/"})
|
||||
self.assertFalse(state["input_started"])
|
||||
self.assertEqual(cua.calls, [])
|
||||
|
||||
def test_post_input_failure_has_do_not_replay_and_no_performed_false(self):
|
||||
# The injected sender uses the module instance imported by native_host.
|
||||
def sender(text, **kwargs):
|
||||
kwargs["before_input"]()
|
||||
raise text_module.TextError("TYPE_KEYMAP_CHANGED")
|
||||
|
||||
cua, _, _, state = self.install(sender=sender)
|
||||
with self.assertRaises(CuaTransportError):
|
||||
cua.handle_cua_message({"action": "TYPE", "text": "/"})
|
||||
response = native_host.decorate_response({"executed": False}, state, {})
|
||||
self.assertTrue(response["input_may_have_been_sent"])
|
||||
self.assertTrue(response["do_not_replay"])
|
||||
self.assertNotIn("performed", response)
|
||||
self.assertEqual(cua.calls, [])
|
||||
|
||||
def test_successful_callback_marks_performed_in_existing_response(self):
|
||||
sender = Mock(side_effect=lambda text, **kwargs: kwargs["before_input"]())
|
||||
cua, _, _, state = self.install(sender=sender)
|
||||
cua.handle_cua_message({"action": "TYPE", "text": "/"})
|
||||
response = native_host.decorate_response({"executed": True}, state, {})
|
||||
self.assertTrue(response["performed"])
|
||||
self.assertTrue(response["input_may_have_been_sent"])
|
||||
self.assertFalse(response["do_not_replay"])
|
||||
|
||||
def test_new_message_does_not_reuse_previous_action_context(self):
|
||||
cua, sender, _, state = self.install()
|
||||
cua.handle_cua_message({"action": "TYPE", "text": "first"})
|
||||
native_host.decorate_response({"executed": True}, state, {})
|
||||
cua.context = {}
|
||||
with self.assertRaises(CuaTransportError):
|
||||
cua.handle_cua_message({"action": "TYPE", "text": "second"})
|
||||
self.assertEqual(sender.call_count, 1)
|
||||
self.assertEqual(cua.calls, [])
|
||||
|
||||
def test_native_snapshot_rejects_held_shift_before_any_input(self):
|
||||
backend = object.__new__(text_module.NativeBackend)
|
||||
backend.display = 1
|
||||
backend.context = 1
|
||||
backend.modifier_codes = {50}
|
||||
|
||||
def keymap(display_id, buffer):
|
||||
data = bytearray(32)
|
||||
data[50 // 8] = 1 << (50 % 8)
|
||||
C.memmove(buffer, bytes(data), 32)
|
||||
return 1
|
||||
|
||||
def active_window(context, result):
|
||||
result._obj.value = 101
|
||||
return 0
|
||||
|
||||
backend.x11 = SimpleNamespace(XkbGetState=Mock(return_value=0),
|
||||
XQueryKeymap=keymap, XDefaultScreen=Mock())
|
||||
backend.xdo = SimpleNamespace(xdo_get_active_window=active_window)
|
||||
backend.xtst = SimpleNamespace(XTestFakeKeyEvent=Mock())
|
||||
with self.assertRaisesRegex(text_module.TextError, "^TYPE_HELD_MODIFIERS_UNSUPPORTED$"):
|
||||
backend.snapshot()
|
||||
backend.xtst.XTestFakeKeyEvent.assert_not_called()
|
||||
backend.x11.XDefaultScreen.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Socket fault injection against the real FBPAIR broker and enrolled identities."""
|
||||
import errno
|
||||
import hashlib
|
||||
import selectors
|
||||
import shlex
|
||||
import signal
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import urllib.error
|
||||
from unittest import mock
|
||||
|
||||
from framebridge_expanded import broker, node
|
||||
import test_fleet_broker_node as fixtures
|
||||
|
||||
|
||||
class StopLoop(BaseException):
|
||||
pass
|
||||
|
||||
|
||||
class NodeRecoveryTests(unittest.TestCase):
|
||||
setUp = fixtures.BrokerNodeTests.setUp
|
||||
tearDown = fixtures.BrokerNodeTests.tearDown
|
||||
make_node = fixtures.BrokerNodeTests.make_node
|
||||
pair = fixtures.BrokerNodeTests.pair
|
||||
|
||||
@staticmethod
|
||||
def disconnect(handler):
|
||||
handler.close_connection = True
|
||||
handler.connection.shutdown(socket.SHUT_RDWR)
|
||||
handler.connection.close()
|
||||
|
||||
def test_transient_poll_disconnects_backoff_cap_and_inventory_recovery(self):
|
||||
state, ident = self.pair("recover-node")
|
||||
node.run(state, once=True)
|
||||
store = self.server.expanded_store
|
||||
store.node_last_seen[ident] = time.time() - 10
|
||||
self.assertFalse(broker.list_confirmed_nodes()[0]["ready"])
|
||||
identity_before = (state / "node/identity.json").read_bytes()
|
||||
original = self.server.RequestHandlerClass.do_POST
|
||||
real_post = broker.post_json
|
||||
attempts, waits, envelopes = [], [], []
|
||||
recovered = []
|
||||
|
||||
def fault(handler):
|
||||
if handler.path.endswith("/node/poll"):
|
||||
attempts.append(1)
|
||||
if len(attempts) <= 8:
|
||||
self.disconnect(handler)
|
||||
return
|
||||
return original(handler)
|
||||
|
||||
def post(url, envelope, **kwargs):
|
||||
if url.endswith("/node/poll"):
|
||||
envelopes.append(envelope)
|
||||
result = real_post(url, envelope, **kwargs)
|
||||
if url.endswith("/node/poll"):
|
||||
recovered.append(True)
|
||||
return result
|
||||
|
||||
def pause(seconds):
|
||||
if recovered:
|
||||
raise StopLoop
|
||||
self.assertFalse(store.node_runtime_status(ident)["ready"])
|
||||
waits.append(seconds)
|
||||
|
||||
with mock.patch.object(self.server.RequestHandlerClass, "do_POST", fault), \
|
||||
mock.patch.object(broker, "post_json", side_effect=post), \
|
||||
mock.patch.object(node, "time", wraps=time) as clock, \
|
||||
self.assertLogs(node._LOG, level="WARNING") as logs:
|
||||
clock.sleep.side_effect = pause
|
||||
with self.assertRaises(StopLoop):
|
||||
node.run(state)
|
||||
self.assertEqual(waits, [1, 2, 4, 8, 16, 30, 30, 30])
|
||||
self.assertEqual(len(attempts), 9)
|
||||
self.assertEqual(len({e["nonce"] for e in envelopes}), 9)
|
||||
self.assertTrue(store.node_runtime_status(ident)["ready"])
|
||||
self.assertEqual((state / "node/identity.json").read_bytes(), identity_before)
|
||||
self.assertTrue(any("NODE_BROKER_RECOVERED" in line for line in logs.output))
|
||||
self.assertTrue(all("signature" not in line and "payload" not in line for line in logs.output))
|
||||
|
||||
def test_result_disconnect_before_and_after_acceptance_never_reexecutes(self):
|
||||
state, ident = self.pair("ack-node")
|
||||
marker = self.root / "executed.txt"
|
||||
real_post = broker.post_json
|
||||
original_post = self.server.RequestHandlerClass.do_POST
|
||||
original_send = self.server.RequestHandlerClass.send_json
|
||||
for after_acceptance in (False, True):
|
||||
with self.subTest(after_acceptance=after_acceptance):
|
||||
marker.write_text("")
|
||||
script = "printf x >> " + shlex.quote(str(marker))
|
||||
payload = {"authorized": True, "job_id": "recovery-command", "timeout_s": 10,
|
||||
"script": script, "sha256": hashlib.sha256(script.encode()).hexdigest()}
|
||||
queued, error = self.server.expanded_store.queue_admin(ident, "admin.exec", payload)
|
||||
self.assertIsNone(error)
|
||||
posted, dispatched, waits, dropped, delivered = [], [], [], [], []
|
||||
|
||||
def execute(value):
|
||||
dispatched.append(value["job_id"])
|
||||
subprocess.run(["/bin/sh", "-c", value["script"]], check=True)
|
||||
return {"ok": True, "exit_code": "0"}
|
||||
|
||||
def fault_post(handler):
|
||||
if not after_acceptance and handler.path.endswith("/node/result") and not dropped:
|
||||
dropped.append(True)
|
||||
self.disconnect(handler)
|
||||
return
|
||||
return original_post(handler)
|
||||
|
||||
def fault_send(handler, code, value):
|
||||
if after_acceptance and handler.path.endswith("/node/result") and not dropped:
|
||||
dropped.append(True)
|
||||
self.disconnect(handler)
|
||||
return
|
||||
return original_send(handler, code, value)
|
||||
|
||||
def post(url, envelope, **kwargs):
|
||||
if url.endswith("/node/result"):
|
||||
posted.append(envelope)
|
||||
result = real_post(url, envelope, **kwargs)
|
||||
if url.endswith("/node/result"):
|
||||
delivered.append(True)
|
||||
return result
|
||||
|
||||
def pause(seconds):
|
||||
if delivered:
|
||||
raise StopLoop
|
||||
waits.append(seconds)
|
||||
|
||||
with mock.patch.object(self.server.RequestHandlerClass, "do_POST", fault_post), \
|
||||
mock.patch.object(self.server.RequestHandlerClass, "send_json", fault_send), \
|
||||
mock.patch.object(broker, "post_json", side_effect=post), \
|
||||
mock.patch("framebridge_expanded.runtime.adapter_request", side_effect=execute), \
|
||||
mock.patch.object(node, "time", wraps=time) as clock, \
|
||||
self.assertLogs(node._LOG, level="WARNING"):
|
||||
clock.sleep.side_effect = pause
|
||||
with self.assertRaises(StopLoop):
|
||||
node.run(state)
|
||||
self.assertEqual(marker.read_text(), "x")
|
||||
self.assertEqual(dispatched, ["recovery-command"])
|
||||
self.assertEqual(waits, [1])
|
||||
self.assertEqual(len(posted), 2)
|
||||
self.assertNotEqual(posted[0]["nonce"], posted[1]["nonce"])
|
||||
self.assertEqual(posted[0]["payload"], posted[1]["payload"])
|
||||
saved = self.server.expanded_store.get_result(queued["request_id"])
|
||||
self.assertEqual(saved["state"], "complete")
|
||||
self.assertTrue(saved["result"]["ok"])
|
||||
|
||||
def test_broker_restart_after_execution_is_terminal_without_replay(self):
|
||||
state, ident = self.pair("orphan-node")
|
||||
marker = self.root / "orphan-effect.txt"
|
||||
script = "printf x >> " + shlex.quote(str(marker))
|
||||
payload = {"authorized": True, "job_id": "orphan-command", "timeout_s": 10,
|
||||
"script": script, "sha256": hashlib.sha256(script.encode()).hexdigest()}
|
||||
queued, error = self.server.expanded_store.queue_admin(ident, "admin.exec", payload)
|
||||
self.assertIsNone(error)
|
||||
real_post = broker.post_json
|
||||
result_attempts = []
|
||||
|
||||
def execute(value):
|
||||
subprocess.run(["/bin/sh", "-c", value["script"]], check=True)
|
||||
return {"ok": True, "exit_code": "0"}
|
||||
|
||||
def post(url, envelope, **kwargs):
|
||||
if url.endswith("/node/result"):
|
||||
result_attempts.append(1)
|
||||
self.assertEqual(marker.read_text(), "x")
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(2)
|
||||
self.server = broker.make_server(port=self.port, state_dir=self.central)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.assertIsNone(self.server.expanded_store.get_result(queued["request_id"]))
|
||||
return real_post(url, envelope, **kwargs)
|
||||
|
||||
with mock.patch.object(broker, "post_json", side_effect=post), \
|
||||
mock.patch("framebridge_expanded.runtime.adapter_request", side_effect=execute) as adapter, \
|
||||
mock.patch.object(node, "time", wraps=time) as clock:
|
||||
with self.assertRaisesRegex(broker.BrokerError, "^BROKER_HTTP_REJECTED_400$"):
|
||||
node.run(state)
|
||||
adapter.assert_called_once()
|
||||
clock.sleep.assert_not_called()
|
||||
self.assertEqual(result_attempts, [1])
|
||||
self.assertEqual(marker.read_text(), "x")
|
||||
# A later operator restart may poll, but must never reconstruct/replay
|
||||
# the orphaned command from its lost acknowledgement.
|
||||
self.assertTrue(node.run(state, once=True)["idle"])
|
||||
self.assertEqual(marker.read_text(), "x")
|
||||
|
||||
def test_gateway_failures_recover_without_replaying_signed_envelope(self):
|
||||
state, _ = self.pair("gateway-node")
|
||||
original = self.server.RequestHandlerClass.do_POST
|
||||
codes, waits, successes = [502, 503, 504], [], []
|
||||
|
||||
def gateway(handler):
|
||||
if codes:
|
||||
handler.send_json(codes.pop(0), {"ok": False})
|
||||
return
|
||||
successes.append(True)
|
||||
return original(handler)
|
||||
|
||||
def pause(seconds):
|
||||
if successes:
|
||||
raise StopLoop
|
||||
waits.append(seconds)
|
||||
|
||||
with mock.patch.object(self.server.RequestHandlerClass, "do_POST", gateway), \
|
||||
mock.patch.object(node, "time", wraps=time) as clock, \
|
||||
self.assertLogs(node._LOG, level="WARNING"):
|
||||
clock.sleep.side_effect = pause
|
||||
with self.assertRaises(StopLoop):
|
||||
node.run(state)
|
||||
self.assertEqual(waits, [1, 2, 4])
|
||||
|
||||
def test_auth_and_invalid_json_fail_without_retry(self):
|
||||
state, _ = self.pair("reject-node")
|
||||
for code, body in [(403, b'{"ok":false}'), (500, b'{"ok":false}'),
|
||||
(200, b'not-json'), (200, b'{"ok":false}')]:
|
||||
with self.subTest(code=code, body=body):
|
||||
attempts = []
|
||||
|
||||
def reject(handler):
|
||||
attempts.append(1)
|
||||
handler.send_response(code)
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
with mock.patch.object(self.server.RequestHandlerClass, "do_POST", reject), \
|
||||
mock.patch.object(node, "time", wraps=time) as clock:
|
||||
with self.assertRaises((broker.BrokerError, node.NodeError)):
|
||||
node.run(state)
|
||||
clock.sleep.assert_not_called()
|
||||
self.assertEqual(len(attempts), 1)
|
||||
|
||||
def test_tls_and_unknown_transport_causes_are_terminal(self):
|
||||
state, _ = self.pair("tls-node")
|
||||
for cause in [urllib.error.URLError(ssl.SSLCertVerificationError("untrusted test CA")),
|
||||
urllib.error.URLError(socket.gaierror(socket.EAI_NONAME, "invalid host")),
|
||||
OSError(errno.EACCES, "denied"), ValueError("invalid response")]:
|
||||
with self.subTest(cause=type(cause).__name__), \
|
||||
mock.patch("urllib.request.OpenerDirector.open", side_effect=cause) as opened, \
|
||||
mock.patch.object(node, "time", wraps=time) as clock:
|
||||
with self.assertRaises(broker.BrokerError):
|
||||
node.run(state)
|
||||
self.assertEqual(opened.call_count, 1)
|
||||
clock.sleep.assert_not_called()
|
||||
|
||||
def test_once_is_fail_fast_and_retry_wait_propagates_cancellation(self):
|
||||
state, _ = self.pair("cancel-node")
|
||||
attempts = []
|
||||
|
||||
def fault(handler):
|
||||
attempts.append(1)
|
||||
self.disconnect(handler)
|
||||
|
||||
with mock.patch.object(self.server.RequestHandlerClass, "do_POST", fault), \
|
||||
mock.patch.object(node, "time", wraps=time) as clock:
|
||||
with self.assertRaises(broker.BrokerError):
|
||||
node.run(state, once=True)
|
||||
clock.sleep.assert_not_called()
|
||||
clock.sleep.side_effect = KeyboardInterrupt
|
||||
with self.assertLogs(node._LOG, level="WARNING"), self.assertRaises(KeyboardInterrupt):
|
||||
node.run(state)
|
||||
self.assertEqual(len(attempts), 2)
|
||||
clock.sleep.assert_called_once_with(1)
|
||||
|
||||
def test_real_broker_restart_recovers_same_node_process_and_identity(self):
|
||||
state, ident = self.pair("restart-node")
|
||||
identity_before = (state / "node/identity.json").read_bytes()
|
||||
code = "from framebridge_expanded.node import run; run(state_dir=" + repr(str(state)) + ")"
|
||||
child = subprocess.Popen([sys.executable, "-u", "-c", code],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
||||
|
||||
def wait_ready():
|
||||
deadline = time.monotonic() + 8
|
||||
while time.monotonic() < deadline:
|
||||
self.assertIsNone(child.poll(), "node exited during recovery")
|
||||
if self.server.expanded_store.node_runtime_status(ident)["ready"]:
|
||||
return
|
||||
threading.Event().wait(0.02)
|
||||
self.fail("node heartbeat did not recover")
|
||||
|
||||
try:
|
||||
wait_ready()
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(2)
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(child.stderr, selectors.EVENT_READ)
|
||||
self.assertTrue(selector.select(10), "child did not report broker interruption")
|
||||
self.assertIn("NODE_BROKER_RETRY", child.stderr.readline())
|
||||
self.assertIsNone(child.poll())
|
||||
self.server = broker.make_server(port=self.port, state_dir=self.central)
|
||||
self.assertFalse(self.server.expanded_store.node_runtime_status(ident)["ready"])
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
wait_ready()
|
||||
self.assertEqual((state / "node/identity.json").read_bytes(), identity_before)
|
||||
self.assertEqual(broker.list_confirmed_nodes()[0]["node_id"], ident)
|
||||
finally:
|
||||
child.terminate()
|
||||
try:
|
||||
child.wait(timeout=3)
|
||||
finally:
|
||||
if child.poll() is None:
|
||||
child.kill()
|
||||
child.wait(timeout=3)
|
||||
child.stderr.close()
|
||||
|
||||
def test_sigterm_stops_real_process_during_retry_wait(self):
|
||||
state, _ = self.pair("stop-node")
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(2)
|
||||
code = "from framebridge_expanded.node import run; run(state_dir=" + repr(str(state)) + ")"
|
||||
child = subprocess.Popen([sys.executable, "-u", "-c", code],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
||||
try:
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(child.stderr, selectors.EVENT_READ)
|
||||
self.assertTrue(selector.select(10), "child did not report retry")
|
||||
self.assertIn("NODE_BROKER_RETRY", child.stderr.readline())
|
||||
started = time.monotonic()
|
||||
child.terminate()
|
||||
self.assertEqual(child.wait(timeout=3), -signal.SIGTERM)
|
||||
self.assertLess(time.monotonic() - started, 3)
|
||||
finally:
|
||||
if child.poll() is None:
|
||||
child.kill()
|
||||
child.wait(timeout=3)
|
||||
child.stderr.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Optional private desktops never change normal installation/readiness."""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'packaging'))
|
||||
from build_expanded import package_relationships
|
||||
from build_profiles import PROFILES, target_profile
|
||||
from framebridge_expanded import cli, config, display_context as dc, runtime
|
||||
|
||||
|
||||
class DesktopPackageRelationships(unittest.TestCase):
|
||||
def test_no_automatic_virtual_desktop_in_any_target(self):
|
||||
for target in PROFILES:
|
||||
with self.subTest(target=target):
|
||||
fields = package_relationships(target_profile(target))
|
||||
def packages(value):
|
||||
return {re.match(r'[a-z0-9+.-]+', item.strip()).group()
|
||||
for item in re.split(r'[,|]', value) if item.strip()}
|
||||
required = packages(fields['Depends'])
|
||||
recommended = packages(fields.get('Recommends', ''))
|
||||
self.assertTrue({'openbox', 'xvfb'}.isdisjoint(required | recommended))
|
||||
self.assertEqual({'openbox', 'xvfb'}, set(fields['Suggests'].split(', ')))
|
||||
self.assertIn('xauth', required)
|
||||
self.assertIn('tmux', required)
|
||||
|
||||
|
||||
class OptionalPrivateDesktop(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix='fb-optional-desktop-')
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.state = Path(self.temporary.name)
|
||||
config.save_config(self.state, config.default_config())
|
||||
self.env = {'PATH': '/fixture/bin'}
|
||||
dc.bind_text(self.env, self.state)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def prerequisites(self, missing, physical=False):
|
||||
def which(name, path=None):
|
||||
return None if name in missing else '/fixture/bin/' + name
|
||||
compatible = {'ok': True, 'profile_id': 'debian13-amd64-py313',
|
||||
'issues': [], 'warnings': []}
|
||||
context = {'kind': 'physical-x11', 'physical': True} if physical else {'kind': 'text', 'physical': False}
|
||||
with patch.object(runtime, 'environment', return_value=dict(self.env)), \
|
||||
patch.object(runtime.shutil, 'which', side_effect=which), \
|
||||
patch.object(runtime, '_display_ok', return_value=physical), \
|
||||
patch.object(dc, 'describe', return_value=context), \
|
||||
patch('framebridge_expanded.network.inspect', return_value={}), \
|
||||
patch('framebridge_expanded.compatibility.assess', return_value=compatible):
|
||||
yield
|
||||
|
||||
def mode(self, value):
|
||||
settings = config.load_config(self.state)
|
||||
settings['headless'] = value
|
||||
config.save_config(self.state, settings)
|
||||
|
||||
def test_normal_text_mode_ready_without_optional_tools(self):
|
||||
with self.prerequisites({'openbox', 'Xvfb'}):
|
||||
result = runtime.doctor(self.state)
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertEqual([], result['missing_commands'])
|
||||
self.assertTrue(result['capabilities']['fbexec'])
|
||||
self.assertTrue(result['capabilities']['fbterm'])
|
||||
self.assertFalse(result['capabilities']['private_desktop'])
|
||||
self.assertEqual(['Xvfb', 'openbox'], result['optional_missing_commands']['private_desktop'])
|
||||
self.assertEqual(dc.VIRTUAL_INSTALL_COMMAND, result['optional_install_commands']['private_desktop'])
|
||||
|
||||
def test_existing_physical_x11_does_not_require_virtual_tools(self):
|
||||
self.mode('no')
|
||||
with self.prerequisites({'openbox', 'Xvfb'}, physical=True):
|
||||
result = runtime.doctor(self.state)
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertTrue(result['capabilities']['graphical_cua'])
|
||||
self.assertFalse(result['capabilities']['private_desktop'])
|
||||
self.assertEqual([], result['missing_commands'])
|
||||
|
||||
def test_explicit_virtual_start_fails_before_spawning_with_install_hint(self):
|
||||
self.mode('yes')
|
||||
with self.prerequisites({'openbox'}), \
|
||||
patch.object(runtime, 'status', return_value={'running': False}), \
|
||||
patch.object(runtime, '_spawn') as spawn:
|
||||
result = runtime.start(self.state)
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('openbox', result['doctor']['missing_commands'])
|
||||
self.assertIn(dc.VIRTUAL_INSTALL_COMMAND, result['error'])
|
||||
spawn.assert_not_called()
|
||||
|
||||
def test_manager_rechecks_missing_tool_before_creating_any_child(self):
|
||||
self.mode('yes')
|
||||
with self.prerequisites({'openbox'}), \
|
||||
patch.object(runtime.signal, 'signal'), \
|
||||
patch.object(runtime, '_spawn') as spawn:
|
||||
with self.assertRaisesRegex(RuntimeError, 'Private virtual desktop tools are missing: openbox'):
|
||||
runtime._manager(self.state)
|
||||
spawn.assert_not_called()
|
||||
error = json.loads((self.state/'last-error.json').read_text())['error']
|
||||
self.assertIn(dc.VIRTUAL_INSTALL_COMMAND, error)
|
||||
|
||||
def test_virtual_tools_available_with_no_optional_install_hint(self):
|
||||
with self.prerequisites(set()):
|
||||
result = runtime.doctor(self.state)
|
||||
self.assertTrue(result['capabilities']['private_desktop'])
|
||||
self.assertIsNone(result['optional_install_commands']['private_desktop'])
|
||||
|
||||
def test_human_doctor_explains_optional_install_without_failing(self):
|
||||
fake_runtime = SimpleNamespace(doctor=Mock(return_value={
|
||||
'ok': True, 'display': 'text tools', 'missing_commands': [], 'issues': [],
|
||||
'platform_compatibility': {'ok': True},
|
||||
'optional_missing_commands': {'private_desktop': ['openbox']},
|
||||
'optional_install_commands': {'private_desktop': dc.VIRTUAL_INSTALL_COMMAND}}))
|
||||
stdout = io.StringIO()
|
||||
with patch.object(cli, '_module', return_value=fake_runtime), \
|
||||
contextlib.redirect_stdout(stdout):
|
||||
code = cli.main(['--state-dir', str(self.state), 'doctor'])
|
||||
self.assertEqual(0, code)
|
||||
self.assertIn('Optional private desktop tools missing: openbox', stdout.getvalue())
|
||||
self.assertIn(dc.VIRTUAL_INSTALL_COMMAND, stdout.getvalue())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Execute maintscript branches with cleanup commands intercepted in a tempdir."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "packaging"))
|
||||
from build_expanded import maintainer_prerm, PRIVILEGED_HELPER_FLOOR
|
||||
from build_profiles import CANDIDATE_VERSION
|
||||
|
||||
|
||||
class MaintainerScriptTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
self.trace = self.root / "calls.jsonl"
|
||||
self.stub = self.root / "cleanup-stub"
|
||||
self.stub.write_text(
|
||||
"#!" + sys.executable + "\n"
|
||||
"import json,os,sys\n"
|
||||
"with open(os.environ['TEST_CALLS'], 'a') as stream: stream.write(json.dumps(sys.argv[1:])+'\\n')\n"
|
||||
"raise SystemExit(int(os.environ.get('TEST_CLEANUP_RC','0')))\n"
|
||||
)
|
||||
self.stub.chmod(0o755)
|
||||
self.screen = self.root / "screen-access.json"
|
||||
|
||||
def run_script(self, args, *, fail=0, screen=False):
|
||||
script = maintainer_prerm(PRIVILEGED_HELPER_FLOOR)
|
||||
# Only side-effecting commands and config probes are redirected. The
|
||||
# real dpkg comparisons are read-only and verify Debian version ordering.
|
||||
script = script.replace("/usr/bin/python3", shlex.quote(str(self.stub)))
|
||||
script = script.replace("/usr/lib/framebridge-expanded/bin/framebridge-screen-access", shlex.quote(str(self.stub)))
|
||||
script = script.replace("/etc/framebridge-expanded/screen-access.json", shlex.quote(str(self.screen)))
|
||||
if screen:
|
||||
self.screen.write_text("{}")
|
||||
env = dict(os.environ, TEST_CALLS=str(self.trace), TEST_CLEANUP_RC=str(fail))
|
||||
result = subprocess.run(["/bin/sh", "-s", "--", *args], input=script, text=True,
|
||||
capture_output=True, env=env, timeout=10)
|
||||
calls = [json.loads(row) for row in self.trace.read_text().splitlines()] if self.trace.exists() else []
|
||||
return result, calls
|
||||
|
||||
def test_downgrade_revokes_only_helper_before_replacement(self):
|
||||
result, calls = self.run_script(["upgrade", "0.3.1"])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(calls[0][:3], ["-I", "-S", "-c"])
|
||||
self.assertEqual(calls[0][3], 'import runpy; runpy.run_path("/usr/lib/framebridge-expanded/expanded/framebridge_expanded/uninstall.py")["purge_credential_helpers"]()')
|
||||
self.assertNotIn("--all-users", calls[0])
|
||||
|
||||
def test_forward_upgrade_retains_helper_state(self):
|
||||
result, calls = self.run_script(["upgrade", "0.3.2"])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_same_version_reinstall_retains_helper_state(self):
|
||||
result, calls = self.run_script(["upgrade", CANDIDATE_VERSION])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_remove_and_deconfigure_keep_existing_cleanup(self):
|
||||
for operation in ("remove", "deconfigure"):
|
||||
if self.trace.exists():
|
||||
self.trace.unlink()
|
||||
result, calls = self.run_script([operation], screen=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(calls, [["-I", "-S", "/usr/lib/framebridge-expanded/expanded/framebridge_expanded/uninstall.py", "--all-users"], ["disable"]])
|
||||
|
||||
def test_failed_helper_cleanup_blocks_downgrade(self):
|
||||
result, calls = self.run_script(["upgrade", "0.3.1"], fail=17)
|
||||
self.assertEqual(result.returncode, 17)
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_invalid_target_does_not_execute_or_interpolate(self):
|
||||
marker = self.root / "must-not-exist"
|
||||
result, calls = self.run_script(["upgrade", "0.3.1;$(touch " + str(marker) + ")"])
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_missing_target_fails_closed(self):
|
||||
result, calls = self.run_script(["upgrade"])
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_current_version_cannot_inject_script_paths(self):
|
||||
with self.assertRaises(ValueError):
|
||||
maintainer_prerm("0.3.2'; /tmp/untrusted-script; '")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,339 @@
|
||||
import hashlib
|
||||
from contextlib import nullcontext
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from framebridge_expanded import package_upgrade as upgrade
|
||||
|
||||
|
||||
class PackageUpgradeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.original_read = upgrade._read
|
||||
self.state = self.root / 'profile'
|
||||
self.state.mkdir(mode=0o700)
|
||||
self.config = {'role': 'node', 'node_id': 'test-node'}
|
||||
self.write(self.state / 'config.json', self.config)
|
||||
self.proc = self.root / '1234'
|
||||
self.proc.mkdir()
|
||||
self.identity = {'pid': 1234, 'birth': '99', 'uid': os.getuid()}
|
||||
self.write(self.state / 'manager.json', self.identity)
|
||||
self.args = ['/usr/bin/python3', '-m', 'framebridge_expanded.runtime',
|
||||
'_manager', '--state', str(self.state)]
|
||||
(self.proc / 'cmdline').write_bytes(b'\0'.join(x.encode() for x in self.args) + b'\0')
|
||||
self.environ()
|
||||
(self.proc / 'stat').write_text('1234 (python3) S ' + ' '.join(['0'] * 18 + ['99']))
|
||||
(self.proc / 'cgroup').write_text('0::/user.slice/user-1000.slice/user@1000.service/app.slice/test.scope\n')
|
||||
|
||||
def write(self, path, data):
|
||||
path.write_text(json.dumps(data))
|
||||
path.chmod(0o600)
|
||||
|
||||
def environ(self, prefix=None):
|
||||
prefix = upgrade.PREFIX if prefix is None else prefix
|
||||
(self.proc / 'environ').write_bytes(b'\0'.join(x.encode() for x in [
|
||||
'FRAMEBRIDGE_EXPANDED_PREFIX=' + prefix,
|
||||
'FRAMEBRIDGE_EXPANDED_STATE=' + str(self.state),
|
||||
'PYTHONPATH=' + prefix + '/expanded:' + prefix + '/portable']) + b'\0')
|
||||
|
||||
def identify(self):
|
||||
if os.getuid() == 0:
|
||||
self.skipTest('Identity fixtures run as the ordinary build owner, not root')
|
||||
return upgrade.identify(self.proc)
|
||||
|
||||
def test_identifies_only_installed_manager_and_exact_owned_record(self):
|
||||
row = self.identify()
|
||||
self.assertEqual(row['pid'], 1234)
|
||||
self.assertEqual(row['state'], str(self.state))
|
||||
self.assertEqual(row['birth'], '99')
|
||||
self.assertIsNone(row['unit'])
|
||||
self.assertNotIn('config', row)
|
||||
|
||||
def test_development_clone_is_not_an_installed_service(self):
|
||||
self.environ('/home/example/source')
|
||||
self.assertIsNone(self.identify())
|
||||
|
||||
def test_arbitrary_python_command_is_ignored(self):
|
||||
(self.proc / 'cmdline').write_bytes(b'/usr/bin/python3\0-c\0print(1)\0')
|
||||
self.assertIsNone(upgrade.identify(self.proc))
|
||||
|
||||
def test_pid_reuse_is_rejected(self):
|
||||
self.write(self.state / 'manager.json', dict(self.identity, birth='98'))
|
||||
if os.getuid() == 0:
|
||||
self.skipTest('Ordinary-owner fixture required')
|
||||
with self.assertRaisesRegex(RuntimeError, 'RECORD_MISMATCH'):
|
||||
upgrade.identify(self.proc)
|
||||
|
||||
def test_recognizes_qa_unit_without_selecting_unrelated_services(self):
|
||||
(self.proc / 'cgroup').write_text('0::/user.slice/user@1000.service/app.slice/framebridge-qa-mint223-private2-c3418304.service\n')
|
||||
self.assertEqual(self.identify()['unit'], 'framebridge-qa-mint223-private2-c3418304.service')
|
||||
|
||||
def test_unrelated_unit_is_not_restarted(self):
|
||||
(self.proc / 'cgroup').write_text('0::/user.slice/user@1000.service/app.slice/owner-important.service\n')
|
||||
self.assertIsNone(self.identify()['unit'])
|
||||
|
||||
def test_world_readable_metadata_is_refused(self):
|
||||
path = self.state / 'config.json'
|
||||
path.chmod(0o644)
|
||||
with self.assertRaisesRegex(RuntimeError, 'FILE_UNTRUSTED'):
|
||||
upgrade._read(path, os.getuid())
|
||||
|
||||
def test_metadata_symlink_is_refused(self):
|
||||
path = self.state / 'alias.json'
|
||||
path.symlink_to(self.state / 'config.json')
|
||||
with self.assertRaises(OSError):
|
||||
upgrade._read(path, os.getuid())
|
||||
|
||||
def test_shared_profile_directory_is_refused(self):
|
||||
self.state.chmod(0o755)
|
||||
with self.assertRaisesRegex(RuntimeError, 'DIRECTORY_UNTRUSTED'):
|
||||
upgrade._private(self.state, os.getuid())
|
||||
|
||||
def root_read(self, path, uid, maximum=upgrade.MAXIMUM):
|
||||
# Root-owned production intent is represented by the test owner's file.
|
||||
return self.original_read(path, os.getuid(), maximum)
|
||||
|
||||
def pending(self, profiles=None):
|
||||
value = {'schema': 1, 'transaction': '1' * 16, 'phase': 'CAPTURED', 'profiles': profiles or []}
|
||||
self.write(self.root / 'pending.json', value)
|
||||
return value
|
||||
|
||||
def test_fresh_install_does_not_schedule_or_start_profiles(self):
|
||||
with mock.patch.object(upgrade, 'ROOT', self.root), \
|
||||
mock.patch.object(upgrade, '_root'), \
|
||||
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
|
||||
mock.patch.object(upgrade.subprocess, 'run') as run:
|
||||
self.assertEqual(upgrade.schedule()['phase'], 'NO_UPGRADE_SNAPSHOT')
|
||||
run.assert_not_called()
|
||||
|
||||
def test_empty_upgrade_stays_stopped(self):
|
||||
self.pending()
|
||||
with mock.patch.object(upgrade, 'ROOT', self.root), \
|
||||
mock.patch.object(upgrade, '_root'), \
|
||||
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
|
||||
mock.patch.object(upgrade.subprocess, 'run') as run:
|
||||
self.assertEqual(upgrade.schedule()['phase'], 'COMPLETE')
|
||||
run.assert_not_called()
|
||||
|
||||
def test_restart_is_detached_and_delayed_past_package_transaction(self):
|
||||
self.pending([{'state': str(self.state)}])
|
||||
with mock.patch.object(upgrade, 'ROOT', self.root), \
|
||||
mock.patch.object(upgrade, '_root'), \
|
||||
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
|
||||
mock.patch.object(upgrade.subprocess, 'run', return_value=SimpleNamespace(returncode=0)) as run:
|
||||
self.assertEqual(upgrade.schedule()['phase'], 'SCHEDULED')
|
||||
args = run.call_args.args[0]
|
||||
self.assertEqual(args[0], '/usr/bin/systemd-run')
|
||||
self.assertIn('--on-active=2s', args)
|
||||
self.assertEqual(args[-2:], [upgrade.MODULE, 'resume'])
|
||||
|
||||
def test_scheduling_failure_keeps_captured_intent(self):
|
||||
self.pending([{'state': str(self.state)}])
|
||||
with mock.patch.object(upgrade, 'ROOT', self.root), \
|
||||
mock.patch.object(upgrade, '_root'), \
|
||||
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
|
||||
mock.patch.object(upgrade.subprocess, 'run', return_value=SimpleNamespace(returncode=1)):
|
||||
with self.assertRaisesRegex(RuntimeError, 'SCHEDULE_FAILED'):
|
||||
upgrade.schedule()
|
||||
self.assertEqual(json.loads((self.root / 'pending.json').read_text())['phase'], 'CAPTURED')
|
||||
|
||||
def test_repeated_configure_does_not_schedule_second_restart(self):
|
||||
self.write(self.root / 'pending.json', {'phase': 'SCHEDULED'})
|
||||
with mock.patch.object(upgrade, 'ROOT', self.root), \
|
||||
mock.patch.object(upgrade, '_root'), \
|
||||
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
|
||||
mock.patch.object(upgrade.subprocess, 'run') as run:
|
||||
self.assertEqual(upgrade.schedule()['phase'], 'SCHEDULED')
|
||||
run.assert_not_called()
|
||||
|
||||
def test_changed_manager_is_not_stopped_and_failure_is_recorded(self):
|
||||
row = {'uid': 1000, 'user': 'owner', 'home': '/home/owner',
|
||||
'state': str(self.state), 'pid': 1234, 'birth': '99',
|
||||
'config_sha256': 'a' * 64, 'unit': None, 'phase': 'CAPTURED'}
|
||||
self.write(self.root / 'pending.json', {'schema': 1, 'phase': 'SCHEDULED', 'profiles': [row]})
|
||||
with mock.patch.object(upgrade, 'ROOT', self.root), \
|
||||
mock.patch.object(upgrade, '_root'), \
|
||||
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
|
||||
mock.patch.object(upgrade, '_wait_dpkg', return_value=nullcontext()), \
|
||||
mock.patch.object(upgrade, '_resume_lock', return_value=nullcontext()), \
|
||||
mock.patch.object(upgrade, 'identify', return_value=dict(row, birth='100')), \
|
||||
mock.patch.object(upgrade, '_owner') as owner:
|
||||
with self.assertRaisesRegex(RuntimeError, 'PROFILE_RESTART_FAILED'):
|
||||
upgrade.resume()
|
||||
owner.assert_not_called()
|
||||
value = json.loads((self.root / 'pending.json').read_text())
|
||||
self.assertEqual(value['phase'], 'FAILED')
|
||||
self.assertIn('IDENTITY_CHANGED', value['profiles'][0]['error'])
|
||||
|
||||
def test_manual_profile_restart_never_enables_service(self):
|
||||
row = {'uid': 1000, 'user': 'owner', 'home': '/home/owner',
|
||||
'state': str(self.state), 'pid': 1234, 'birth': '99',
|
||||
'config_sha256': 'a' * 64, 'unit': None, 'phase': 'CAPTURED'}
|
||||
self.write(self.root / 'pending.json', {'schema': 1, 'phase': 'SCHEDULED', 'profiles': [row]})
|
||||
with mock.patch.object(upgrade, 'ROOT', self.root), \
|
||||
mock.patch.object(upgrade, '_root'), \
|
||||
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
|
||||
mock.patch.object(upgrade, '_wait_dpkg', return_value=nullcontext()), \
|
||||
mock.patch.object(upgrade, '_resume_lock', return_value=nullcontext()), \
|
||||
mock.patch.object(upgrade, 'identify', return_value=row), \
|
||||
mock.patch.object(upgrade, '_owner', return_value={'ok': True, 'ready': True}) as owner, \
|
||||
mock.patch.object(upgrade, '_start_manual', return_value='framebridge-profile-' + 'a' * 20 + '.service'), \
|
||||
mock.patch.object(upgrade.subprocess, 'run') as run:
|
||||
self.assertEqual(upgrade.resume()['phase'], 'COMPLETE')
|
||||
self.assertEqual([c.args[1] for c in owner.call_args_list], ['user-desktop', 'user-stop', 'user-ready'])
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
def test_snapshot_does_not_stop_the_process_running_apt(self):
|
||||
row = {'uid': os.getuid(), 'state': str(self.state), 'pid': 1234}
|
||||
with mock.patch.object(upgrade, 'ROOT', self.root), \
|
||||
mock.patch.object(upgrade, '_root'), \
|
||||
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
|
||||
mock.patch.object(upgrade, 'identify', return_value=row), \
|
||||
mock.patch.object(upgrade.subprocess, 'run') as run:
|
||||
value = upgrade.snapshot(self.root)
|
||||
self.assertEqual(value['phase'], 'CAPTURED')
|
||||
run.assert_not_called()
|
||||
|
||||
def test_optional_desktop_migration_failure_does_not_stop_runtime_recovery(self):
|
||||
row = {'uid': 1000, 'user': 'owner', 'home': '/home/owner',
|
||||
'state': str(self.state), 'pid': 1234, 'birth': '99',
|
||||
'config_sha256': 'a' * 64, 'unit': None, 'phase': 'CAPTURED'}
|
||||
self.write(self.root / 'pending.json', {'schema': 1, 'phase': 'SCHEDULED', 'profiles': [row]})
|
||||
def owner(profile, operation):
|
||||
if operation == 'user-desktop':
|
||||
raise RuntimeError('Legacy console is closed')
|
||||
return {'ok': True, 'ready': True}
|
||||
with mock.patch.object(upgrade, 'ROOT', self.root), \
|
||||
mock.patch.object(upgrade, '_root'), \
|
||||
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
|
||||
mock.patch.object(upgrade, '_wait_dpkg', return_value=nullcontext()), \
|
||||
mock.patch.object(upgrade, '_resume_lock', return_value=nullcontext()), \
|
||||
mock.patch.object(upgrade, 'identify', return_value=row), \
|
||||
mock.patch.object(upgrade, '_owner', side_effect=owner), \
|
||||
mock.patch.object(upgrade, '_start_manual', return_value='framebridge-profile-' + 'a' * 20 + '.service'):
|
||||
value = upgrade.resume()
|
||||
self.assertEqual(value['phase'], 'COMPLETE')
|
||||
self.assertEqual(value['profiles'][0]['phase'], 'READY')
|
||||
self.assertFalse(value['profiles'][0]['desktop']['ok'])
|
||||
|
||||
def check_stop_failure_retains_marker(self, before):
|
||||
if os.getuid() == 0:
|
||||
self.skipTest('Ordinary-owner fixture required')
|
||||
from framebridge_expanded import runtime, autostart
|
||||
directory = self.state / 'autostart'
|
||||
directory.mkdir(mode=0o700)
|
||||
marker = directory / 'manual-stop.json'
|
||||
if before is not None:
|
||||
marker.write_bytes(before)
|
||||
marker.chmod(0o600)
|
||||
def fail(state, **kwargs):
|
||||
marker.write_text('{"temporary":true}')
|
||||
marker.chmod(0o600)
|
||||
raise RuntimeError('simulated bounded stop failure')
|
||||
with mock.patch.object(runtime, 'stop', side_effect=fail), \
|
||||
mock.patch.object(upgrade, '_birth', return_value='99'), \
|
||||
mock.patch.object(upgrade, '_background', return_value=None), \
|
||||
mock.patch.object(runtime, 'start') as start, \
|
||||
mock.patch.object(autostart, '_configuration_action', side_effect=lambda state, action: action(state)):
|
||||
with self.assertRaisesRegex(RuntimeError, 'bounded stop failure'):
|
||||
upgrade.user_operation('user-stop', str(self.state),
|
||||
hashlib.sha256((self.state / 'config.json').read_bytes()).hexdigest(),
|
||||
manager_pid=1234, manager_birth='99')
|
||||
start.assert_not_called()
|
||||
if before is None:
|
||||
self.assertFalse(marker.exists())
|
||||
else:
|
||||
self.assertEqual(marker.read_bytes(), before)
|
||||
|
||||
def test_stop_failure_retains_absent_manual_stop_policy(self):
|
||||
self.check_stop_failure_retains_marker(None)
|
||||
|
||||
def test_stop_failure_retains_existing_manual_stop_bytes(self):
|
||||
self.check_stop_failure_retains_marker(b'{"manual_stop": true}\n')
|
||||
|
||||
|
||||
|
||||
def test_user_unit_restart_rejects_account_reassignment(self):
|
||||
row = {'uid': 1000, 'user': 'owner', 'home': '/home/owner'}
|
||||
account = SimpleNamespace(pw_name='different', pw_dir='/home/different')
|
||||
with mock.patch.object(upgrade.pwd, 'getpwuid', return_value=account):
|
||||
with self.assertRaisesRegex(RuntimeError, 'ACCOUNT_CHANGED'):
|
||||
upgrade._unit(row)
|
||||
|
||||
|
||||
|
||||
def test_manual_start_uses_an_independent_user_service_cgroup(self):
|
||||
row = {'uid': os.getuid(), 'user': 'owner', 'home': '/home/owner', 'state': str(self.state)}
|
||||
account = SimpleNamespace(pw_name='owner', pw_dir='/home/owner')
|
||||
with mock.patch.object(upgrade.pwd, 'getpwuid', return_value=account), \
|
||||
mock.patch.object(upgrade.subprocess, 'run', return_value=SimpleNamespace(returncode=0)) as run:
|
||||
if os.getuid() == 0:
|
||||
self.skipTest('Ordinary-owner fixture required')
|
||||
unit = upgrade._start_manual(row)
|
||||
args = run.call_args.args[0]
|
||||
self.assertIn('/usr/bin/systemd-run', args)
|
||||
self.assertIn('--property=ExitType=cgroup', args)
|
||||
self.assertIn('--property=KillMode=control-group', args)
|
||||
self.assertIn('--unit=' + unit, args)
|
||||
self.assertEqual(args[-5:], ['/usr/bin/fb', '--state-dir', str(self.state), '--json', 'start'])
|
||||
self.assertNotIn('enable', args)
|
||||
|
||||
def test_background_identity_failure_is_refused_without_signaling(self):
|
||||
from framebridge_expanded import connection_background as background
|
||||
with mock.patch.object(background, '_directory', return_value=self.state), \
|
||||
mock.patch.object(background, '_read', return_value={'pid': os.getpid()}), \
|
||||
mock.patch.object(background, '_valid_identity', return_value=False), \
|
||||
mock.patch.object(background, 'disconnect') as disconnect:
|
||||
with self.assertRaisesRegex(RuntimeError, 'BACKGROUND_IDENTITY_UNVERIFIED'):
|
||||
upgrade._background(self.state)
|
||||
disconnect.assert_not_called()
|
||||
|
||||
def test_verified_background_connection_is_retained_without_reading_credentials(self):
|
||||
from framebridge_expanded import connection_background as background
|
||||
record = {'pid': 1234, 'start_ticks': 88}
|
||||
with mock.patch.object(background, '_directory', return_value=self.state), \
|
||||
mock.patch.object(background, '_read', return_value=record), \
|
||||
mock.patch.object(background, '_valid_identity', return_value=True), \
|
||||
mock.patch.object(background, 'status', return_value={'verified': True}):
|
||||
value = upgrade._background(self.state)
|
||||
self.assertTrue(value['preserved_in_memory'])
|
||||
self.assertFalse(value['process_reloaded'])
|
||||
self.assertEqual(value['start_ticks'], 88)
|
||||
|
||||
def check_runtime_stop_connection(self, preserve):
|
||||
from framebridge_expanded import runtime, connection_background, tty, autostart
|
||||
with mock.patch.object(runtime, 'load_config', return_value=self.config), \
|
||||
mock.patch.object(runtime, '_read', return_value={}), \
|
||||
mock.patch.object(runtime, '_terminate'), \
|
||||
mock.patch.object(runtime, '_atomic'), \
|
||||
mock.patch.object(runtime, 'status', return_value={'running': False}), \
|
||||
mock.patch.object(tty, 'stop', return_value={'ok': True, 'cleanup_verified': True}), \
|
||||
mock.patch.object(autostart, 'mark_manual_stop'), \
|
||||
mock.patch.object(connection_background, 'disconnect', return_value={'ok': True}) as disconnect, \
|
||||
mock.patch.object(connection_background, 'status', return_value={'ok': True, 'alive': True}) as status:
|
||||
if preserve:
|
||||
runtime.stop(self.state, preserve_connection=True)
|
||||
disconnect.assert_not_called()
|
||||
status.assert_called_once_with(self.state)
|
||||
else:
|
||||
runtime.stop(self.state)
|
||||
disconnect.assert_called_once_with(self.state)
|
||||
status.assert_not_called()
|
||||
|
||||
def test_normal_runtime_stop_still_disconnects(self):
|
||||
self.check_runtime_stop_connection(False)
|
||||
|
||||
def test_upgrade_runtime_stop_retains_existing_connection(self):
|
||||
self.check_runtime_stop_connection(True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Private package versions retain the fixed privileged-helper rollback floor.
|
||||
|
||||
Only a temporary maintainer script is run. Cleanup Python is replaced with a
|
||||
recording stub; no installed helper, profile or package transaction is touched.
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
WORK = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(WORK / "packaging"))
|
||||
import build_expanded
|
||||
|
||||
PRIVATE_VERSION = "0.3.2+private.20261028.1"
|
||||
|
||||
|
||||
class PrivateCandidatePrermTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.control = self.root / "DEBIAN"
|
||||
self.control.mkdir()
|
||||
self.log = self.root / "cleanup-calls.jsonl"
|
||||
self.python_stub = self.root / "record-cleanup"
|
||||
self.python_stub.write_text(
|
||||
"#!/usr/bin/python3\nimport json,sys\nfrom pathlib import Path\n"
|
||||
+ "with Path(" + repr(str(self.log)) + ").open('a') as stream:\n"
|
||||
+ " stream.write(json.dumps(sys.argv[1:])+'\\n')\n")
|
||||
self.python_stub.chmod(0o755)
|
||||
|
||||
def candidate_script(self, package_version=PRIVATE_VERSION):
|
||||
# Execute the real production write expression with an arbitrary package
|
||||
# VERSION, rather than reconstructing its floor selection in the test.
|
||||
tree = ast.parse(Path(build_expanded.__file__).read_text())
|
||||
main = next(node for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "main")
|
||||
writers = [node for node in ast.walk(main)
|
||||
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
|
||||
and isinstance(node.value.func, ast.Attribute)
|
||||
and node.value.func.attr == "write_text"
|
||||
and node.value.args and isinstance(node.value.args[0], ast.Call)
|
||||
and isinstance(node.value.args[0].func, ast.Name)
|
||||
and node.value.args[0].func.id == "maintainer_prerm"]
|
||||
self.assertEqual(len(writers), 1)
|
||||
namespace = dict(control=self.control, VERSION=package_version,
|
||||
PRIVILEGED_HELPER_FLOOR=build_expanded.PRIVILEGED_HELPER_FLOOR,
|
||||
maintainer_prerm=build_expanded.maintainer_prerm)
|
||||
exec(compile(ast.Module(body=writers, type_ignores=[]), build_expanded.__file__, "exec"), namespace)
|
||||
script = self.control / "prerm"
|
||||
text = script.read_text()
|
||||
self.assertIn('"$2" lt 0.3.2;', text)
|
||||
self.assertNotIn(package_version, text)
|
||||
# The real dpkg is used only for its read-only version validator and
|
||||
# comparator. Every privileged cleanup command is a recording stub.
|
||||
script.write_text(text.replace("/usr/bin/python3", shlex.quote(str(self.python_stub))))
|
||||
return script
|
||||
|
||||
def upgrade_target(self, target, package_version=PRIVATE_VERSION):
|
||||
script = self.candidate_script(package_version)
|
||||
result = subprocess.run(["/bin/sh", str(script), "upgrade", target],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else []
|
||||
|
||||
def test_private_to_official_032_preserves_helper_grants(self):
|
||||
self.assertEqual(self.upgrade_target("0.3.2"), [])
|
||||
|
||||
def test_target_below_032_revokes_helper_grants(self):
|
||||
calls = self.upgrade_target("0.3.1")
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(calls[0][:3], ["-I", "-S", "-c"])
|
||||
self.assertIn('["purge_credential_helpers"]()', calls[0][3])
|
||||
|
||||
def test_same_private_version_preserves_helper_grants(self):
|
||||
self.assertEqual(self.upgrade_target(PRIVATE_VERSION), [])
|
||||
|
||||
def test_forward_upgrade_preserves_helper_grants(self):
|
||||
self.assertEqual(self.upgrade_target("0.3.3"), [])
|
||||
|
||||
def test_arbitrary_future_package_version_keeps_the_032_floor(self):
|
||||
self.assertEqual(self.upgrade_target("0.3.2", package_version="9.9.9+private.1"), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Runtime stop must propagate terminal cleanup evidence."""
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import call, patch
|
||||
|
||||
from framebridge_expanded import runtime, tty
|
||||
|
||||
|
||||
class RuntimeTerminalCleanupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
stack = ExitStack()
|
||||
self.addCleanup(stack.close)
|
||||
self.state = Path("/unused-framebridge-runtime-test")
|
||||
stack.enter_context(patch.object(runtime, "state_path", return_value=self.state))
|
||||
stack.enter_context(patch.object(runtime, "load_config", return_value={}))
|
||||
self.disconnect = stack.enter_context(patch(
|
||||
"framebridge_expanded.connection_background.disconnect", return_value={"ok": True}))
|
||||
self.manual = stack.enter_context(patch("framebridge_expanded.autostart.mark_manual_stop"))
|
||||
self.terminal = stack.enter_context(patch.object(
|
||||
tty, "stop", return_value={"ok": True, "cleanup_verified": True}))
|
||||
self.manager = {"pid": 101}
|
||||
self.child = {"pid": 102}
|
||||
self.worker = {"pid": 103}
|
||||
self.read = stack.enter_context(patch.object(runtime, "_read", side_effect=[
|
||||
self.manager, {"processes": [self.child]}, {"processes": [self.worker]}]))
|
||||
self.terminate = stack.enter_context(patch.object(runtime, "_terminate"))
|
||||
self.atomic = stack.enter_context(patch.object(runtime, "_atomic"))
|
||||
self.status = stack.enter_context(patch.object(
|
||||
runtime, "status", return_value={"ok": True, "running": False, "ready": False}))
|
||||
|
||||
def test_failed_terminal_cleanup_leaves_local_services_running(self):
|
||||
self.terminal.return_value = {"ok": False, "state": "STOPPED", "cleanup_verified": False}
|
||||
with self.assertRaisesRegex(RuntimeError, "TTY cleanup could not be verified"):
|
||||
runtime.stop(self.state)
|
||||
self.disconnect.assert_called_once_with(self.state)
|
||||
self.manual.assert_not_called()
|
||||
self.terminate.assert_not_called()
|
||||
self.atomic.assert_not_called()
|
||||
self.status.assert_not_called()
|
||||
|
||||
def test_cleanup_requires_both_explicit_true_fields(self):
|
||||
for outcome in ({"ok": True}, {"ok": True, "cleanup_verified": False},
|
||||
{"ok": False, "cleanup_verified": True}, None):
|
||||
with self.subTest(outcome=outcome):
|
||||
self.terminal.return_value = outcome
|
||||
with self.assertRaises(RuntimeError):
|
||||
runtime.stop(self.state)
|
||||
self.terminate.assert_not_called()
|
||||
|
||||
def test_normal_stop_verifies_before_and_after_terminating_services(self):
|
||||
result = runtime.stop(self.state)
|
||||
self.assertEqual(self.terminal.call_args_list, [call(self.state), call(self.state)])
|
||||
self.terminate.assert_has_calls([
|
||||
call(None, self.manager), call(None, self.child), call(None, self.worker)])
|
||||
self.manual.assert_called_once_with(self.state)
|
||||
self.assertTrue(result["tty_cleanup_verified"])
|
||||
self.assertFalse(result["running"])
|
||||
|
||||
def test_late_cleanup_failure_cannot_return_global_success(self):
|
||||
self.terminal.side_effect = [
|
||||
{"ok": True, "cleanup_verified": True},
|
||||
{"ok": False, "cleanup_verified": False}]
|
||||
with self.assertRaisesRegex(RuntimeError, "TTY cleanup could not be verified"):
|
||||
runtime.stop(self.state)
|
||||
self.assertTrue(self.terminate.called)
|
||||
self.atomic.assert_called_once_with(self.state / "ready.json", {"ready": False})
|
||||
self.status.assert_not_called()
|
||||
|
||||
def test_failed_disconnect_preserves_existing_stop_guard(self):
|
||||
self.disconnect.return_value = {"ok": False}
|
||||
with self.assertRaisesRegex(RuntimeError, "background ChatGPT connection"):
|
||||
runtime.stop(self.state)
|
||||
self.terminal.assert_not_called()
|
||||
self.manual.assert_not_called()
|
||||
self.terminate.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,6 +3,10 @@
|
||||
The bounded executor is a fixture. This is not VM, GUI, TTY or input acceptance.
|
||||
"""
|
||||
import ast
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
@@ -13,9 +17,26 @@ from test_screen_frontdoor import Executor, Image, MCP, NODE_ID
|
||||
PORTABLE = Path(__file__).resolve().parents[1] / "portable/front_door_mcp.py"
|
||||
|
||||
|
||||
class TypedChunkExecutor(Executor):
|
||||
"""Model the explicitly negotiated typed route without changing legacy fixtures."""
|
||||
def __call__(self, **kwargs):
|
||||
outer = super().__call__(**kwargs)
|
||||
request = json.loads(base64.b64decode(shlex.split(kwargs["script"])[-1]))
|
||||
if request["op"] == "read_chunk" and request.get("chunk_bytes"):
|
||||
size, index = request["chunk_bytes"], request["index"]
|
||||
envelope = json.loads(outer["stdout"])
|
||||
envelope["result"].update(
|
||||
total_chunks=math.ceil(len(self.data) / size),
|
||||
data=base64.b64encode(self.data[index * size:(index + 1) * size]).decode(),
|
||||
more=(index + 1) * size < len(self.data),
|
||||
)
|
||||
outer["stdout"] = json.dumps(envelope)
|
||||
return outer
|
||||
|
||||
|
||||
class PortableScreenRegistrationTests(unittest.TestCase):
|
||||
def registration(self, target="LOCAL", corrupt=None):
|
||||
fixture = Executor(hostname="fb-central-lab" if target == "LOCAL" else "fb-node-lab")
|
||||
fixture = TypedChunkExecutor(hostname="fb-central-lab" if target == "LOCAL" else "fb-node-lab")
|
||||
calls, legacy_calls = [], []
|
||||
|
||||
def expanded(alias, script, authorized, timeout_s=120, job_id="", sha256="", include_stdout=False):
|
||||
@@ -51,6 +72,7 @@ class PortableScreenRegistrationTests(unittest.TestCase):
|
||||
framebridge_exec=legacy,
|
||||
register_framebridge_screen_tools=register_framebridge_screen_tools,
|
||||
resolve_profile_node=lambda node: node)
|
||||
scope["_blocking_tools"] = scope["mcp"] # This fixture calls registrations synchronously.
|
||||
# Load the definition and registration from production source rather
|
||||
# than recreating the adapter inside this test.
|
||||
module = ast.Module(body=definitions + registrations, type_ignores=[])
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Deterministic protocol fixtures only: never observe or control a desktop."""
|
||||
import base64
|
||||
import concurrent.futures
|
||||
import contextlib
|
||||
import hashlib
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import sys
|
||||
import types
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from framebridge_expanded.real_screen import screen_rpc as rpc
|
||||
from framebridge_expanded.real_screen import screen_transport as transport
|
||||
from framebridge_expanded.real_screen.screen_frontdoor import (
|
||||
CHUNK_BYTES, EXPANDED_CHUNK_BYTES, EXPANDED_STDOUT_BYTES,
|
||||
MAX_STDOUT_BYTES, RPC_COMMAND, ScreenClient, ScreenError,
|
||||
)
|
||||
from test_screen_frontdoor import CAPTURE_ID, NODE_ID, Image, png, Executor
|
||||
|
||||
|
||||
class Clock:
|
||||
now = 100.0
|
||||
|
||||
def __call__(self):
|
||||
return self.now
|
||||
|
||||
|
||||
class ProtocolFixture:
|
||||
"""Saved generated PNG fixture through actual producer, RPC and typed transport.
|
||||
|
||||
The private records model a previously captured artifact for protocol tests;
|
||||
they are generated test pixels and do not certify any real screen observation.
|
||||
"""
|
||||
def __init__(self, directory, width=384, height=256):
|
||||
self.directory = directory
|
||||
self.data = png(width, height)
|
||||
self.manifest = {
|
||||
'capture_id': CAPTURE_ID, 'node': 'test-fixture-host', 'uid': os.getuid(),
|
||||
'source': 'linux-framebuffer', 'screen_type': 'tty', 'active_vt': 'tty1',
|
||||
'bytes': len(self.data), 'sha256': hashlib.sha256(self.data).hexdigest(),
|
||||
'width': width, 'height': height, 'synthetic': False,
|
||||
'pixel_source_verified': True, 'full_screen': True, 'authentic_pixels': True,
|
||||
'session_id': 'screen_fixture', 'requested_session_visible': False,
|
||||
'control_available': True, '_action_consumed': False,
|
||||
'binding': {'window_id': 'fixture-window', 'geometry': [0, 0, width, height]},
|
||||
}
|
||||
path = directory / (CAPTURE_ID + '.png')
|
||||
path.write_bytes(self.data)
|
||||
path.chmod(0o600)
|
||||
for suffix in ('.json', '.bound.json'):
|
||||
rpc.write_record(directory / (CAPTURE_ID + suffix), self.manifest)
|
||||
self.calls, self.reply_sizes = [], []
|
||||
self.rpc, self.transport = rpc, transport
|
||||
self.variant = None
|
||||
self.clock, self.latency = None, 0
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
request = json.loads(base64.b64decode(shlex.split(kwargs['script'])[-1]))
|
||||
self.calls.append((request, kwargs['timeout_s']))
|
||||
if self.clock is not None:
|
||||
self.clock.now += self.latency
|
||||
payload = {key: kwargs[key] for key in (
|
||||
'script', 'authorized', 'timeout_s', 'job_id', 'sha256', 'include_stdout')}
|
||||
payload.update(version=1, mode='EXEC', request_type='screen_rpc')
|
||||
|
||||
def runner(argument, timeout_s):
|
||||
# No real observe/input functions are invoked by this fixture executor.
|
||||
if request['op'] in ('key', 'text'):
|
||||
manifest = self.rpc.public_manifest(self.manifest)
|
||||
manifest['action'] = {'kind': request['op'], 'previous_capture_id': request['capture_id'],
|
||||
'result': {'performed': True}}
|
||||
envelope = {'ok': True, 'result': manifest}
|
||||
else:
|
||||
envelope = self.rpc.dispatch(request, self.directory)
|
||||
if request['op'] == 'read_chunk' and self.variant == 'corrupt':
|
||||
encoded = envelope['result']['data']
|
||||
data = base64.b64decode(encoded)
|
||||
envelope['result']['data'] = base64.b64encode(bytes([data[0] ^ 1]) + data[1:]).decode()
|
||||
return 0, self.rpc.encode(envelope) + b'\n', b''
|
||||
|
||||
outer = self.transport.handle_request(payload, runner=runner)
|
||||
outer.update(target=kwargs['target'], node_id=NODE_ID,
|
||||
mcp_job_id=kwargs['job_id'], mcp_script_sha256=kwargs['sha256'])
|
||||
self.reply_sizes.append(len(outer['stdout'].encode()))
|
||||
if self.variant == 'truncated' and request['op'] == 'read_chunk':
|
||||
outer['stdout_truncated'] = True
|
||||
return outer
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def frozen_receiver(testcase):
|
||||
"""Load the hashed base receiver fixture, including in exported source archives.
|
||||
|
||||
The capsule contains only the five original real-screen modules from the
|
||||
stated immutable revision. Its source hashes and outer digest are verified;
|
||||
no Git checkout, installed receiver, or live desktop is used by this test.
|
||||
"""
|
||||
revision = 'f6b14c52109e0d55b919f179a38c8a4c8f8868f9'
|
||||
path = Path(__file__).resolve().parent / 'fixtures/frozen_screen_receiver_f6b14c52.json.gz'
|
||||
raw = path.read_bytes()
|
||||
testcase.assertEqual(hashlib.sha256(raw).hexdigest(), '47670e1325c0f77fabeea9c69c09923c3fd0ed2463a8300a4940bb5e4bafffc5')
|
||||
capsule = json.loads(gzip.decompress(raw))
|
||||
testcase.assertEqual(capsule['revision'], revision)
|
||||
names = ('screen_frontdoor', 'screen_capture', 'screen_control', 'screen_rpc', 'screen_transport')
|
||||
testcase.assertEqual(set(capsule['files']), set(names))
|
||||
package_name = '_framebridge_frozen_screen_fixture'
|
||||
package = types.ModuleType(package_name)
|
||||
package.__path__ = []
|
||||
with patch.dict(sys.modules, {package_name: package}):
|
||||
modules = {}
|
||||
for name in names:
|
||||
entry = capsule['files'][name]
|
||||
source = entry['source']
|
||||
testcase.assertEqual(hashlib.sha256(source.encode()).hexdigest(), entry['sha256'])
|
||||
full_name = package_name + '.' + name
|
||||
module = types.ModuleType(full_name)
|
||||
module.__package__ = package_name
|
||||
sys.modules[full_name] = module
|
||||
exec(compile(source, revision + ':' + name + '.py', 'exec'), module.__dict__)
|
||||
modules[name] = module
|
||||
yield modules['screen_rpc'], modules['screen_transport']
|
||||
|
||||
|
||||
class ScreenTransferBudgetTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.directory = Path(self.temp.name)
|
||||
self.fixture = ProtocolFixture(self.directory)
|
||||
|
||||
def client(self, **kwargs):
|
||||
return ScreenClient(self.fixture, Image, **kwargs)
|
||||
|
||||
def read(self, client):
|
||||
return client.call('read_capture', 'GERAMEG', True, capture_id=CAPTURE_ID)
|
||||
|
||||
def test_expanded_complete_original_through_producer_rpc_and_transport(self):
|
||||
result = self.read(self.client(stdout_limit_bytes=EXPANDED_STDOUT_BYTES))
|
||||
self.assertEqual(result[1].data, self.fixture.data)
|
||||
self.assertEqual(hashlib.sha256(result[1].data).hexdigest(), self.fixture.manifest['sha256'])
|
||||
self.assertEqual(len(self.fixture.calls), 1 + math.ceil(len(self.fixture.data) / EXPANDED_CHUNK_BYTES))
|
||||
self.assertTrue(all(r['chunk_bytes'] == EXPANDED_CHUNK_BYTES for r, _ in self.fixture.calls[1:]))
|
||||
self.assertLessEqual(max(self.fixture.reply_sizes), EXPANDED_STDOUT_BYTES)
|
||||
self.assertGreater(max(self.fixture.reply_sizes), MAX_STDOUT_BYTES)
|
||||
# A full-sized serialized reply is measured, not estimated from base64 alone.
|
||||
self.assertGreater(max(self.fixture.reply_sizes), 60000)
|
||||
self.assertGreater(EXPANDED_STDOUT_BYTES - max(self.fixture.reply_sizes), 4096)
|
||||
for key in ('capture_id', 'node', 'session_id', 'width', 'height', 'binding', 'requested_session_visible'):
|
||||
self.assertEqual(result[0]['result'][key], self.fixture.manifest[key])
|
||||
self.assertEqual(result[0]['node_id'], NODE_ID)
|
||||
legacy_calls = 1 + math.ceil(len(self.fixture.data) / CHUNK_BYTES)
|
||||
self.assertGreater(legacy_calls, 10 * len(self.fixture.calls))
|
||||
|
||||
def test_legacy_omits_extension_and_stays_within_4096_bytes(self):
|
||||
result = self.read(self.client())
|
||||
self.assertEqual(result[1].data, self.fixture.data)
|
||||
self.assertEqual(len(self.fixture.calls), 1 + math.ceil(len(self.fixture.data) / CHUNK_BYTES))
|
||||
self.assertTrue(all('chunk_bytes' not in r for r, _ in self.fixture.calls))
|
||||
self.assertLessEqual(max(self.fixture.reply_sizes), MAX_STDOUT_BYTES)
|
||||
|
||||
def test_new_central_with_actual_frozen_receiver_uses_legacy_schema(self):
|
||||
with frozen_receiver(self) as (frozen_rpc, frozen_transport):
|
||||
self.fixture.rpc, self.fixture.transport = frozen_rpc, frozen_transport
|
||||
self.assertNotIn('max_chunk_bytes', frozen_rpc.public_manifest(self.fixture.manifest))
|
||||
# Only the capture itself is mocked: all frozen protocol and PNG reads
|
||||
# execute the actual base code. Never access a live desktop in tests.
|
||||
with patch.object(frozen_rpc, 'observe', return_value=self.fixture.manifest):
|
||||
for op, arguments in [('observe', {}), ('read_capture', {'capture_id': CAPTURE_ID})]:
|
||||
with self.subTest(op=op):
|
||||
self.fixture.calls.clear()
|
||||
result = self.client(stdout_limit_bytes=EXPANDED_STDOUT_BYTES).call(
|
||||
op, 'GERAMEG', True, **arguments)
|
||||
self.assertEqual(result[1].data, self.fixture.data)
|
||||
self.assertEqual(result[0]['result']['sha256'], self.fixture.manifest['sha256'])
|
||||
self.assertNotIn('max_chunk_bytes', result[0]['result'])
|
||||
self.assertTrue(all('chunk_bytes' not in request for request, _ in self.fixture.calls))
|
||||
self.assertEqual(len(self.fixture.calls), 1 + math.ceil(len(self.fixture.data) / CHUNK_BYTES))
|
||||
self.assertLessEqual(max(self.fixture.reply_sizes), MAX_STDOUT_BYTES)
|
||||
|
||||
def test_capability_is_exact_bounded_int_and_cannot_expand_transport_budget(self):
|
||||
original = self.fixture.rpc.public_manifest
|
||||
for capability in (True, 0, -1, EXPANDED_CHUNK_BYTES + 1, 1.5, '45000'):
|
||||
with self.subTest(capability=capability), patch.object(
|
||||
self.fixture.rpc, 'public_manifest',
|
||||
side_effect=lambda value: dict(original(value), max_chunk_bytes=capability)):
|
||||
self.fixture.calls.clear()
|
||||
result = self.read(self.client(stdout_limit_bytes=EXPANDED_STDOUT_BYTES))
|
||||
self.assertEqual(result['error'], 'SCREEN_TRANSFER_CAPABILITY_INVALID')
|
||||
self.assertEqual(len(self.fixture.calls), 1)
|
||||
self.fixture.calls.clear()
|
||||
result = self.read(self.client())
|
||||
self.assertEqual(result[1].data, self.fixture.data)
|
||||
self.assertTrue(all('chunk_bytes' not in request for request, _ in self.fixture.calls))
|
||||
self.assertLessEqual(max(self.fixture.reply_sizes), MAX_STDOUT_BYTES)
|
||||
|
||||
def test_ambiguous_submitted_input_failures_require_observe_without_claiming_performed(self):
|
||||
variants = ('non_ok', 'truncated', 'malformed', 'binding')
|
||||
for op, arguments in [('key', {'key': 'ENTER'}), ('text', {'text': 'fixture-only'})]:
|
||||
for variant in variants:
|
||||
with self.subTest(op=op, variant=variant):
|
||||
calls = []
|
||||
fixture = Executor()
|
||||
def execute(**kwargs):
|
||||
calls.append(kwargs)
|
||||
outer = fixture(**kwargs)
|
||||
if variant == 'non_ok':
|
||||
return {'ok': False, 'error': 'SCREEN_RPC_FAILED'}
|
||||
if variant == 'truncated':
|
||||
outer['stdout_truncated'] = True
|
||||
elif variant == 'malformed':
|
||||
outer['stdout'] = '{invalid fixture'
|
||||
elif variant == 'binding':
|
||||
outer['mcp_script_sha256'] = '0' * 64
|
||||
return outer
|
||||
result = ScreenClient(execute, Image).call(
|
||||
op, 'GERAMEG', True, capture_id=CAPTURE_ID, **arguments)
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertTrue(result['observe_again'])
|
||||
self.assertEqual(result['capture_id'], CAPTURE_ID)
|
||||
self.assertNotIn('performed', result)
|
||||
self.assertNotIn('capture_consumed', result)
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_unconfirmed_input_image_integrity_failure_does_not_assert_performed(self):
|
||||
for variant in ('dimensions', 'chunk_hash', 'image_hash'):
|
||||
with self.subTest(variant=variant):
|
||||
fixture = Executor(variant)
|
||||
result = ScreenClient(fixture, Image).call(
|
||||
'key', 'GERAMEG', True, capture_id=CAPTURE_ID, key='ENTER')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertTrue(result['observe_again'])
|
||||
self.assertNotIn('performed', result)
|
||||
self.assertNotIn('capture_consumed', result)
|
||||
self.assertEqual(sum(request['op'] == 'key' for request in fixture.requests), 1)
|
||||
|
||||
def test_generic_rpc_exception_after_consumption_remains_uncertain(self):
|
||||
attempts = []
|
||||
def execute(**kwargs):
|
||||
request = shlex.split(kwargs['script'])[-1]
|
||||
output = io.StringIO()
|
||||
def interrupted(*args, **arguments):
|
||||
attempts.append('fixture-input-attempt')
|
||||
raise OSError('fixture interruption after consuming capture')
|
||||
with patch.object(rpc, 'state_directory', return_value=self.directory), \
|
||||
patch.object(rpc, 'act', side_effect=interrupted), contextlib.redirect_stdout(output):
|
||||
code = rpc.main([request])
|
||||
return {'ok': False, 'exit_code': str(code), 'target': kwargs['target'], 'node_id': NODE_ID,
|
||||
'mcp_job_id': kwargs['job_id'], 'mcp_script_sha256': kwargs['sha256'],
|
||||
'stdout': output.getvalue(), 'stdout_truncated': False}
|
||||
result = ScreenClient(execute, Image).call(
|
||||
'key', 'GERAMEG', True, capture_id=CAPTURE_ID, key='ENTER')
|
||||
self.assertEqual(result['error'], 'SCREEN_REQUEST_FAILED')
|
||||
self.assertTrue(result['observe_again'])
|
||||
self.assertNotIn('performed', result)
|
||||
self.assertNotIn('capture_consumed', result)
|
||||
self.assertEqual(attempts, ['fixture-input-attempt'])
|
||||
self.assertTrue(rpc.load_capture(self.directory, CAPTURE_ID)['_action_consumed'])
|
||||
|
||||
def test_chunk_rejection_after_unconfirmed_input_still_requires_observation(self):
|
||||
fixture = Executor()
|
||||
def execute(**kwargs):
|
||||
result = fixture(**kwargs)
|
||||
if fixture.requests[-1]['op'] == 'read_chunk':
|
||||
result.update(ok=False, exit_code='1', stdout=json.dumps(
|
||||
{'ok': False, 'error': {'code': 'UNKNOWN_REQUEST_FIELDS'}}))
|
||||
return result
|
||||
result = ScreenClient(execute, Image).call(
|
||||
'key', 'GERAMEG', True, capture_id=CAPTURE_ID, key='ENTER')
|
||||
self.assertEqual(result['error'], 'UNKNOWN_REQUEST_FIELDS')
|
||||
self.assertTrue(result['observe_again'])
|
||||
self.assertNotIn('performed', result)
|
||||
self.assertNotIn('capture_consumed', result)
|
||||
self.assertEqual([request['op'] for request in fixture.requests], ['key', 'read_chunk'])
|
||||
|
||||
def test_known_backend_rejection_keeps_original_meaning_without_uncertainty(self):
|
||||
for op, arguments in [('key', {'key': 'ENTER'}), ('text', {'text': 'fixture-only'})]:
|
||||
with self.subTest(op=op):
|
||||
fixture = Executor('error_exit1')
|
||||
result = ScreenClient(fixture, Image).call(
|
||||
op, 'GERAMEG', True, capture_id=CAPTURE_ID, **arguments)
|
||||
self.assertEqual(result['error'], 'SESSION_NOT_VISIBLE')
|
||||
self.assertNotIn('observe_again', result)
|
||||
self.assertNotIn('performed', result)
|
||||
self.assertEqual(len(fixture.requests), 1)
|
||||
|
||||
def test_truncation_and_corruption_never_return_partial_success(self):
|
||||
for variant, error in [('truncated', 'SCREEN_STDOUT_INVALID'), ('corrupt', 'SCREEN_IMAGE_HASH_MISMATCH')]:
|
||||
with self.subTest(variant=variant):
|
||||
self.fixture.variant = variant
|
||||
result = self.read(self.client(stdout_limit_bytes=EXPANDED_STDOUT_BYTES))
|
||||
self.assertIs(result['ok'], False)
|
||||
self.assertEqual(result['error'], error)
|
||||
self.assertNotIn('result', result)
|
||||
|
||||
def test_monotonic_total_deadline_propagates_remaining_whole_seconds(self):
|
||||
clock = Clock()
|
||||
self.fixture.clock, self.fixture.latency = clock, 1.2
|
||||
with patch('framebridge_expanded.real_screen.screen_frontdoor.time.monotonic', clock):
|
||||
result = self.read(self.client(stdout_limit_bytes=EXPANDED_STDOUT_BYTES, transfer_timeout_s=4))
|
||||
self.assertEqual(result['error'], 'SCREEN_TRANSFER_TIMEOUT')
|
||||
self.assertEqual([t for _, t in self.fixture.calls], [4, 2, 1])
|
||||
self.assertLess(clock.now, 104)
|
||||
self.assertNotIn('result', result)
|
||||
|
||||
def test_late_executor_response_cannot_publish_stale_success(self):
|
||||
clock = Clock()
|
||||
self.fixture.clock, self.fixture.latency = clock, 6
|
||||
with patch('framebridge_expanded.real_screen.screen_frontdoor.time.monotonic', clock):
|
||||
result = self.read(self.client(transfer_timeout_s=5))
|
||||
self.assertEqual(result['error'], 'SCREEN_TRANSFER_TIMEOUT')
|
||||
self.assertEqual(len(self.fixture.calls), 1)
|
||||
|
||||
def test_deadline_after_confirmed_input_preserves_no_replay_warning(self):
|
||||
clock = Clock()
|
||||
self.fixture.clock, self.fixture.latency = clock, 1.2
|
||||
with patch('framebridge_expanded.real_screen.screen_frontdoor.time.monotonic', clock):
|
||||
result = self.client(transfer_timeout_s=3).call(
|
||||
'key', 'GERAMEG', True, capture_id=CAPTURE_ID, key='ENTER')
|
||||
self.assertEqual(result['error'], 'SCREEN_TRANSFER_TIMEOUT')
|
||||
self.assertTrue(result['performed'])
|
||||
self.assertTrue(result['capture_consumed'])
|
||||
self.assertTrue(result['observe_again'])
|
||||
self.assertEqual(result['capture_id'], CAPTURE_ID)
|
||||
|
||||
def test_executor_exception_during_input_requires_fresh_observation(self):
|
||||
def failed(**kwargs):
|
||||
raise TimeoutError('test fixture timeout')
|
||||
result = ScreenClient(failed, Image).call(
|
||||
'key', 'GERAMEG', True, capture_id=CAPTURE_ID, key='ENTER')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertTrue(result['observe_again'])
|
||||
self.assertNotIn('performed', result)
|
||||
self.assertEqual(result['capture_id'], CAPTURE_ID)
|
||||
|
||||
def test_chunk_extension_is_bounded_strict_and_legacy_default_remains(self):
|
||||
request = {'op': 'read_chunk', 'capture_id': CAPTURE_ID, 'index': 0,
|
||||
'expected_sha256': self.fixture.manifest['sha256']}
|
||||
self.assertEqual(rpc.dispatch(request, self.directory)['result']['chunk_bytes'], CHUNK_BYTES)
|
||||
for value in (True, 0, -1, EXPANDED_CHUNK_BYTES + 1, 1.5, '45000'):
|
||||
with self.subTest(value=value), self.assertRaisesRegex(rpc.ScreenRpcError, 'CHUNK_BOUNDS_INVALID'):
|
||||
rpc.validate_request(dict(request, chunk_bytes=value))
|
||||
with self.assertRaisesRegex(rpc.ScreenRpcError, 'UNKNOWN_REQUEST_FIELDS'):
|
||||
rpc.validate_request(dict(request, extra='invalid'))
|
||||
|
||||
def test_client_stdout_budgets_reject_one_byte_over_exact_boundary(self):
|
||||
for budget in (MAX_STDOUT_BYTES, EXPANDED_STDOUT_BYTES):
|
||||
for over in (0, 1):
|
||||
with self.subTest(budget=budget, over=over):
|
||||
def execute(**kwargs):
|
||||
envelope = {'ok': True, 'result': {'padding': ''}}
|
||||
envelope['result']['padding'] = 'x' * (budget + over - len(rpc.encode(envelope)))
|
||||
return {'ok': True, 'exit_code': '0', 'target': kwargs['target'], 'node_id': NODE_ID,
|
||||
'mcp_job_id': kwargs['job_id'], 'mcp_script_sha256': kwargs['sha256'],
|
||||
'stdout': rpc.encode(envelope).decode(), 'stdout_truncated': False}
|
||||
client = ScreenClient(execute, Image, stdout_limit_bytes=budget)
|
||||
if over:
|
||||
with self.assertRaisesRegex(ScreenError, 'SCREEN_STDOUT_INVALID'):
|
||||
client.remote('GERAMEG', {'op': 'status'})
|
||||
else:
|
||||
self.assertIn('padding', client.remote('GERAMEG', {'op': 'status'}))
|
||||
|
||||
def test_typed_transport_exact_serialized_boundary(self):
|
||||
script = RPC_COMMAND + shlex.quote(base64.b64encode(rpc.encode({'op': 'status'})).decode()) + '\n'
|
||||
payload = {'version': 1, 'mode': 'EXEC', 'authorized': True, 'script': script,
|
||||
'timeout_s': 30, 'job_id': 'test-screen-boundary', 'sha256': hashlib.sha256(script.encode()).hexdigest(),
|
||||
'include_stdout': True, 'request_type': 'screen_rpc'}
|
||||
for over in (0, 1):
|
||||
envelope = {'ok': True, 'result': {'padding': ''}}
|
||||
envelope['result']['padding'] = 'x' * (EXPANDED_STDOUT_BYTES + over - len(rpc.encode(envelope)) - 1)
|
||||
stdout = rpc.encode(envelope) + b'\n'
|
||||
self.assertEqual(len(stdout), EXPANDED_STDOUT_BYTES + over)
|
||||
result = transport.handle_request(payload, runner=lambda *args: (0, stdout, b''))
|
||||
self.assertEqual(result['ok'], not over)
|
||||
if over:
|
||||
self.assertIn('SCREEN_RPC_OUTPUT_LIMIT', result['stdout'])
|
||||
|
||||
def test_main_serializes_competing_inputs_and_consumes_capture_once(self):
|
||||
request = base64.b64encode(rpc.encode({'op': 'key', 'capture_id': CAPTURE_ID, 'key': 'ENTER'})).decode()
|
||||
barrier = threading.Barrier(2)
|
||||
def invoke():
|
||||
barrier.wait(timeout=2)
|
||||
return rpc.main([request])
|
||||
with patch.object(rpc, 'state_directory', return_value=self.directory), \
|
||||
patch.object(rpc, 'act', return_value={'ok': True, 'performed': True}) as act, \
|
||||
patch.object(rpc, 'observe', return_value=dict(self.fixture.manifest)), \
|
||||
patch('builtins.print'), concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||
outcomes = list(pool.map(lambda _: invoke(), range(2)))
|
||||
self.assertEqual(sorted(outcomes), [0, 1])
|
||||
self.assertEqual(act.call_count, 1)
|
||||
self.assertTrue(rpc.load_capture(self.directory, CAPTURE_ID)['_action_consumed'])
|
||||
|
||||
def test_main_lock_covers_dispatch_in_competing_rpc_entries(self):
|
||||
first_entered, second_entered, release = threading.Event(), threading.Event(), threading.Event()
|
||||
count = 0
|
||||
mutex = threading.Lock()
|
||||
def dispatch(*args):
|
||||
nonlocal count
|
||||
with mutex:
|
||||
count += 1
|
||||
current = count
|
||||
if current == 1:
|
||||
first_entered.set()
|
||||
self.assertTrue(release.wait(2))
|
||||
else:
|
||||
second_entered.set()
|
||||
return {'ok': True, 'result': {}}
|
||||
request = base64.b64encode(rpc.encode({'op': 'status'})).decode()
|
||||
with patch.object(rpc, 'state_directory', return_value=self.directory), \
|
||||
patch.object(rpc, 'dispatch', side_effect=dispatch), patch('builtins.print'), \
|
||||
concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||
first = pool.submit(rpc.main, [request])
|
||||
try:
|
||||
self.assertTrue(first_entered.wait(2))
|
||||
second = pool.submit(rpc.main, [request])
|
||||
self.assertFalse(second_entered.wait(0.1))
|
||||
finally:
|
||||
release.set()
|
||||
self.assertEqual((first.result(timeout=2), second.result(timeout=2)), (0, 0))
|
||||
self.assertTrue(second_entered.is_set())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Owned real-console cleanup, daemon outcome and fail-closed evidence regressions."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "expanded"))
|
||||
os.environ["FRAMEBRIDGE_EXPANDED_PREFIX"] = str(ROOT)
|
||||
from framebridge_expanded import tty, tty_cleanup, tty_sudo_control as control
|
||||
|
||||
|
||||
class CleanupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="fb-cleanup-qa-", dir=Path("/run/user") / str(os.getuid()))
|
||||
self.state = Path(self.temp.name)
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.addCleanup(tty.stop, self.state)
|
||||
|
||||
def dead_daemon(self):
|
||||
child = subprocess.Popen([sys.executable, "-c", "import time;time.sleep(10)"])
|
||||
identity = tty._identity(child.pid)
|
||||
child.terminate()
|
||||
child.wait()
|
||||
return tty_cleanup.daemon_identity(identity)
|
||||
|
||||
def pidfile(self, daemon):
|
||||
tty._write_json(self.state / "tty-process.json", {key: daemon[key] for key in ("pid", "birth", "uid", "boot")})
|
||||
|
||||
def assert_gone(self, record):
|
||||
for pid, birth in ((record["pid"], record["birth"]), (record["server_pid"], record["server_birth"])):
|
||||
process = control._proc(pid)
|
||||
self.assertFalse(process and process["birth"] == birth and process["state"] not in {"Z", "X"})
|
||||
controller = control.SudoConsoleController(tty.runtime_path(self.state), timeout=5)
|
||||
self.assertEqual(controller._scope_members_existing(record), set())
|
||||
|
||||
def test_real_close_and_stop_verify_owned_process_death(self):
|
||||
self.assertTrue(tty.start(self.state)["ok"])
|
||||
for close_first in (True, False):
|
||||
opened = tty.request(self.state, {"authorized": True, "op": "open"})
|
||||
self.assertTrue(opened["ok"], opened)
|
||||
receipt = tty_cleanup.read(self.state)
|
||||
self.assertEqual(len(receipt["sessions"]), 1)
|
||||
record = receipt["sessions"][0]
|
||||
if close_first:
|
||||
closed = tty.request(self.state, {"authorized": True, "op": "close", "session_id": record["session_id"]})
|
||||
self.assertTrue(closed["ok"], closed)
|
||||
self.assertTrue(closed["result"]["cleanup_verified"])
|
||||
stopped = tty.stop(self.state)
|
||||
self.assertTrue(stopped["ok"], stopped)
|
||||
self.assertTrue(stopped["cleanup_verified"])
|
||||
self.assert_gone(record)
|
||||
self.assertTrue(tty.stop(self.state)["cleanup_verified"])
|
||||
if close_first:
|
||||
self.assertTrue(tty.start(self.state)["ok"])
|
||||
|
||||
def test_failed_shutdown_cannot_become_success_by_stop_or_restart(self):
|
||||
limits = tty.Limits()
|
||||
home = self.state / "home"; home.mkdir()
|
||||
manager = tty.PortableManager(self.state, limits, home)
|
||||
manager.cleanup_daemon = self.dead_daemon()
|
||||
manager._record_cleanup()
|
||||
self.addCleanup(manager.shutdown)
|
||||
manager.open()
|
||||
self.pidfile(manager.cleanup_daemon)
|
||||
with mock.patch.object(manager._sudo, "close", side_effect=control.SudoControlError("TTY_SUDO_SCOPE_STILL_RUNNING")):
|
||||
result = manager.shutdown()
|
||||
self.assertFalse(result["cleanup_verified"])
|
||||
before = (self.state / tty_cleanup.NAME).read_bytes()
|
||||
for _ in range(2):
|
||||
result = tty.stop(self.state)
|
||||
self.assertFalse(result["ok"], result)
|
||||
self.assertFalse(result["cleanup_verified"])
|
||||
self.assertEqual(result["error"], "TTY_CLEANUP_UNVERIFIED")
|
||||
self.assertEqual(tty.start(self.state)["error"], "TTY_CLEANUP_PENDING")
|
||||
self.assertEqual((self.state / tty_cleanup.NAME).read_bytes(), before)
|
||||
self.assertTrue(control._proc(tty_cleanup.read(self.state)["sessions"][0]["pid"]))
|
||||
self.assertTrue(manager.shutdown()["cleanup_verified"])
|
||||
self.assertTrue(tty.stop(self.state)["cleanup_verified"])
|
||||
|
||||
def test_missing_or_other_daemon_receipt_does_not_verify_stop(self):
|
||||
expected = self.dead_daemon(); self.pidfile(expected)
|
||||
self.assertFalse(tty.stop(self.state)["cleanup_verified"])
|
||||
other = self.dead_daemon()
|
||||
tty_cleanup.write(self.state, other, [], verified=True)
|
||||
self.assertFalse(tty.stop(self.state)["cleanup_verified"])
|
||||
|
||||
def test_untrusted_or_tampered_receipt_fails_closed(self):
|
||||
daemon = self.dead_daemon(); self.pidfile(daemon)
|
||||
path = self.state / tty_cleanup.NAME
|
||||
tty_cleanup.write(self.state, daemon, [], verified=True)
|
||||
path.chmod(0o644)
|
||||
self.assertFalse(tty.stop(self.state)["cleanup_verified"])
|
||||
path.chmod(0o600)
|
||||
value = json.loads(path.read_text()); value["daemon"]["uid"] += 1
|
||||
tty._write_json(path, value)
|
||||
self.assertFalse(tty.stop(self.state)["cleanup_verified"])
|
||||
value["daemon"] = daemon; value["state_dir"] += "-other"
|
||||
tty._write_json(path, value)
|
||||
self.assertFalse(tty.stop(self.state)["cleanup_verified"])
|
||||
path.unlink(); path.symlink_to(self.state / "tty-process.json")
|
||||
self.assertFalse(tty.stop(self.state)["cleanup_verified"])
|
||||
self.assertEqual(tty.start(self.state)["error"], "TTY_CLEANUP_PENDING")
|
||||
|
||||
def test_inflight_operation_receipt_is_preserved_on_restart(self):
|
||||
daemon = self.dead_daemon()
|
||||
tty_cleanup.write(self.state, daemon, [], operation_pending=True)
|
||||
before = (self.state / tty_cleanup.NAME).read_bytes()
|
||||
self.assertEqual(tty.start(self.state)["error"], "TTY_CLEANUP_PENDING")
|
||||
self.assertEqual((self.state / tty_cleanup.NAME).read_bytes(), before)
|
||||
|
||||
def test_pending_receipt_allows_restart_only_after_natural_identity_death(self):
|
||||
daemon = self.dead_daemon()
|
||||
child = subprocess.Popen([sys.executable, "-c", "import time;time.sleep(10)"])
|
||||
identity = tty._identity(child.pid)
|
||||
sid = uuid.uuid4().hex
|
||||
unit = "framebridge-tty-" + "a" * 12 + "-" + sid + "-" + "b" * 8 + ".service"
|
||||
record = {"session_id": sid, "unit_name": unit, "pid": child.pid, "birth": identity["birth"],
|
||||
"server_pid": daemon["pid"], "server_birth": daemon["birth"],
|
||||
"control_group": "/absent-fb-cleanup-qa/" + unit,
|
||||
"pane_control_group": "/absent-fb-cleanup-qa/" + unit}
|
||||
tty_cleanup.write(self.state, daemon, [record], errors=[{"error": "TTY_SUDO_SCOPE_STILL_RUNNING"}])
|
||||
try:
|
||||
self.assertEqual(tty.start(self.state)["error"], "TTY_CLEANUP_PENDING")
|
||||
finally:
|
||||
child.terminate(); child.wait()
|
||||
self.assertTrue(tty.start(self.state)["ok"])
|
||||
self.assertTrue(tty.stop(self.state)["cleanup_verified"])
|
||||
|
||||
def test_prelaunch_invalid_arguments_do_not_poison_profile(self):
|
||||
home = self.state / "home"; home.mkdir()
|
||||
manager = tty.PortableManager(self.state, tty.Limits(), home)
|
||||
manager.cleanup_daemon = self.dead_daemon()
|
||||
manager._record_cleanup()
|
||||
self.addCleanup(manager.shutdown)
|
||||
with mock.patch.object(manager._sudo, "_run") as run:
|
||||
with self.assertRaises(control.SudoControlError):
|
||||
manager._sudo.launch(uuid.uuid4().hex, cwd=self.state / "absent")
|
||||
with self.assertRaises(tty.TerminalDenied):
|
||||
manager.open(rows=0)
|
||||
run.assert_not_called()
|
||||
self.assertFalse(manager._cleanup_uncertain())
|
||||
self.assertTrue(manager.shutdown()["cleanup_verified"])
|
||||
self.assertTrue(tty.start(self.state)["ok"])
|
||||
self.assertTrue(tty.stop(self.state)["cleanup_verified"])
|
||||
|
||||
def test_previous_boot_receipts_recover_without_signaling_old_pid_numbers(self):
|
||||
daemon = tty_cleanup.daemon_identity(tty._identity(os.getpid()))
|
||||
daemon["boot"] = str(uuid.uuid4())
|
||||
for verified, pending in ((True, False), (False, False), (False, True)):
|
||||
with self.subTest(verified=verified, pending=pending):
|
||||
tty_cleanup.write(self.state, daemon, [], verified=verified, operation_pending=pending)
|
||||
self.pidfile(daemon)
|
||||
with mock.patch.object(os, "pidfd_open") as opening:
|
||||
self.assertTrue(tty.stop(self.state)["cleanup_verified"])
|
||||
opening.assert_not_called()
|
||||
self.assertTrue(tty.start(self.state)["ok"])
|
||||
self.assertTrue(tty.stop(self.state)["cleanup_verified"])
|
||||
|
||||
def test_verified_launch_rollback_does_not_poison_profile(self):
|
||||
home = self.state / "home"; home.mkdir()
|
||||
manager = tty.PortableManager(self.state, tty.Limits(), home)
|
||||
manager.cleanup_daemon = self.dead_daemon(); manager._record_cleanup()
|
||||
self.addCleanup(manager.shutdown)
|
||||
original = manager._sudo._inspect
|
||||
injected = False
|
||||
def mismatch(record):
|
||||
nonlocal injected
|
||||
current = original(record)
|
||||
if not injected:
|
||||
injected = True
|
||||
return {**current, "no_new_privs": 0}
|
||||
return current
|
||||
with mock.patch.object(manager._sudo, "_inspect", side_effect=mismatch):
|
||||
with self.assertRaises(control.SudoControlError) as error:
|
||||
manager.open()
|
||||
self.assertEqual(error.exception.code, "TTY_SUDO_PRIVILEGE_STATE_MISMATCH")
|
||||
self.assertFalse(manager._cleanup_uncertain())
|
||||
self.assertTrue(manager.shutdown()["cleanup_verified"])
|
||||
self.assertTrue(manager.open()["session_id"])
|
||||
self.assertTrue(manager.shutdown()["cleanup_verified"])
|
||||
|
||||
def test_partial_launch_and_failed_rollback_remain_unverified(self):
|
||||
home = self.state / "home"; home.mkdir()
|
||||
manager = tty.PortableManager(self.state, tty.Limits(), home)
|
||||
manager.cleanup_daemon = self.dead_daemon()
|
||||
manager._record_cleanup(); self.pidfile(manager.cleanup_daemon)
|
||||
original = manager._sudo._run
|
||||
def failed(command, **kwargs):
|
||||
if command[0] == manager._sudo.systemd_run:
|
||||
original(command, **kwargs)
|
||||
raise control.SudoControlError("TTY_SUDO_BACKEND_TIMEOUT")
|
||||
if command[0] == manager._sudo.systemctl and "stop" in command:
|
||||
raise control.SudoControlError("TTY_SUDO_BACKEND_TIMEOUT")
|
||||
return original(command, **kwargs)
|
||||
try:
|
||||
with mock.patch.object(manager._sudo, "_run", side_effect=failed):
|
||||
with self.assertRaises(control.SudoControlError):
|
||||
manager.open()
|
||||
self.assertTrue(manager._sudo._uncertain_launches)
|
||||
self.assertFalse(manager.shutdown()["cleanup_verified"])
|
||||
self.assertTrue(tty_cleanup.read(self.state)["operation_pending"])
|
||||
self.assertFalse(tty.stop(self.state)["cleanup_verified"])
|
||||
self.assertEqual(tty.start(self.state)["error"], "TTY_CLEANUP_PENDING")
|
||||
finally:
|
||||
for unit in manager._sudo._uncertain_launches:
|
||||
manager._sudo._unit(unit) # Narrow ownership validation first.
|
||||
original([manager._sudo.systemctl, "--user", "stop", unit], optional=True)
|
||||
|
||||
def test_real_close_kills_hup_and_term_ignoring_owned_job(self):
|
||||
controller = control.SudoConsoleController(tty.runtime_path(self.state), timeout=5)
|
||||
record = controller.launch(uuid.uuid4().hex, cwd=self.state)
|
||||
self.addCleanup(lambda: controller.close(record) if record["session_id"] in controller._records else None)
|
||||
marker = self.state / "child.pid"
|
||||
script = "import os,signal,time;signal.signal(signal.SIGHUP,signal.SIG_IGN);signal.signal(signal.SIGTERM,signal.SIG_IGN);open(" + repr(str(marker)) + ",'w').write(str(os.getpid()));time.sleep(30)"
|
||||
import shlex
|
||||
command = "nohup " + shlex.quote(sys.executable) + " -c " + shlex.quote(script) + " >/dev/null 2>&1 & disown"
|
||||
controller._tmux(record, "send-keys", "-t", record["pane"], "-l", command)
|
||||
controller._tmux(record, "send-keys", "-t", record["pane"], "Enter")
|
||||
deadline = time.monotonic() + 3
|
||||
while not marker.exists() and time.monotonic() < deadline:
|
||||
time.sleep(0.03)
|
||||
self.assertTrue(marker.exists())
|
||||
child = control._proc(int(marker.read_text()))
|
||||
calls = []
|
||||
original = controller._run
|
||||
def run(command, **kwargs):
|
||||
calls.append(command)
|
||||
return original(command, **kwargs)
|
||||
started = time.monotonic()
|
||||
with mock.patch.object(controller, "_run", side_effect=run):
|
||||
self.assertTrue(controller.close(record)["closed"])
|
||||
self.assertLess(time.monotonic() - started, 5)
|
||||
killed = control._proc(child["pid"])
|
||||
self.assertFalse(killed and killed["birth"] == child["birth"] and killed["state"] not in {"Z", "X"})
|
||||
self.assert_gone(record)
|
||||
kill_index = next(i for i, call in enumerate(calls) if "kill-session" in call)
|
||||
stops = [(i, call) for i, call in enumerate(calls) if "stop" in call]
|
||||
self.assertTrue(stops)
|
||||
self.assertTrue(all(i > kill_index and "--no-block" in call for i, call in stops))
|
||||
|
||||
|
||||
class PidfdGuardTests(unittest.TestCase):
|
||||
def test_reused_pid_after_pidfd_open_is_not_signaled(self):
|
||||
controller = control.SudoConsoleController.__new__(control.SudoConsoleController)
|
||||
controller.uid = os.getuid()
|
||||
controller.timeout = 0.06
|
||||
controller.systemctl = "systemctl"
|
||||
record = {"session_id": "a" * 32, "unit_name": "owned.service", "name": "private-session",
|
||||
"pid": 123456, "birth": "1", "server_pid": 123457, "server_birth": "2",
|
||||
"control_group": "/private/owned.service", "pane_control_group": "/private/own.scope",
|
||||
"pane_scope_unit": "own.scope", "busy_reason": None}
|
||||
member = {"pid": 123458, "birth": "3", "uids": (os.getuid(),) * 4, "state": "S"}
|
||||
opened = False
|
||||
def pidfd_open(pid):
|
||||
nonlocal opened
|
||||
self.assertEqual(pid, member["pid"])
|
||||
opened = True
|
||||
return 987
|
||||
def process(pid):
|
||||
if pid == member["pid"]:
|
||||
return {**member, "birth": "4" if opened else "3"}
|
||||
return None
|
||||
with (mock.patch.object(controller, "_inspect", return_value=record),
|
||||
mock.patch.object(controller, "_tmux"),
|
||||
mock.patch.object(controller, "_run"),
|
||||
mock.patch.object(controller, "_close_members", return_value={member["pid"]: member}),
|
||||
mock.patch.object(controller, "_terminated", return_value=record),
|
||||
mock.patch.object(controller, "_pane_group") as group,
|
||||
mock.patch.object(control, "_proc", side_effect=process),
|
||||
mock.patch.object(os, "pidfd_open", side_effect=pidfd_open),
|
||||
mock.patch.object(os, "close") as closing,
|
||||
mock.patch.object(signal, "pidfd_send_signal") as sending):
|
||||
with self.assertRaises(control.SudoControlError):
|
||||
controller._close(record)
|
||||
self.assertTrue(opened)
|
||||
sending.assert_not_called()
|
||||
group.assert_not_called()
|
||||
closing.assert_called_once_with(987)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -254,8 +254,10 @@ class PortableSudoLimitsTests(SudoControlTests):
|
||||
self.assertEqual(error.exception.code, "TTY_SUDO_SIZE_INVALID")
|
||||
|
||||
def test_actual_nnp_mismatch_rolls_back_candidate(self):
|
||||
with mock.patch.object(self.controller, "_inspect",
|
||||
return_value={"no_new_privs": 0, "busy": False}):
|
||||
original = self.controller._inspect
|
||||
def mismatch(record):
|
||||
return {**original(record), "no_new_privs": 0}
|
||||
with mock.patch.object(self.controller, "_inspect", side_effect=mismatch):
|
||||
with self.assertRaises(control.SudoControlError) as error:
|
||||
self.controller.launch(uuid.uuid4().hex, mode="off")
|
||||
self.assertEqual(error.exception.code, "TTY_SUDO_PRIVILEGE_STATE_MISMATCH")
|
||||
|
||||
@@ -81,7 +81,10 @@ class UninstallTests(unittest.TestCase):
|
||||
def test_all_users_drops_privileges_and_includes_service_accounts(self):
|
||||
accounts = [SimpleNamespace(pw_name="testservice", pw_uid=104, pw_dir="/var/lib/testservice"), SimpleNamespace(pw_name="testperson", pw_uid=1000, pw_dir="/home/testperson"), SimpleNamespace(pw_name="nobody", pw_uid=65534, pw_dir="/nonexistent")]
|
||||
run = mock.Mock(return_value=SimpleNamespace(returncode=0))
|
||||
with mock.patch.object(uninstall.os, "getuid", return_value=0):uninstall.cleanup_all_users(run=run, accounts=accounts)
|
||||
with mock.patch.object(uninstall.os, "getuid", return_value=0), \
|
||||
mock.patch.object(uninstall, "purge_credential_helpers") as purge:
|
||||
uninstall.cleanup_all_users(run=run, accounts=accounts)
|
||||
purge.assert_called_once_with(run=run)
|
||||
self.assertEqual(run.call_count, 2)
|
||||
for call, name in zip(run.call_args_list, ["testservice", "testperson"]):
|
||||
args = call.args[0]
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""XKB action/behavior validation with bounded in-memory public ABI tables."""
|
||||
import ctypes as C
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
try:
|
||||
from framebridge_expanded import x11_text as subject
|
||||
except ModuleNotFoundError as error:
|
||||
if error.name != "framebridge_expanded":
|
||||
raise
|
||||
import x11_text as subject
|
||||
from test_x11_text import KeyboardFixture
|
||||
|
||||
|
||||
NO_ACTION = bytes(8)
|
||||
SHIFT_ACTION = bytes([1, 0, 1, 1, 0, 0, 0, 0])
|
||||
SLASH = subject.Stroke(61, (), 0, ord("/"))
|
||||
UPPERCASE_A = subject.Stroke(38, (50,), 1, ord("A"))
|
||||
|
||||
|
||||
class ActionTableFixture:
|
||||
def __init__(self):
|
||||
self.symmap = (subject.SymMap * 256)()
|
||||
self.keyacts = (C.c_ushort * 256)()
|
||||
self.actions = ((C.c_ubyte * 8) * 8)()
|
||||
self.behaviors = (C.c_ubyte * 512)()
|
||||
self.symmap[50].group_info = 1
|
||||
self.symmap[50].width = 1
|
||||
self.keyacts[50] = 1
|
||||
self.actions[1][:] = SHIFT_ACTION
|
||||
self.symmap[61].group_info = 1
|
||||
self.symmap[61].width = 1
|
||||
self.keyacts[61] = 2
|
||||
self.actions[2][:] = NO_ACTION
|
||||
self.server = subject.ServerMapPrefix(3, 8, self.actions,
|
||||
C.addressof(self.behaviors), self.keyacts)
|
||||
self.client = subject.ClientMapPrefix()
|
||||
self.client.key_sym_map = self.symmap
|
||||
self.keyboard = subject.KeyboardPrefix()
|
||||
self.keyboard.min_key_code = 8
|
||||
self.keyboard.max_key_code = 61
|
||||
self.keyboard.server = C.pointer(self.server)
|
||||
self.keyboard.map = C.pointer(self.client)
|
||||
self.pointer = C.pointer(self.keyboard)
|
||||
self.backend = object.__new__(subject.NativeBackend)
|
||||
self.backend.display = 1
|
||||
self.backend.modifier_keys = {1: 50}
|
||||
self.backend.x11 = SimpleNamespace(XkbGetMap=Mock(return_value=self.pointer),
|
||||
XkbFreeKeyboard=Mock())
|
||||
|
||||
def assert_freed(self, case):
|
||||
self.backend.x11.XkbFreeKeyboard.assert_called_once_with(self.pointer, 0, True)
|
||||
|
||||
def validate_text_without_input(self, text):
|
||||
keyboard = KeyboardFixture()
|
||||
keyboard.validate_strokes = self.backend.validate_strokes
|
||||
subject.type_text(text, backend=keyboard, before_input=lambda: None,
|
||||
clock=lambda: 0, sleep=lambda _: None)
|
||||
return keyboard
|
||||
|
||||
|
||||
class ActionValidationTests(unittest.TestCase):
|
||||
def test_us_slash_and_shift_are_safe_and_read_tables_are_freed(self):
|
||||
fixture = ActionTableFixture()
|
||||
fixture.backend.validate_strokes((SLASH, UPPERCASE_A))
|
||||
fixture.backend.x11.XkbGetMap.assert_called_once_with(1, 2 | 16 | 32, 256)
|
||||
fixture.assert_freed(self)
|
||||
|
||||
def test_modifier_map_flag_is_safe_for_exact_shift_mask(self):
|
||||
subject.validate_actions({38: (NO_ACTION,), 50: (bytes([1, 4, 1, 1, 0, 0, 0, 0]),)},
|
||||
(UPPERCASE_A,), {1: 50})
|
||||
|
||||
def test_group_latch_lock_redirect_and_pointer_actions_reject_before_events(self):
|
||||
# XKB action types 2=LATCH_MODS, 3=LOCK_MODS, 4=SET_GROUP,
|
||||
# 5=LATCH_GROUP, 6=LOCK_GROUP, 7=MOVE_PTR, 17=REDIRECT_KEY.
|
||||
for action_type in (2, 3, 4, 5, 6, 7, 17):
|
||||
with self.subTest(action_type=action_type):
|
||||
fixture = ActionTableFixture()
|
||||
fixture.actions[2][0] = action_type
|
||||
keyboard = KeyboardFixture()
|
||||
keyboard.validate_strokes = fixture.backend.validate_strokes
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTION_UNSUPPORTED$"):
|
||||
subject.type_text("/", backend=keyboard, before_input=lambda: None,
|
||||
clock=lambda: 0, sleep=lambda _: None)
|
||||
self.assertEqual(keyboard.events, [])
|
||||
fixture.assert_freed(self)
|
||||
|
||||
def test_character_key_may_not_set_modifiers(self):
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTION_UNSUPPORTED$"):
|
||||
subject.validate_actions({61: (SHIFT_ACTION,)}, (SLASH,), {1: 50})
|
||||
|
||||
def test_modifier_action_requires_exact_mask_and_no_extra_effects(self):
|
||||
actions = [bytes([1, 0, 4, 4, 0, 0, 0, 0]),
|
||||
bytes([1, 2, 1, 1, 0, 0, 0, 0]),
|
||||
bytes([1, 0, 1, 1, 1, 0, 0, 0]),
|
||||
bytes([1, 0, 1, 1, 0, 1, 0, 0]), NO_ACTION]
|
||||
for action in actions:
|
||||
with self.subTest(action=action):
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTION_UNSUPPORTED$"):
|
||||
subject.validate_actions({38: (NO_ACTION,), 50: (action,)},
|
||||
(UPPERCASE_A,), {1: 50})
|
||||
|
||||
def test_clearlocks_flags_one_and_five_preserve_unrelated_numlock(self):
|
||||
for flags in (1, 5):
|
||||
with self.subTest(flags=flags):
|
||||
fixture = ActionTableFixture()
|
||||
fixture.actions[1][1] = flags
|
||||
keyboard = KeyboardFixture(locked=16)
|
||||
keyboard.validate_strokes = fixture.backend.validate_strokes
|
||||
subject.type_text("A/", backend=keyboard, before_input=lambda: None,
|
||||
clock=lambda: 0, sleep=lambda _: None)
|
||||
self.assertEqual("".join(keyboard.output), "A/")
|
||||
self.assertEqual(keyboard.initial.locked_mods, 16)
|
||||
self.assertNotIn(77, [code for code, _ in keyboard.events])
|
||||
|
||||
def test_clearlocks_cannot_clear_an_existing_lock_on_action_modifier(self):
|
||||
for flags in (1, 5):
|
||||
with self.subTest(flags=flags):
|
||||
fixture = ActionTableFixture()
|
||||
fixture.actions[1][1] = flags
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTION_WOULD_CLEAR_LOCK$"):
|
||||
fixture.backend.validate_strokes((UPPERCASE_A,), locked_mods=1)
|
||||
fixture.assert_freed(self)
|
||||
|
||||
def test_action_missing_or_wrong_width_fails_closed(self):
|
||||
for actions, error in [({}, "TYPE_KEY_ACTIONS_UNAVAILABLE"),
|
||||
({61: (bytes(7),)}, "TYPE_KEY_ACTIONS_INVALID")]:
|
||||
with self.subTest(actions=actions):
|
||||
with self.assertRaisesRegex(subject.TextError, "^" + error + "$"):
|
||||
subject.validate_actions(actions, (SLASH,), {1: 50})
|
||||
|
||||
def test_all_groups_and_levels_are_checked_not_just_first_action(self):
|
||||
fixture = ActionTableFixture()
|
||||
fixture.symmap[61].group_info = 2
|
||||
fixture.symmap[61].width = 2
|
||||
fixture.server.num_acts = 6
|
||||
fixture.actions[5][0] = 6 # Unsafe fourth entry, reached by bounded read.
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTION_UNSUPPORTED$"):
|
||||
fixture.backend.validate_strokes((SLASH,))
|
||||
fixture.assert_freed(self)
|
||||
|
||||
def test_planned_character_or_modifier_nondefault_behavior_is_rejected(self):
|
||||
for code in (38, 50, 61):
|
||||
for behavior_byte in (0, 1):
|
||||
with self.subTest(code=code, behavior_byte=behavior_byte):
|
||||
fixture = ActionTableFixture()
|
||||
fixture.behaviors[2 * code + behavior_byte] = 1
|
||||
keyboard = KeyboardFixture()
|
||||
keyboard.validate_strokes = fixture.backend.validate_strokes
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_BEHAVIOR_UNSUPPORTED$"):
|
||||
subject.type_text("A/", backend=keyboard, before_input=lambda: None,
|
||||
clock=lambda: 0, sleep=lambda _: None)
|
||||
self.assertEqual(keyboard.events, [])
|
||||
fixture.assert_freed(self)
|
||||
|
||||
def test_unplanned_behavior_does_not_reject_safe_plan(self):
|
||||
fixture = ActionTableFixture()
|
||||
fixture.behaviors[2 * 40] = 1
|
||||
fixture.backend.validate_strokes((SLASH,))
|
||||
fixture.assert_freed(self)
|
||||
|
||||
def test_zero_action_offsets_and_missing_optional_tables_default_to_no_action(self):
|
||||
fixture = ActionTableFixture()
|
||||
fixture.server.key_acts = C.POINTER(C.c_ushort)()
|
||||
fixture.server.acts = C.POINTER(C.c_ubyte * 8)()
|
||||
fixture.server.num_acts = 0
|
||||
fixture.server.behaviors = None
|
||||
actions = fixture.backend._actions()
|
||||
self.assertEqual(actions[61], (NO_ACTION,))
|
||||
self.assertEqual(fixture.backend.behaviors[61], bytes(2))
|
||||
subject.validate_actions(actions, (SLASH,), {1: 50})
|
||||
fixture.assert_freed(self)
|
||||
|
||||
def test_bad_action_offset_or_span_is_rejected_and_freed(self):
|
||||
cases = [(3, 1, 1), (2, 2, 1), (1, 0, 1), (65535, 1, 1)]
|
||||
for offset, width, groups in cases:
|
||||
with self.subTest(offset=offset, width=width, groups=groups):
|
||||
fixture = ActionTableFixture()
|
||||
fixture.keyacts[61] = offset
|
||||
fixture.symmap[61].width = width
|
||||
fixture.symmap[61].group_info = groups
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTIONS_INVALID$"):
|
||||
fixture.backend._actions()
|
||||
fixture.assert_freed(self)
|
||||
|
||||
def test_invalid_dimensions_are_rejected_before_table_read_and_freed(self):
|
||||
for attribute, value in [("group_info", 5), ("width", 33)]:
|
||||
with self.subTest(attribute=attribute):
|
||||
fixture = ActionTableFixture()
|
||||
setattr(fixture.symmap[61], attribute, value)
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTIONS_INVALID$"):
|
||||
fixture.backend._actions()
|
||||
fixture.assert_freed(self)
|
||||
|
||||
def test_invalid_keyboard_range_or_required_pointer_is_rejected_and_freed(self):
|
||||
for fault in ("range", "server", "client", "symmap", "actions", "count"):
|
||||
with self.subTest(fault=fault):
|
||||
fixture = ActionTableFixture()
|
||||
if fault == "range":
|
||||
fixture.keyboard.min_key_code = 7
|
||||
elif fault == "server":
|
||||
fixture.keyboard.server = C.POINTER(subject.ServerMapPrefix)()
|
||||
elif fault == "client":
|
||||
fixture.keyboard.map = C.POINTER(subject.ClientMapPrefix)()
|
||||
elif fault == "symmap":
|
||||
fixture.client.key_sym_map = C.POINTER(subject.SymMap)()
|
||||
elif fault == "actions":
|
||||
fixture.server.acts = C.POINTER(C.c_ubyte * 8)()
|
||||
else:
|
||||
fixture.server.num_acts = 9
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTIONS_INVALID$"):
|
||||
fixture.backend._actions()
|
||||
fixture.assert_freed(self)
|
||||
|
||||
def test_null_keyboard_pointer_reports_unavailable_without_freeing_null(self):
|
||||
fixture = ActionTableFixture()
|
||||
fixture.backend.x11.XkbGetMap.return_value = C.POINTER(subject.KeyboardPrefix)()
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTIONS_UNAVAILABLE$"):
|
||||
fixture.backend._actions()
|
||||
fixture.backend.x11.XkbFreeKeyboard.assert_not_called()
|
||||
|
||||
def test_public_xkb_prefix_abi_offsets(self):
|
||||
self.assertEqual(C.sizeof(subject.SymMap), 8)
|
||||
self.assertEqual(subject.SymMap.offset.offset, 6)
|
||||
self.assertEqual(subject.ClientMapPrefix.key_sym_map.offset, 32)
|
||||
self.assertEqual(subject.ServerMapPrefix.acts.offset, 8)
|
||||
self.assertEqual(subject.ServerMapPrefix.behaviors.offset, 16)
|
||||
self.assertEqual(subject.ServerMapPrefix.key_acts.offset, 24)
|
||||
self.assertEqual(subject.KeyboardPrefix.server.offset, 24)
|
||||
self.assertEqual(subject.KeyboardPrefix.map.offset, 32)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,360 @@
|
||||
"""Behavioral X11 text regressions; no display, libraries, or input devices.
|
||||
|
||||
The fixture is an independent US physical-key model. It deliberately places
|
||||
the earlier LATAM slash before the US slash, matching the ASUS failure class.
|
||||
"""
|
||||
import ast
|
||||
import ctypes as C
|
||||
from dataclasses import replace
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
import textwrap
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
try:
|
||||
from framebridge_expanded import x11_text as subject
|
||||
except ModuleNotFoundError as error:
|
||||
if error.name != "framebridge_expanded":
|
||||
raise
|
||||
import x11_text as subject
|
||||
|
||||
|
||||
SHIFT, CAPS, NUM = 1, 2, 16
|
||||
|
||||
|
||||
class KeyboardFixture:
|
||||
def __init__(self, *, held=(), locked=0):
|
||||
# Physical X11 evdev codes and the US unshifted/shifted characters.
|
||||
rows = [
|
||||
(10, "1", "!"), (11, "2", "@"), (12, "3", "#"),
|
||||
(13, "4", "$"), (14, "5", "%"), (15, "6", "^"),
|
||||
(16, "7", "&"), (17, "8", "*"), (18, "9", "("),
|
||||
(19, "0", ")"), (20, "-", "_"), (21, "=", "+"),
|
||||
(34, "[", "{"), (35, "]", "}"), (47, ";", ":"),
|
||||
(48, "'", '"'), (49, "`", "~"), (51, "\\", "|"),
|
||||
(59, ",", "<"), (60, ".", ">"), (61, "/", "?"),
|
||||
(65, " ", " "),
|
||||
]
|
||||
for start, letters in [(24, "qwertyuiop"), (38, "asdfghjkl"), (52, "zxcvbnm")]:
|
||||
rows.extend((start + index, letter, letter.upper())
|
||||
for index, letter in enumerate(letters))
|
||||
self.physical = {code: (lower, upper) for code, lower, upper in rows}
|
||||
self.modifier_keys = {SHIFT: 50}
|
||||
mappings = [subject.Mapping(16, 1, ord("/"), SHIFT, ord("/"))]
|
||||
for code, lower, upper in rows:
|
||||
mappings.append(subject.Mapping(code, 0, ord(lower), 0, ord(lower)))
|
||||
if lower != upper:
|
||||
mappings.append(subject.Mapping(code, 0, ord(upper), SHIFT, ord(upper)))
|
||||
self.mappings = tuple(mappings)
|
||||
self.pressed = set(held)
|
||||
self.initial = subject.Snapshot(0, 0, 0, 0, locked, 0, 0, locked,
|
||||
101, 1920, 1080, "fixture-map-v1", tuple(sorted(held)))
|
||||
self.events = []
|
||||
self.output = []
|
||||
self.snapshot_calls = 0
|
||||
self.sync_calls = 0
|
||||
self.sleeps = 0
|
||||
self.on_snapshot = None
|
||||
self.on_sleep = None
|
||||
self.fail_events = {}
|
||||
self.closed = False
|
||||
|
||||
def lookup(self, code, mask, group):
|
||||
if group == 1 and code == 16:
|
||||
return ord("/") if mask & SHIFT else ord("7")
|
||||
if group != 0 or code not in self.physical:
|
||||
return 0
|
||||
lower, upper = self.physical[code]
|
||||
shifted = bool(mask & SHIFT)
|
||||
if lower.isalpha():
|
||||
shifted ^= bool(mask & CAPS)
|
||||
return ord(upper if shifted else lower)
|
||||
|
||||
def snapshot(self):
|
||||
self.snapshot_calls += 1
|
||||
mods = self.initial.locked_mods
|
||||
if self.pressed & {50, 62}:
|
||||
mods |= SHIFT
|
||||
if 37 in self.pressed:
|
||||
mods |= 4
|
||||
value = replace(self.initial, mods=mods,
|
||||
base_mods=mods & ~self.initial.locked_mods,
|
||||
held=tuple(sorted(self.pressed & {37, 50, 62})))
|
||||
return self.on_snapshot(value, self.snapshot_calls) if self.on_snapshot else value
|
||||
|
||||
def event(self, code, pressed):
|
||||
self.events.append((code, pressed))
|
||||
if pressed:
|
||||
self.pressed.add(code)
|
||||
else:
|
||||
self.pressed.discard(code)
|
||||
if pressed and code in self.physical:
|
||||
mask = self.initial.locked_mods | (SHIFT if self.pressed & {50, 62} else 0)
|
||||
value = self.lookup(code, mask, self.initial.group)
|
||||
self.output.append(chr(value) if value else "\ufffd")
|
||||
error = self.fail_events.get(len(self.events))
|
||||
if error:
|
||||
raise error
|
||||
|
||||
def sync(self):
|
||||
self.sync_calls += 1
|
||||
|
||||
def sleep(self, duration):
|
||||
self.sleeps += 1
|
||||
if self.on_sleep:
|
||||
self.on_sleep(self.sleeps)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class TextBehaviorTests(unittest.TestCase):
|
||||
def run_text(self, text, keyboard=None, guard=None, **kwargs):
|
||||
keyboard = keyboard or KeyboardFixture()
|
||||
subject.type_text(text, backend=keyboard, before_input=guard or (lambda: None),
|
||||
clock=lambda: 0, sleep=keyboard.sleep, **kwargs)
|
||||
return keyboard
|
||||
|
||||
def assert_error(self, code, operation):
|
||||
with self.assertRaises(subject.TextError) as caught:
|
||||
operation()
|
||||
self.assertEqual(str(caught.exception), code)
|
||||
|
||||
def test_slash_uses_current_us_key_even_with_earlier_latam_entry(self):
|
||||
keyboard = self.run_text("/")
|
||||
self.assertEqual(keyboard.events, [(61, True), (61, False)])
|
||||
self.assertEqual("".join(keyboard.output), "/")
|
||||
|
||||
def test_original_shell_looking_text_is_only_exact_data(self):
|
||||
text = "exec tmux -S /run/user/1000/fbe-tty-probe/sudo-consoles/sudo/s-c451.sock attach-session -t fb-real-f86"
|
||||
keyboard = self.run_text(text)
|
||||
self.assertEqual("".join(keyboard.output), text)
|
||||
self.assertEqual(sum(event == (61, True) for event in keyboard.events), text.count("/"))
|
||||
|
||||
def test_unsupported_last_character_does_not_even_release_held_modifiers(self):
|
||||
keyboard = KeyboardFixture(held=(37, 50))
|
||||
self.assert_error("TYPE_TEXT_UNREPRESENTABLE_IN_CURRENT_GROUP",
|
||||
lambda: self.run_text("already representable/\U0001f642", keyboard))
|
||||
self.assertEqual(keyboard.events, [])
|
||||
self.assertEqual(keyboard.pressed, {37, 50})
|
||||
|
||||
def test_nonzero_group_or_group_latch_is_rejected_before_events(self):
|
||||
cases = [dict(group=1, locked_group=1), dict(group=2, locked_group=2),
|
||||
dict(base_group=1), dict(latched_group=1), dict(locked_group=1)]
|
||||
for changed in cases:
|
||||
with self.subTest(changed=changed):
|
||||
keyboard = KeyboardFixture(held=(50,))
|
||||
keyboard.initial = replace(keyboard.initial, **changed)
|
||||
self.assert_error("TYPE_KEYBOARD_GROUP_UNSUPPORTED", lambda: self.run_text("a", keyboard))
|
||||
self.assertEqual(keyboard.events, [])
|
||||
|
||||
def test_latched_modifier_rejected_without_consuming_it(self):
|
||||
keyboard = KeyboardFixture()
|
||||
keyboard.initial = replace(keyboard.initial, latched_mods=SHIFT)
|
||||
self.assert_error("TYPE_LATCHED_MODIFIERS_UNSUPPORTED", lambda: self.run_text("A", keyboard))
|
||||
self.assertEqual(keyboard.events, [])
|
||||
|
||||
def test_locked_control_alt_or_super_rejected_before_any_event(self):
|
||||
for mask in (4, 8, 64):
|
||||
with self.subTest(mask=mask):
|
||||
keyboard = KeyboardFixture(locked=mask)
|
||||
self.assert_error("TYPE_LOCKED_MODIFIERS_UNSUPPORTED",
|
||||
lambda: self.run_text("a/", keyboard))
|
||||
self.assertEqual(keyboard.events, [])
|
||||
self.assertEqual(keyboard.initial.locked_mods, mask)
|
||||
|
||||
def test_mid_text_focus_change_stops_after_prefix_without_replay(self):
|
||||
keyboard = KeyboardFixture()
|
||||
keyboard.on_sleep = lambda count: setattr(keyboard, "initial", replace(keyboard.initial, window=999))
|
||||
self.assert_error("TYPE_ACTIVE_WINDOW_CHANGED", lambda: self.run_text("abc", keyboard))
|
||||
self.assertEqual(keyboard.events, [(38, True), (38, False)])
|
||||
self.assertEqual("".join(keyboard.output), "a")
|
||||
|
||||
def test_mid_text_group_change_stops_and_marks_restoration_uncertain(self):
|
||||
keyboard = KeyboardFixture()
|
||||
keyboard.on_sleep = lambda count: setattr(keyboard, "initial", replace(keyboard.initial, group=1))
|
||||
self.assert_error("TYPE_MODIFIER_RESTORE_UNCERTAIN", lambda: self.run_text("ab", keyboard))
|
||||
self.assertEqual(keyboard.events, [(38, True), (38, False)])
|
||||
|
||||
def test_mid_text_keymap_change_stops_after_prefix(self):
|
||||
keyboard = KeyboardFixture()
|
||||
keyboard.on_sleep = lambda count: setattr(keyboard, "initial", replace(keyboard.initial, keymap="changed"))
|
||||
self.assert_error("TYPE_KEYMAP_CHANGED", lambda: self.run_text("ab", keyboard))
|
||||
self.assertEqual("".join(keyboard.output), "a")
|
||||
self.assertEqual(keyboard.events, [(38, True), (38, False)])
|
||||
|
||||
def test_mid_text_geometry_change_stops_after_prefix(self):
|
||||
keyboard = KeyboardFixture()
|
||||
keyboard.on_sleep = lambda count: setattr(keyboard, "initial", replace(keyboard.initial, width=800))
|
||||
self.assert_error("TYPE_GEOMETRY_CHANGED", lambda: self.run_text("ab", keyboard))
|
||||
self.assertEqual("".join(keyboard.output), "a")
|
||||
|
||||
def test_guard_failure_before_first_mutation_has_zero_events(self):
|
||||
keyboard = KeyboardFixture(held=(37,))
|
||||
guard = Mock(side_effect=subject.TextError("AUTHORITY_EXPIRED"))
|
||||
self.assert_error("AUTHORITY_EXPIRED", lambda: self.run_text("a", keyboard, guard))
|
||||
self.assertEqual(keyboard.events, [])
|
||||
guard.assert_called_once_with()
|
||||
|
||||
def test_guard_failure_mid_text_retains_prefix_without_replay(self):
|
||||
keyboard = KeyboardFixture()
|
||||
guard = Mock(side_effect=[None, subject.TextError("AUTHORITY_EXPIRED")])
|
||||
self.assert_error("AUTHORITY_EXPIRED", lambda: self.run_text("ab", keyboard, guard))
|
||||
self.assertEqual(keyboard.events, [(38, True), (38, False)])
|
||||
self.assertEqual("".join(keyboard.output), "a")
|
||||
|
||||
def test_focus_change_during_permission_wait_emits_zero_events(self):
|
||||
keyboard = KeyboardFixture()
|
||||
|
||||
def guard():
|
||||
keyboard.initial = replace(keyboard.initial, window=999)
|
||||
|
||||
permission = Mock(side_effect=guard)
|
||||
self.assert_error("TYPE_ACTIVE_WINDOW_CHANGED", lambda: self.run_text("/", keyboard, permission))
|
||||
permission.assert_called_once_with()
|
||||
self.assertEqual(keyboard.events, [])
|
||||
|
||||
def test_keymap_change_during_permission_wait_emits_zero_events(self):
|
||||
keyboard = KeyboardFixture()
|
||||
|
||||
def guard():
|
||||
keyboard.initial = replace(keyboard.initial, keymap="changed-during-ack")
|
||||
|
||||
permission = Mock(side_effect=guard)
|
||||
self.assert_error("TYPE_KEYMAP_CHANGED", lambda: self.run_text("/", keyboard, permission))
|
||||
permission.assert_called_once_with()
|
||||
self.assertEqual(keyboard.events, [])
|
||||
|
||||
def test_failed_keydown_is_released_and_held_modifiers_restored(self):
|
||||
keyboard = KeyboardFixture(held=(37,))
|
||||
keyboard.fail_events[3] = subject.TextError("EVENT_FAILED")
|
||||
self.assert_error("EVENT_FAILED", lambda: self.run_text("A", keyboard))
|
||||
self.assertEqual(keyboard.events, [(37, False), (50, True), (38, True),
|
||||
(38, False), (50, False), (37, True)])
|
||||
self.assertEqual(keyboard.pressed, {37})
|
||||
|
||||
def test_cleanup_continues_after_one_cleanup_event_fails(self):
|
||||
keyboard = KeyboardFixture(held=(37, 62))
|
||||
keyboard.fail_events[4] = subject.TextError("KEYDOWN_FAILED")
|
||||
keyboard.fail_events[5] = subject.TextError("RELEASE_FAILED")
|
||||
self.assert_error("TYPE_MODIFIER_RESTORE_UNCERTAIN", lambda: self.run_text("A", keyboard))
|
||||
self.assertEqual(keyboard.events[-4:], [(38, False), (50, False), (37, True), (62, True)])
|
||||
self.assertEqual(keyboard.pressed, {37, 62})
|
||||
|
||||
def test_partial_modifier_clear_is_restored_after_event_failure(self):
|
||||
keyboard = KeyboardFixture(held=(37, 50))
|
||||
keyboard.fail_events[1] = subject.TextError("CLEAR_FAILED")
|
||||
self.assert_error("CLEAR_FAILED", lambda: self.run_text("a", keyboard))
|
||||
self.assertEqual(keyboard.events, [(37, False), (37, True)])
|
||||
self.assertEqual(keyboard.pressed, {37, 50})
|
||||
|
||||
def test_caps_lock_uses_lookup_result_without_toggling_lock(self):
|
||||
keyboard = self.run_text("aA/", KeyboardFixture(locked=CAPS))
|
||||
self.assertEqual("".join(keyboard.output), "aA/")
|
||||
self.assertEqual(keyboard.events, [(50, True), (38, True), (38, False),
|
||||
(50, False), (38, True), (38, False),
|
||||
(61, True), (61, False)])
|
||||
self.assertEqual(keyboard.initial.locked_mods, CAPS)
|
||||
|
||||
def test_num_lock_is_preserved_and_does_not_add_numlock_events(self):
|
||||
keyboard = self.run_text("1/A", KeyboardFixture(locked=NUM))
|
||||
self.assertEqual("".join(keyboard.output), "1/A")
|
||||
self.assertNotIn(77, [code for code, _ in keyboard.events])
|
||||
self.assertEqual(keyboard.initial.locked_mods, NUM)
|
||||
|
||||
def test_held_modifiers_clear_once_and_restore_after_success(self):
|
||||
keyboard = self.run_text("a", KeyboardFixture(held=(37, 62)))
|
||||
self.assertEqual(keyboard.events, [(37, False), (62, False), (38, True),
|
||||
(38, False), (37, True), (62, True)])
|
||||
self.assertEqual(keyboard.pressed, {37, 62})
|
||||
|
||||
def test_too_small_budget_rejects_before_events(self):
|
||||
keyboard = KeyboardFixture(held=(37,))
|
||||
self.assert_error("TYPE_BUDGET_TOO_SMALL", lambda: self.run_text("abc", keyboard, timeout=.02))
|
||||
self.assertEqual(keyboard.events, [])
|
||||
|
||||
def test_expected_window_or_geometry_mismatch_has_zero_events(self):
|
||||
for kwargs, code in [({"expected_window": 202}, "TYPE_ACTIVE_WINDOW_CHANGED"),
|
||||
({"expected_geometry": (800, 600)}, "TYPE_GEOMETRY_CHANGED")]:
|
||||
with self.subTest(kwargs=kwargs):
|
||||
keyboard = KeyboardFixture()
|
||||
self.assert_error(code, lambda: self.run_text("a", keyboard, **kwargs))
|
||||
self.assertEqual(keyboard.events, [])
|
||||
|
||||
def test_layout_binding_required_entry_never_becomes_typeable(self):
|
||||
keyboard = KeyboardFixture()
|
||||
keyboard.mappings += (subject.Mapping(120, 0, 0x01002603, 0, 0x2603, 1),)
|
||||
keyboard.physical[120] = ("\u2603", "\u2603")
|
||||
self.assert_error("TYPE_TEXT_UNREPRESENTABLE_IN_CURRENT_GROUP",
|
||||
lambda: self.run_text("\u2603", keyboard))
|
||||
self.assertEqual(keyboard.events, [])
|
||||
|
||||
|
||||
class NativeBoundaryTests(unittest.TestCase):
|
||||
def test_primary_header_abi_sizes_and_offsets(self):
|
||||
self.assertEqual(C.sizeof(subject.CharCode), 32)
|
||||
self.assertEqual({name: getattr(subject.CharCode, name).offset for name, _ in subject.CharCode._fields_},
|
||||
{"key": 0, "code": 4, "symbol": 8, "group": 16,
|
||||
"modmask": 20, "needs_binding": 24})
|
||||
self.assertEqual(C.sizeof(subject.XkbState), 18)
|
||||
self.assertEqual({name: getattr(subject.XkbState, name).offset for name, _ in subject.XkbState._fields_},
|
||||
{"group": 0, "locked_group": 1, "base_group": 2,
|
||||
"latched_group": 4, "mods": 6, "base_mods": 7,
|
||||
"latched_mods": 8, "locked_mods": 9, "compat_state": 10,
|
||||
"grab_mods": 11, "compat_grab_mods": 12, "lookup_mods": 13,
|
||||
"compat_lookup_mods": 14, "ptr_buttons": 16})
|
||||
self.assertEqual(subject.XdoPrefix.charcodes.offset, 16)
|
||||
self.assertEqual(subject.XdoPrefix.charcodes_len.offset, 24)
|
||||
self.assertEqual([name for name, _ in subject.XdoPrefix._fields_],
|
||||
["xdpy", "display_name", "charcodes", "charcodes_len"])
|
||||
|
||||
def test_only_native_input_binding_is_xtest_key_event(self):
|
||||
tree = ast.parse(textwrap.dedent(inspect.getsource(subject.NativeBackend._bind)))
|
||||
bindings = {call.args[1].value for call in ast.walk(tree)
|
||||
if isinstance(call, ast.Call) and isinstance(call.func, ast.Name)
|
||||
and call.func.id == "bind"}
|
||||
self.assertEqual(bindings & {"xdo_enter_text_window", "xdo_send_keysequence_window",
|
||||
"XkbLockGroup", "XkbLatchGroup", "XChangeKeyboardMapping",
|
||||
"XkbSetMap", "XSetSelectionOwner", "XSendEvent"}, set())
|
||||
self.assertIn("XTestFakeKeyEvent", bindings)
|
||||
self.assertEqual({name for name in bindings if name.startswith("xdo_")},
|
||||
{"xdo_version", "xdo_new", "xdo_free", "xdo_get_active_window"})
|
||||
|
||||
def test_unknown_libxdo_abi_rejected_without_context_or_display(self):
|
||||
fake = SimpleNamespace(xdo_version=Mock(return_value=b"4.20260303.1"), xdo_new=Mock())
|
||||
with patch.object(subject, "trusted_library", return_value=fake), patch.object(subject.NativeBackend, "_bind"):
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_NATIVE_ABI_UNSUPPORTED$"):
|
||||
subject.NativeBackend()
|
||||
fake.xdo_new.assert_not_called()
|
||||
|
||||
def test_untrusted_library_ancestor_is_rejected_before_load(self):
|
||||
for uid, mode in [(1000, 0o755), (0, 0o777), (0, 0o775)]:
|
||||
with self.subTest(uid=uid, mode=mode):
|
||||
leaf = Mock()
|
||||
leaf.lstat.return_value = SimpleNamespace(st_uid=0, st_mode=0o755)
|
||||
ancestor = Mock()
|
||||
ancestor.lstat.return_value = SimpleNamespace(st_uid=uid, st_mode=mode)
|
||||
leaf.parents = [ancestor]
|
||||
fake_path = Mock()
|
||||
fake_path.__truediv__ = Mock(return_value=fake_path)
|
||||
fake_path.resolve.return_value = leaf
|
||||
with patch.object(subject, "Path", return_value=fake_path), patch.object(subject.C, "CDLL") as loader:
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_NATIVE_LIBRARY_UNTRUSTED$"):
|
||||
subject.trusted_library("libX11.so.6")
|
||||
loader.assert_not_called()
|
||||
|
||||
def test_missing_library_is_rejected_without_loader(self):
|
||||
fake_path = Mock()
|
||||
fake_path.__truediv__ = Mock(return_value=fake_path)
|
||||
fake_path.resolve.side_effect = FileNotFoundError("fixture missing")
|
||||
with patch.object(subject, "Path", return_value=fake_path), patch.object(subject.C, "CDLL") as loader:
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_NATIVE_LIBRARY_UNAVAILABLE$"):
|
||||
subject.trusted_library("libX11.so.6")
|
||||
loader.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Exercise bounded worker pipes with real Python-only child processes.
|
||||
|
||||
The worker command is replaced at the Popen boundary. No native backend or
|
||||
display is instantiated; request/permission/deadline behavior uses real pipes.
|
||||
"""
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
try:
|
||||
from framebridge_expanded import x11_text as subject
|
||||
except ModuleNotFoundError as error:
|
||||
if error.name != "framebridge_expanded":
|
||||
raise
|
||||
import x11_text as subject
|
||||
|
||||
|
||||
STUB = r'''
|
||||
import json, signal, sys, time
|
||||
mode = sys.argv[1]
|
||||
if mode == "hang_unread":
|
||||
time.sleep(10)
|
||||
raise SystemExit(2)
|
||||
request = json.loads(sys.stdin.buffer.readline())
|
||||
if request["window"] != 101 or request["geometry"] != [1920,1080]:
|
||||
print("ERROR TYPE_FIXTURE_CONTEXT_INVALID", flush=True)
|
||||
raise SystemExit(2)
|
||||
if mode == "preflight":
|
||||
print("ERROR TYPE_TEXT_UNREPRESENTABLE_IN_CURRENT_GROUP", flush=True)
|
||||
elif mode == "exit":
|
||||
raise SystemExit(2)
|
||||
elif mode == "bad":
|
||||
print("NOT A VALID MESSAGE", flush=True)
|
||||
elif mode == "oversize":
|
||||
print("x" * 5000, flush=True)
|
||||
elif mode == "hang_before":
|
||||
time.sleep(10)
|
||||
else:
|
||||
for index in range(2 if mode == "twice" else 1):
|
||||
print("INPUT", flush=True)
|
||||
if sys.stdin.buffer.readline() != b"GO\n":
|
||||
raise SystemExit(3)
|
||||
if mode == "hang_after":
|
||||
time.sleep(10)
|
||||
elif mode == "ignore_terminate":
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
time.sleep(10)
|
||||
elif mode == "error_after":
|
||||
print("ERROR TYPE_KEYMAP_CHANGED", flush=True)
|
||||
else:
|
||||
print("OK", flush=True)
|
||||
'''
|
||||
|
||||
|
||||
class BoundedWorkerTests(unittest.TestCase):
|
||||
def test_selector_failure_does_not_spawn_worker(self):
|
||||
with patch.object(subject.selectors, "DefaultSelector", side_effect=OSError("fixture")), \
|
||||
patch.object(subject.subprocess, "Popen") as spawn:
|
||||
with self.assertRaises(OSError):
|
||||
subject.bounded_type_text("/", expected_window=101, expected_geometry=(1920, 1080),
|
||||
before_input=lambda: None)
|
||||
spawn.assert_not_called()
|
||||
|
||||
def test_spawn_failure_closes_selector(self):
|
||||
selector = Mock()
|
||||
with patch.object(subject.selectors, "DefaultSelector", return_value=selector), \
|
||||
patch.object(subject.subprocess, "Popen", side_effect=OSError("fixture")):
|
||||
with self.assertRaises(OSError):
|
||||
subject.bounded_type_text("/", expected_window=101, expected_geometry=(1920, 1080),
|
||||
before_input=lambda: None)
|
||||
selector.close.assert_called_once_with()
|
||||
|
||||
@contextmanager
|
||||
def child(self, mode):
|
||||
real_popen = subprocess.Popen
|
||||
children = []
|
||||
|
||||
def spawn(argv, **kwargs):
|
||||
self.assertEqual(argv, [sys.executable, "-I", "-S", "-B", str(Path(subject.__file__).resolve()), "--worker"])
|
||||
child = real_popen([sys.executable, "-u", "-c", STUB, mode], **kwargs)
|
||||
children.append(child)
|
||||
return child
|
||||
|
||||
try:
|
||||
with patch.object(subject.subprocess, "Popen", side_effect=spawn):
|
||||
yield children
|
||||
finally:
|
||||
for process in children:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=2)
|
||||
self.assertIsNotNone(process.returncode)
|
||||
self.assertTrue(process.stdin.closed)
|
||||
self.assertTrue(process.stdout.closed)
|
||||
|
||||
def invoke(self, guard, *, text="-S /run/fixture", timeout=.5):
|
||||
return subject.bounded_type_text(text, expected_window=101,
|
||||
expected_geometry=(1920, 1080),
|
||||
before_input=guard, timeout=timeout)
|
||||
|
||||
def test_preflight_failure_does_not_request_any_permission(self):
|
||||
guard = Mock()
|
||||
with self.child("preflight"):
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_TEXT_UNREPRESENTABLE_IN_CURRENT_GROUP$"):
|
||||
self.invoke(guard)
|
||||
guard.assert_not_called()
|
||||
|
||||
def test_one_input_batch_requires_one_guard_and_ack(self):
|
||||
guard = Mock()
|
||||
with self.child("success"):
|
||||
self.assertIsNone(self.invoke(guard))
|
||||
guard.assert_called_once_with()
|
||||
|
||||
def test_each_input_batch_requires_a_fresh_guard(self):
|
||||
guard = Mock()
|
||||
with self.child("twice"):
|
||||
self.invoke(guard)
|
||||
self.assertEqual(guard.call_count, 2)
|
||||
|
||||
def test_timeout_before_permission_has_zero_guard_calls(self):
|
||||
guard = Mock()
|
||||
started = time.monotonic()
|
||||
with self.child("hang_before"):
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_TIMEOUT$"):
|
||||
self.invoke(guard, timeout=.15)
|
||||
self.assertLess(time.monotonic() - started, 2)
|
||||
guard.assert_not_called()
|
||||
|
||||
def test_timeout_after_permission_retains_possible_input_evidence(self):
|
||||
guard = Mock()
|
||||
started = time.monotonic()
|
||||
with self.child("hang_after"):
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_TIMEOUT$"):
|
||||
self.invoke(guard, timeout=.15)
|
||||
self.assertLess(time.monotonic() - started, 2)
|
||||
guard.assert_called_once_with()
|
||||
|
||||
def test_guard_failure_terminates_child_waiting_for_ack(self):
|
||||
guard = Mock(side_effect=RuntimeError("AUTHORITY_EXPIRED"))
|
||||
started = time.monotonic()
|
||||
with self.child("success"):
|
||||
with self.assertRaisesRegex(RuntimeError, "^AUTHORITY_EXPIRED$"):
|
||||
self.invoke(guard)
|
||||
self.assertLess(time.monotonic() - started, 2)
|
||||
guard.assert_called_once_with()
|
||||
|
||||
def test_stalled_child_that_ignores_termination_is_killed(self):
|
||||
guard = Mock()
|
||||
started = time.monotonic()
|
||||
with self.child("ignore_terminate") as children:
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_TIMEOUT$"):
|
||||
self.invoke(guard, timeout=.15)
|
||||
self.assertLess(time.monotonic() - started, 2)
|
||||
guard.assert_called_once_with()
|
||||
self.assertLess(children[0].returncode, 0)
|
||||
|
||||
def test_partial_input_error_preserves_permission_evidence(self):
|
||||
guard = Mock()
|
||||
with self.child("error_after"):
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEYMAP_CHANGED$"):
|
||||
self.invoke(guard)
|
||||
guard.assert_called_once_with()
|
||||
|
||||
def test_unknown_protocol_line_fails_without_permission(self):
|
||||
guard = Mock()
|
||||
with self.child("bad"):
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_PROTOCOL_INVALID$"):
|
||||
self.invoke(guard)
|
||||
guard.assert_not_called()
|
||||
|
||||
def test_oversized_worker_line_fails_without_permission(self):
|
||||
guard = Mock()
|
||||
with self.child("oversize"):
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_PROTOCOL_INVALID$"):
|
||||
self.invoke(guard)
|
||||
guard.assert_not_called()
|
||||
|
||||
def test_worker_exit_is_not_mistaken_for_success(self):
|
||||
guard = Mock()
|
||||
with self.child("exit"):
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_EXIT_UNCERTAIN$"):
|
||||
self.invoke(guard)
|
||||
guard.assert_not_called()
|
||||
|
||||
def test_large_unicode_request_cannot_block_on_unread_pipe(self):
|
||||
guard = Mock()
|
||||
started = time.monotonic()
|
||||
with self.child("hang_unread"):
|
||||
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_TIMEOUT$"):
|
||||
self.invoke(guard, text="\U0001f642" * 4096, timeout=.15)
|
||||
self.assertLess(time.monotonic() - started, 2)
|
||||
guard.assert_not_called()
|
||||
|
||||
def test_main_dispatch_runs_only_after_backend_definition(self):
|
||||
# Replace only the NativeBackend AST in memory. This executes the real
|
||||
# module/worker order with a constructor that cannot call native code.
|
||||
wrapper = r'''
|
||||
import ast, pathlib, sys
|
||||
source = pathlib.Path(sys.argv[1]).read_text()
|
||||
tree = ast.parse(source)
|
||||
replacement = ast.parse("class NativeBackend:\n def __init__(self):\n raise TextError('TYPE_FIXTURE_NATIVE_BOUNDARY')\n").body[0]
|
||||
for index, node in enumerate(tree.body):
|
||||
if isinstance(node, ast.ClassDef) and node.name == 'NativeBackend':
|
||||
tree.body[index] = replacement
|
||||
ast.fix_missing_locations(tree)
|
||||
sys.argv = [sys.argv[1], '--worker']
|
||||
namespace = {'__name__': '__main__', '__file__': sys.argv[0]}
|
||||
exec(compile(tree, sys.argv[0], 'exec'), namespace)
|
||||
'''
|
||||
request = json.dumps({"text": "a", "window": 101,
|
||||
"geometry": [1920, 1080], "timeout": 1}) + "\n"
|
||||
result = subprocess.run([sys.executable, "-c", wrapper, str(Path(subject.__file__))],
|
||||
input=request, text=True, capture_output=True, timeout=2)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout, "ERROR TYPE_FIXTURE_NATIVE_BOUNDARY\n")
|
||||
|
||||
def test_real_isolated_worker_bootstrap_reaches_safe_display_preflight(self):
|
||||
guard = Mock()
|
||||
# This display number cannot refer to the user's real session. Native
|
||||
# preflight can read libraries but cannot emit a GUI event.
|
||||
with patch.dict(os.environ, {"DISPLAY": ":99999"}):
|
||||
with self.assertRaises(subject.TextError) as caught:
|
||||
self.invoke(guard, timeout=1)
|
||||
self.assertIn(str(caught.exception), {"TYPE_DISPLAY_UNAVAILABLE",
|
||||
"TYPE_NATIVE_LIBRARY_UNAVAILABLE",
|
||||
"TYPE_NATIVE_ABI_UNSUPPORTED"})
|
||||
guard.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,134 @@
|
||||
# Signed distribution repositories
|
||||
|
||||
The canonical source is the existing GERANAS Gitea repository
|
||||
`gerardo/framebridge`. Build all three reviewed DEBs from its release commit;
|
||||
do not edit installed files to produce an update.
|
||||
|
||||
One HTTPS repository at `https://framebridge.betancourt.one/debian/` contains
|
||||
three independent suites:
|
||||
|
||||
| Suite | Tested distribution profile |
|
||||
| --- | --- |
|
||||
| trixie | Debian 13, amd64, Python 3.13 |
|
||||
| noble | Ubuntu 24.04, amd64, Python 3.12 |
|
||||
| mint22 | Linux Mint 22.x, amd64, Python 3.12 |
|
||||
|
||||
Select exactly the suite matching the computer. These are three builds of one
|
||||
binary package, `framebridge-original-expanded`, with unique pool filenames.
|
||||
This table does not certify untested releases or architectures.
|
||||
|
||||
## Prepare and review
|
||||
|
||||
Run `python3 tools/build_apt_repository.py prepare --help` in the source
|
||||
repository. Supply the exact three version-matched DEBs, the existing official
|
||||
public key and a new output directory. The command copies the artifacts, builds
|
||||
indexes and by-hash paths, and prints the immutable input manifest SHA-256.
|
||||
It does not open a private keyring, sign, publish or install anything.
|
||||
|
||||
Review the manifest, package hashes and release identity before signing.
|
||||
The public key must contain exactly one primary release key with fingerprint
|
||||
`2B1F01BC806C0B33CB6CEDDE52859CEBC91B8808`. Official subkeys are allowed.
|
||||
|
||||
## Sign in the owner's normal terminal
|
||||
|
||||
Run `python3 tools/build_apt_repository.py sign --help`. Pass the reviewed
|
||||
output directory and exact manifest SHA-256. The signer requires a non-root
|
||||
interactive terminal and an owner-private existing signing home. Set
|
||||
`GPG_TTY=$(tty)` in that normal terminal if the pinentry implementation needs it.
|
||||
The owner's passphrase is entered only into normal pinentry; it is never an
|
||||
argument, environment variable or chat message.
|
||||
|
||||
The signer writes `Release.gpg` and `InRelease` for each suite, verifies the
|
||||
signatures against the public key and exact reviewed Release plaintext, then
|
||||
clears the dedicated signing agent. Existing signatures or partial results
|
||||
cause refusal; inspect a partial transaction instead of blindly rerunning it.
|
||||
|
||||
Run `verify` with the same pinned manifest hash after signing and again after
|
||||
transport. Metadata expires after 30 days; publish a freshly signed Release
|
||||
before expiry, without altering historical signed artifacts.
|
||||
|
||||
## Publish through GERANAS
|
||||
|
||||
Use the existing GERANAS web origin and the CLOUD HTTPS reverse proxy. Do not
|
||||
create another Git backend or expose MCP/auth/broker services. Back up the
|
||||
current public generation before activation. Preserve the existing signed
|
||||
0.3.1 downloads and old repository metadata in an archive path.
|
||||
|
||||
Publish only reviewed manifest files, the six verified signature files and the
|
||||
signing receipt. Public directories must be `0755` and files `0644`; a private
|
||||
staging umask must not leak into the served generation. Never publish the
|
||||
signing home, private receipts, account configuration or operational worktree.
|
||||
Activate a complete verified generation, preserving the web unit's existing
|
||||
read-only bind mounts, then run isolated APT update and exact-version download
|
||||
for all three suites through the final HTTPS authority.
|
||||
|
||||
## Configure a client
|
||||
|
||||
Install the verified official public key as
|
||||
`/etc/apt/keyrings/framebridge-archive-keyring.asc` with mode `0644`.
|
||||
Its fingerprint must match the value above. Add a Deb822 source file:
|
||||
|
||||
```text
|
||||
Types: deb
|
||||
URIs: https://framebridge.betancourt.one/debian/
|
||||
Suites: trixie
|
||||
Components: main
|
||||
Architectures: amd64
|
||||
Signed-By: /etc/apt/keyrings/framebridge-archive-keyring.asc
|
||||
```
|
||||
|
||||
Replace `trixie` with `noble` or `mint22` for those tested profiles. Then use
|
||||
normal APT commands:
|
||||
|
||||
```sh
|
||||
sudo apt update
|
||||
apt-cache policy framebridge-original-expanded
|
||||
sudo apt install --only-upgrade framebridge-original-expanded
|
||||
```
|
||||
|
||||
Do not use trusted=yes or disable signature/validity checks. APT update/download
|
||||
verification does not itself prove runtime restart or reboot persistence.
|
||||
Confirm the actual installed version and ready connection after each upgrade.
|
||||
|
||||
## Tool checks
|
||||
|
||||
`python3 tools/test_build_apt_repository.py` creates disposable packages and a
|
||||
disposable signing identity in a temporary directory. It tests actual isolated
|
||||
APT acceptance for each suite and rejects altered packages/indexes/signatures,
|
||||
unexpected versions, path traversal and bundled extra primary trust keys.
|
||||
It never uses the official private signing key or changes system APT sources.
|
||||
|
||||
|
||||
## One signing session for the release
|
||||
|
||||
For a complete release, also pass --source with the reviewed
|
||||
FrameBridge-VERSION-source.tar.gz and --dependency-source twice with the two
|
||||
existing dependency-source archives. Their original sibling .asc signatures
|
||||
are verified and copied unchanged. The generator creates a downloads staging
|
||||
subdirectory with the three DEBs, source, dependency sources and SHA256SUMS.
|
||||
The same input manifest covers all of these bytes. The owner-terminal sign
|
||||
command signs the three DEBs, project source and checksums in the same session
|
||||
as the three APT suites. Existing dependency signatures are preserved.
|
||||
|
||||
Map the staging downloads subdirectory to the existing public /downloads/;
|
||||
the rest of the repository goes under /debian/. Do not publish a nested second
|
||||
/debian/downloads/ route as the official download location. Verify every
|
||||
signature with the pinned manifest before activating either path.
|
||||
|
||||
## Canonical Gitea publication
|
||||
|
||||
publish_gitea.py is an administrator tool for the existing GERANAS Gitea, not a
|
||||
second Git host. Its default mode checks a root-frozen bundle SHA-256, exact old
|
||||
main, ancestor relationship and the new release tag. --apply requires an
|
||||
exclusive journal in a root-owned private directory. Run only the reviewed
|
||||
root-frozen tool and bundle using the normal authorized administrator context.
|
||||
|
||||
The tool creates a narrowly scoped, uniquely named temporary token using the
|
||||
installed Gitea administrator CLI. It keeps the token only in process memory,
|
||||
feeds normal Git ASKPASS through a private Unix socket and performs an atomic
|
||||
push without a force flag. It verifies exact refs, revokes the temporary token
|
||||
through normal Basic authentication and verifies HTTP 401 afterward. The
|
||||
journal contains the nonsecret token name so an interrupted transaction can be
|
||||
reviewed. If any step is uncertain, inspect that transaction rather than
|
||||
replaying it. No token value is placed in URLs, arguments, environment variables,
|
||||
files or reports.
|
||||
Executable
+299
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build and verify three signed APT suites from the reviewed distribution DEBs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import email.utils
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
PACKAGE = "framebridge-original-expanded"
|
||||
FINGERPRINT = "2B1F01BC806C0B33CB6CEDDE52859CEBC91B8808"
|
||||
DEPENDENCY_SOURCE_ASSETS = {
|
||||
"FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz": (82606998, "64540a85bd65eac3f394d8c0a967cacbc00e112676d57d644dcd1bb691c1926a"),
|
||||
"FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz": (26941745, "6445d4f0e773aba833fe5c610fbae54102b92de2ce636438f769f21e890c2bd9"),
|
||||
}
|
||||
PROFILES = {
|
||||
"debian": ("trixie", "Debian 13", "debian13-amd64-py313", "3.13"),
|
||||
"ubuntu": ("noble", "Ubuntu 24.04", "ubuntu24.04-amd64-py312", "3.12"),
|
||||
"mint": ("mint22", "Linux Mint 22.x", "mint22-amd64-py312", "3.12"),
|
||||
}
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
def digest(data, algorithm="sha256"):
|
||||
return hashlib.new(algorithm, data).hexdigest()
|
||||
|
||||
def run(argv, **kwargs):
|
||||
return subprocess.run([str(x) for x in argv], check=True, capture_output=True, **kwargs)
|
||||
|
||||
def dump(path, value):
|
||||
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
def public_fingerprint(key):
|
||||
with tempfile.TemporaryDirectory(prefix="framebridge-public-key-") as tmp:
|
||||
os.chmod(tmp, 0o700)
|
||||
result = run(["gpg", "--no-options", "--homedir", tmp, "--batch",
|
||||
"--with-colons", "--show-keys", key]).stdout.decode()
|
||||
fingerprints = [line.split(":")[9] for line in result.splitlines()
|
||||
if line.startswith("fpr:")]
|
||||
primary_keys = [line for line in result.splitlines() if line.startswith("pub:")]
|
||||
require(len(primary_keys) == 1 and fingerprints and fingerprints[0] == FINGERPRINT,
|
||||
"Expected exactly one official primary release key")
|
||||
return fingerprints[0]
|
||||
|
||||
def control(path):
|
||||
text = run(["dpkg-deb", "--field", path]).stdout.decode()
|
||||
fields = {}
|
||||
for line in text.splitlines():
|
||||
if line and not line[0].isspace():
|
||||
name, separator, value = line.partition(":")
|
||||
require(separator, "Invalid Debian control field")
|
||||
fields[name] = value.strip()
|
||||
require(not any(k in fields for k in ("Filename", "Size", "MD5sum", "SHA256", "SHA512")),
|
||||
"Artifact control contains repository-generated fields")
|
||||
return text.rstrip() + "\n", fields
|
||||
|
||||
def release_bytes(suite, label, date, files):
|
||||
lines = ["Origin: FrameBridge", "Label: FrameBridge", f"Suite: {suite}",
|
||||
f"Codename: {suite}", f"Date: {email.utils.format_datetime(date)}",
|
||||
f"Valid-Until: {email.utils.format_datetime(date + dt.timedelta(days=30))}",
|
||||
"Architectures: amd64", "Components: main",
|
||||
f"Description: FrameBridge for {label}", "Acquire-By-Hash: yes"]
|
||||
for algorithm, heading in (("sha256", "SHA256"), ("sha512", "SHA512")):
|
||||
lines.append(heading + ":")
|
||||
lines.extend(f" {digest(data, algorithm)} {len(data):16d} {name}"
|
||||
for name, data in sorted(files.items()))
|
||||
return ("\n".join(lines) + "\n").encode()
|
||||
|
||||
|
||||
def verify_detached(public_key, signature, payload):
|
||||
with tempfile.TemporaryDirectory(prefix="framebridge-asset-verify-") as tmp:
|
||||
tmp = Path(tmp)
|
||||
ring = tmp / "release.gpg"
|
||||
ring.write_bytes(run(["gpg", "--no-options", "--homedir", tmp, "--batch", "--output", "-",
|
||||
"--dearmor", public_key]).stdout)
|
||||
result = run(["gpgv", "--homedir", tmp, "--keyring", ring, "--status-fd", "1", signature, payload])
|
||||
require(f"[GNUPG:] VALIDSIG {FINGERPRINT} ".encode() in result.stdout, "Unexpected dependency-source signer")
|
||||
|
||||
def prepare_downloads(args, inputs):
|
||||
source = getattr(args, "source", None)
|
||||
dependencies = getattr(args, "dependency_source", [])
|
||||
if source is None:
|
||||
require(not dependencies, "Dependency assets require the corresponding project-source archive")
|
||||
return []
|
||||
require(source.is_file() and not source.is_symlink() and
|
||||
source.name == f"FrameBridge-{args.version}-source.tar.gz", "Unexpected project-source archive")
|
||||
require({p.name for p in dependencies} == set(DEPENDENCY_SOURCE_ASSETS) and
|
||||
len(dependencies) == len(DEPENDENCY_SOURCE_ASSETS), "Exactly the reviewed two dependency-source archives required")
|
||||
for path in dependencies:
|
||||
expected_size, expected_sha = DEPENDENCY_SOURCE_ASSETS[path.name]
|
||||
require(path.is_file() and not path.is_symlink(), "Unsafe dependency-source archive")
|
||||
data = path.read_bytes()
|
||||
require(len(data) == expected_size and digest(data) == expected_sha, "Dependency-source archive hash mismatch")
|
||||
verify_detached(args.public_key, path.with_name(path.name + ".asc"), path)
|
||||
directory = args.output / "downloads"
|
||||
directory.mkdir()
|
||||
assets = []
|
||||
def add(name, data, needs_signing):
|
||||
target = directory / name
|
||||
require(not target.exists(), "Duplicate release filename")
|
||||
target.write_bytes(data)
|
||||
assets.append({"filename": name, "size_bytes": len(data), "sha256": digest(data),
|
||||
"signature": name + ".asc", "needs_signing": needs_signing})
|
||||
for _, _, _, profile, _, data in inputs:
|
||||
add(f"FrameBridge-{args.version}-{profile}.deb", data, True)
|
||||
add(source.name, source.read_bytes(), True)
|
||||
for path in dependencies:
|
||||
add(path.name, path.read_bytes(), False)
|
||||
shutil.copyfile(path.with_name(path.name + ".asc"), directory / (path.name + ".asc"))
|
||||
shutil.copyfile(args.public_key, directory / "framebridge-release-key.asc")
|
||||
sums = "".join(item["sha256"] + " " + item["filename"] + "\n" for item in assets)
|
||||
add("SHA256SUMS", sums.encode(), True)
|
||||
return assets
|
||||
|
||||
def prepare(args):
|
||||
output = args.output
|
||||
require(not output.exists(), "Output must be a new directory; never overwrite a published repository")
|
||||
require(re.fullmatch(r"[0-9][A-Za-z0-9.+:~_-]*", args.version), "Invalid version")
|
||||
public_fingerprint(args.public_key)
|
||||
epoch = args.epoch if args.epoch is not None else int(dt.datetime.now(dt.timezone.utc).timestamp())
|
||||
date = dt.datetime.fromtimestamp(epoch, dt.timezone.utc)
|
||||
inputs = []
|
||||
for distro, (suite, label, profile, python) in PROFILES.items():
|
||||
path = getattr(args, distro)
|
||||
require(path.is_file() and not path.is_symlink(), f"{distro}: artifact is not a regular file")
|
||||
text, fields = control(path)
|
||||
require(fields.get("Package") == PACKAGE, f"{distro}: wrong binary package")
|
||||
require(fields.get("Version") == args.version, f"{distro}: wrong version")
|
||||
require(fields.get("Architecture") == "amd64", f"{distro}: wrong architecture")
|
||||
require(re.search(r"python3\s*\(>=\s*" + re.escape(python), fields.get("Depends", "")),
|
||||
f"{distro}: Python dependency does not match the tested profile")
|
||||
data = path.read_bytes()
|
||||
inputs.append((distro, suite, label, profile, text, data))
|
||||
output.mkdir(parents=True, mode=0o755)
|
||||
records = []
|
||||
for distro, suite, label, profile, text, data in inputs:
|
||||
relative = f"pool/main/f/framebridge/{PACKAGE}_{args.version}_{profile}.deb"
|
||||
artifact = output / relative
|
||||
artifact.parent.mkdir(parents=True, exist_ok=True)
|
||||
artifact.write_bytes(data)
|
||||
require(digest(artifact.read_bytes()) == digest(data), "Copied artifact hash mismatch")
|
||||
stanza = text + f"Filename: {relative}\nSize: {len(data)}\nSHA256: {digest(data)}\nSHA512: {digest(data, 'sha512')}\n\n"
|
||||
plain = stanza.encode()
|
||||
indexes = {"main/binary-amd64/Packages": plain,
|
||||
"main/binary-amd64/Packages.gz": gzip.compress(plain, mtime=0)}
|
||||
suite_dir = output / "dists" / suite
|
||||
for name, index in indexes.items():
|
||||
target = suite_dir / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(index)
|
||||
for algorithm in ("sha256", "sha512"):
|
||||
by_hash = target.parent / "by-hash" / algorithm.upper() / digest(index, algorithm)
|
||||
by_hash.parent.mkdir(parents=True, exist_ok=True)
|
||||
by_hash.write_bytes(index)
|
||||
(suite_dir / "Release").write_bytes(release_bytes(suite, label, date, indexes))
|
||||
records.append({"distribution": distro, "label": label, "suite": suite,
|
||||
"profile": profile, "filename": relative,
|
||||
"size": len(data), "sha256": digest(data)})
|
||||
downloads = prepare_downloads(args, inputs)
|
||||
shutil.copyfile(args.public_key, output / "framebridge-archive-keyring.asc")
|
||||
files = {p.relative_to(output).as_posix(): {"bytes": p.stat().st_size,
|
||||
"sha256": digest(p.read_bytes())} for p in sorted(output.rglob("*")) if p.is_file()}
|
||||
manifest = {"schema": 1, "package": PACKAGE, "version": args.version,
|
||||
"fingerprint": FINGERPRINT, "prepared_utc": date.isoformat(),
|
||||
"artifacts": records, "download_assets": downloads, "files": files}
|
||||
dump(output / "APT-INPUT-MANIFEST.json", manifest)
|
||||
manifest_hash = digest((output / "APT-INPUT-MANIFEST.json").read_bytes())
|
||||
print(json.dumps({"prepared": True, "signed": False, "directory": str(output),
|
||||
"version": args.version, "manifest_sha256": manifest_hash,
|
||||
"suites": [x["suite"] for x in records]}, sort_keys=True))
|
||||
|
||||
def check_inputs(output, expected):
|
||||
p = output / "APT-INPUT-MANIFEST.json"
|
||||
require(p.is_file() and not p.is_symlink(), "Input manifest missing or unsafe")
|
||||
raw = p.read_bytes()
|
||||
require(digest(raw) == expected, "Manifest hash mismatch")
|
||||
manifest = json.loads(raw)
|
||||
require(manifest.get("package") == PACKAGE and manifest.get("fingerprint") == FINGERPRINT,
|
||||
"Wrong package or signing identity")
|
||||
require(len(manifest.get("artifacts", [])) == 3, "Exactly three distribution artifacts required")
|
||||
require({x["suite"] for x in manifest["artifacts"]} == {x[0] for x in PROFILES.values()},
|
||||
"Unexpected distribution suite set")
|
||||
for name, metadata in manifest["files"].items():
|
||||
relative = Path(name)
|
||||
require(not relative.is_absolute() and ".." not in relative.parts, "Unsafe manifest path")
|
||||
path = output / relative
|
||||
require(path.is_file() and not path.is_symlink(), f"Missing or unsafe input: {name}")
|
||||
data = path.read_bytes()
|
||||
require(len(data) == metadata["bytes"] and digest(data) == metadata["sha256"],
|
||||
f"Input changed after review: {name}")
|
||||
public_fingerprint(output / "framebridge-archive-keyring.asc")
|
||||
return manifest
|
||||
|
||||
def signature_paths(output, sign_only=False):
|
||||
for suite, *_ in PROFILES.values():
|
||||
root = output / "dists" / suite
|
||||
yield root / "Release", root / "InRelease", True
|
||||
yield root / "Release", root / "Release.gpg", False
|
||||
manifest = json.loads((output / "APT-INPUT-MANIFEST.json").read_text())
|
||||
for asset in manifest.get("download_assets", []):
|
||||
if sign_only and not asset["needs_signing"]:
|
||||
continue
|
||||
filename = asset["filename"]
|
||||
require(Path(filename).name == filename and Path(asset["signature"]).name == asset["signature"],
|
||||
"Unsafe download signature path")
|
||||
yield output / "downloads" / filename, output / "downloads" / asset["signature"], False
|
||||
|
||||
def verify_signatures(output, manifest):
|
||||
with tempfile.TemporaryDirectory(prefix="framebridge-apt-verify-") as tmp:
|
||||
tmp = Path(tmp)
|
||||
keyring = tmp / "release.gpg"
|
||||
keyring.write_bytes(run(["gpg", "--no-options", "--homedir", tmp, "--batch", "--output", "-", "--dearmor",
|
||||
output / "framebridge-archive-keyring.asc"]).stdout)
|
||||
for release, signed, clearsign in signature_paths(output):
|
||||
require(signed.is_file() and not signed.is_symlink(), "Missing signature")
|
||||
args = ["gpgv", "--homedir", tmp, "--keyring", keyring, "--status-fd", "1"]
|
||||
result = run(args + ([signed] if clearsign else [signed, release]))
|
||||
require(f"[GNUPG:] VALIDSIG {FINGERPRINT} ".encode() in result.stdout,
|
||||
"Signature made by unexpected key")
|
||||
if clearsign:
|
||||
plaintext = run(["gpgv", "--homedir", tmp, "--keyring", keyring,
|
||||
"--output", "-", signed]).stdout
|
||||
require(plaintext == release.read_bytes(), "Clear-signed release differs from reviewed input")
|
||||
return {"verified": True, "version": manifest["version"],
|
||||
"suites": [x["suite"] for x in manifest["artifacts"]], "fingerprint": FINGERPRINT}
|
||||
|
||||
def sign(args):
|
||||
require(os.getuid() != 0 and sys.stdin.isatty() and os.isatty(1),
|
||||
"Signing must run as the owner in a normal interactive terminal")
|
||||
manifest = check_inputs(args.output, args.manifest_sha256)
|
||||
require(args.key_home.is_dir() and not args.key_home.is_symlink(), "Unsafe signing home")
|
||||
key_stat = args.key_home.stat()
|
||||
require(key_stat.st_uid == os.getuid() and key_stat.st_mode & 0o077 == 0, "Signing home must be owner-private")
|
||||
targets = list(signature_paths(args.output, sign_only=True))
|
||||
require(all(not target.exists() for _, target, _ in targets), "Signatures already exist; do not overwrite")
|
||||
common = ["gpg", "--no-options", "--homedir", args.key_home, "--pinentry-mode", "default",
|
||||
"--local-user", FINGERPRINT, "--digest-algo", "SHA256"]
|
||||
try:
|
||||
for source, target, clearsign in targets:
|
||||
check_inputs(args.output, args.manifest_sha256)
|
||||
temporary = target.with_name(target.name + ".partial")
|
||||
require(not temporary.exists(), "Incomplete previous signing transaction needs review")
|
||||
subprocess.run([str(x) for x in common + ["--output", temporary,
|
||||
"--clearsign" if clearsign else "--detach-sign", source]], check=True)
|
||||
temporary.rename(target)
|
||||
result = verify_signatures(args.output, manifest)
|
||||
result["manifest_sha256"] = args.manifest_sha256
|
||||
dump(args.output / "APT-SIGNING-RECEIPT.json", result)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
finally:
|
||||
run(["gpgconf", "--homedir", args.key_home, "--kill", "gpg-agent"])
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
prepare_parser = commands.add_parser("prepare")
|
||||
prepare_parser.add_argument("--output", type=Path, required=True)
|
||||
prepare_parser.add_argument("--version", required=True)
|
||||
for distro in PROFILES:
|
||||
prepare_parser.add_argument("--" + distro, type=Path, required=True)
|
||||
prepare_parser.add_argument("--public-key", type=Path, required=True)
|
||||
prepare_parser.add_argument("--epoch", type=int)
|
||||
prepare_parser.add_argument("--source", type=Path, help="Reviewed corresponding project-source tar.gz")
|
||||
prepare_parser.add_argument("--dependency-source", type=Path, action="append", default=[],
|
||||
help="Reviewed dependency-source archive with its unchanged sibling .asc")
|
||||
for name in ("sign", "verify"):
|
||||
sub = commands.add_parser(name)
|
||||
sub.add_argument("--output", type=Path, required=True)
|
||||
sub.add_argument("--manifest-sha256", required=True)
|
||||
if name == "sign":
|
||||
sub.add_argument("--key-home", type=Path,
|
||||
default=Path.home() / ".local/share/framebridge-release-signing")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.command == "prepare":
|
||||
prepare(args)
|
||||
elif args.command == "sign":
|
||||
sign(args)
|
||||
else:
|
||||
manifest = check_inputs(args.output, args.manifest_sha256)
|
||||
print(json.dumps(verify_signatures(args.output, manifest), sort_keys=True))
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
print(f"FrameBridge APT repository: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+227
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish a reviewed release by normal authenticated fast-forward Git push."""
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
REPOSITORY = "http://127.0.0.1:3005/gerardo/framebridge.git"
|
||||
API = "http://127.0.0.1:3005/api/v1"
|
||||
CONTAINER = "geranas-gitea"
|
||||
|
||||
def require(value, message):
|
||||
if not value:
|
||||
raise ValueError(message)
|
||||
|
||||
def run(args, **kwargs):
|
||||
return subprocess.run([str(x) for x in args], check=True, capture_output=True, **kwargs)
|
||||
|
||||
def remote_refs():
|
||||
raw = run(["git", "-c", "credential.helper=", "ls-remote", REPOSITORY]).stdout.decode()
|
||||
return dict((ref, sha) for sha, ref in (line.split() for line in raw.splitlines()))
|
||||
|
||||
def api_request(method, path, token):
|
||||
request = urllib.request.Request(API + path, method=method,
|
||||
headers={"Authorization": "Basic " + base64.b64encode(("gerardo:" + token).encode()).decode()})
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
return response.status, response.read()
|
||||
|
||||
def check_bundle(args, source):
|
||||
data = args.bundle.read_bytes()
|
||||
require(hashlib.sha256(data).hexdigest() == args.bundle_sha256, "Bundle hash mismatch")
|
||||
run(["git", "clone", "--bare", "--no-hardlinks", args.bundle, source])
|
||||
run(["git", "--git-dir", source, "fsck", "--full"])
|
||||
actual = run(["git", "--git-dir", source, "rev-parse", args.commit + "^{commit}"]).stdout.decode().strip()
|
||||
require(actual == args.commit, "Reviewed release commit missing")
|
||||
run(["git", "--git-dir", source, "merge-base", "--is-ancestor", args.old_commit, args.commit])
|
||||
resolved = run(["git", "--git-dir", source, "rev-parse", args.tag + "^{}"]).stdout.decode().strip()
|
||||
require(resolved == args.commit, "Release tag does not point to reviewed commit")
|
||||
refs = remote_refs()
|
||||
require(refs.get("refs/heads/main") == args.old_commit, "Canonical main changed; review before publishing")
|
||||
require("refs/tags/" + args.tag not in refs, "Release tag already exists; never overwrite")
|
||||
return refs
|
||||
|
||||
def record(path, state):
|
||||
temporary = path.with_suffix(".partial")
|
||||
with open(temporary, "x", encoding="utf-8") as stream:
|
||||
json.dump(state, stream, indent=2, sort_keys=True)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
temporary.replace(path)
|
||||
|
||||
def askpass_server(directory, token):
|
||||
address = str(directory / "credential.sock")
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(address)
|
||||
os.chmod(address, 0o600)
|
||||
server.listen(2)
|
||||
server.settimeout(0.5)
|
||||
stop = threading.Event()
|
||||
def serve():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
connection, _ = server.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
with connection:
|
||||
connection.settimeout(3)
|
||||
request = connection.recv(32)
|
||||
if request == b"username":
|
||||
connection.sendall(b"gerardo\n")
|
||||
elif request == b"password":
|
||||
connection.sendall(token.encode() + b"\n")
|
||||
thread = threading.Thread(target=serve, daemon=True)
|
||||
thread.start()
|
||||
client = directory / "askpass"
|
||||
client.write_text(
|
||||
"#!/usr/bin/python3\nimport socket,sys\n"
|
||||
"p=sys.argv[1].lower()\n"
|
||||
"kind=b'username' if p.startswith('username') else b'password' if p.startswith('password') else None\n"
|
||||
"if kind is None: raise SystemExit(1)\n"
|
||||
"s=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)\n"
|
||||
f"s.connect({address!r})\ns.sendall(kind)\ns.shutdown(socket.SHUT_WR)\n"
|
||||
"data=b''\n"
|
||||
"while True:\n chunk=s.recv(4096)\n if not chunk: break\n data+=chunk\n"
|
||||
"sys.stdout.buffer.write(data)\n")
|
||||
client.chmod(0o700)
|
||||
return server, stop, thread, client
|
||||
|
||||
def root_directory(path):
|
||||
require(path.is_absolute() and path.resolve(strict=True) == path, "Root staging path must be absolute without symlink parents")
|
||||
require(path.is_dir() and path.stat().st_mode & 0o077 == 0, "Root staging directory must be private")
|
||||
for current in (path, *path.parents):
|
||||
metadata = current.stat()
|
||||
require(metadata.st_uid == 0 and metadata.st_mode & 0o022 == 0,
|
||||
"Root staging path has a non-root or shared-writable ancestor")
|
||||
|
||||
def publish(args):
|
||||
require(os.geteuid() == 0 and socket.gethostname().lower() == "geranas",
|
||||
"Run on GERANAS using the authorized normal administrator context")
|
||||
for sha in (args.bundle_sha256,):
|
||||
require(re.fullmatch(r"[a-f0-9]{64}", sha), "Invalid bundle SHA-256")
|
||||
for sha in (args.commit, args.old_commit):
|
||||
require(re.fullmatch(r"[a-f0-9]{40}", sha), "Invalid source commit")
|
||||
require(re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", args.tag), "Invalid release tag")
|
||||
require(args.bundle.is_file() and not args.bundle.is_symlink(), "Unsafe bundle path")
|
||||
bundle_stat = args.bundle.stat()
|
||||
require(bundle_stat.st_uid == 0 and bundle_stat.st_mode & 0o022 == 0 and bundle_stat.st_nlink == 1,
|
||||
"Bundle must be a root-owned reviewed regular file without shared write access")
|
||||
root_directory(args.bundle.parent)
|
||||
os.umask(0o077)
|
||||
with tempfile.TemporaryDirectory(prefix="fb-gitea-push-", dir="/run") as tmp:
|
||||
directory = Path(tmp)
|
||||
source = directory / "source.git"
|
||||
before = check_bundle(args, source)
|
||||
state = {"schema": 1, "repository": "gerardo/framebridge", "old_commit": args.old_commit,
|
||||
"commit": args.commit, "tag": args.tag, "bundle_sha256": args.bundle_sha256,
|
||||
"phase": "CHECKED", "push_performed": False, "token_revoked": False}
|
||||
if not args.apply:
|
||||
print(json.dumps(state, sort_keys=True))
|
||||
return
|
||||
require(args.journal is not None and args.journal.parent.is_dir(), "Explicit existing journal directory required")
|
||||
root_directory(args.journal.parent)
|
||||
require(not args.journal.exists() and not args.journal.is_symlink(), "Existing transaction; never replay blindly")
|
||||
# Reserve the exact new transaction before minting a credential.
|
||||
descriptor = os.open(args.journal, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
os.close(descriptor)
|
||||
record(args.journal, state)
|
||||
token_name = "framebridge-release-" + uuid.uuid4().hex
|
||||
state["temporary_token_name"] = token_name
|
||||
state["phase"] = "TOKEN_CREATION_REQUESTED"
|
||||
record(args.journal, state)
|
||||
token = None
|
||||
failure = None
|
||||
server = stop = thread = None
|
||||
try:
|
||||
# Capture the token only in this process's memory. Never log this result.
|
||||
result = run(["docker", "exec", "--user", "git", CONTAINER, "gitea",
|
||||
"--config", "/etc/gitea/app.ini", "admin", "user", "generate-access-token",
|
||||
"--username", "gerardo", "--token-name", token_name, "--raw",
|
||||
"--scopes", "write:repository,write:user"])
|
||||
token = result.stdout.decode().strip()
|
||||
require(re.fullmatch(r"[a-fA-F0-9]{40,128}", token), "Unexpected credential response; token needs owner review")
|
||||
state["phase"] = "TEMPORARY_TOKEN_CREATED"
|
||||
record(args.journal, state)
|
||||
# The installed token-management route requires Basic authentication.
|
||||
# Gitea accepts a PAT as the Basic password; prove that gate before pushing.
|
||||
status, _ = api_request("GET", "/users/gerardo/tokens?limit=1", token)
|
||||
require(status == 200, "Normal Basic token-management authentication failed")
|
||||
state["token_management_auth_verified"] = True
|
||||
record(args.journal, state)
|
||||
server, stop, thread, client = askpass_server(directory, token)
|
||||
env = dict(os.environ, GIT_ASKPASS=str(client), GIT_TERMINAL_PROMPT="0", LC_ALL="C")
|
||||
# No force flag. The normal receive-pack checks and Gitea hooks run.
|
||||
run(["git", "--git-dir", source, "-c", "credential.helper=", "push", "--atomic",
|
||||
REPOSITORY, args.commit + ":refs/heads/main", "refs/tags/" + args.tag], env=env, timeout=120)
|
||||
state["push_performed"] = True
|
||||
state["phase"] = "PUSH_COMPLETED"
|
||||
after = remote_refs()
|
||||
require(after.get("refs/heads/main") == args.commit, "Main ref readback mismatch")
|
||||
resolved_tag = after.get("refs/tags/" + args.tag + "^{}", after.get("refs/tags/" + args.tag))
|
||||
require(resolved_tag == args.commit, "Release tag readback mismatch")
|
||||
state["refs_verified"] = True
|
||||
record(args.journal, state)
|
||||
except Exception as error:
|
||||
# Commands never contain the credential; do not print captured HTTP/Git output.
|
||||
failure = f"{type(error).__name__}: {error}"
|
||||
state["failure"] = failure
|
||||
finally:
|
||||
if stop is not None:
|
||||
stop.set()
|
||||
thread.join(timeout=2)
|
||||
server.close()
|
||||
if token is not None:
|
||||
try:
|
||||
status, _ = api_request("DELETE", "/users/gerardo/tokens/" +
|
||||
urllib.parse.quote(token_name, safe=""), token)
|
||||
require(status == 204, "Unexpected token revocation response")
|
||||
try:
|
||||
api_request("GET", "/user", token)
|
||||
except urllib.error.HTTPError as error:
|
||||
require(error.code == 401, "Revoked credential did not return HTTP 401")
|
||||
else:
|
||||
raise ValueError("Credential remains accepted after revocation")
|
||||
state["token_revoked"] = True
|
||||
except Exception as error:
|
||||
state["revocation_failure"] = f"{type(error).__name__}: {error}"
|
||||
failure = failure or "Temporary token revocation requires review"
|
||||
token = None
|
||||
state["phase"] = ("COMPLETED" if state.get("refs_verified") and
|
||||
state["token_revoked"] and failure is None else "REVIEW_REQUIRED")
|
||||
record(args.journal, state)
|
||||
print(json.dumps(state, sort_keys=True))
|
||||
require(failure is None, failure)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--bundle", type=Path, required=True)
|
||||
parser.add_argument("--bundle-sha256", required=True)
|
||||
parser.add_argument("--commit", required=True)
|
||||
parser.add_argument("--old-commit", required=True)
|
||||
parser.add_argument("--tag", required=True)
|
||||
parser.add_argument("--journal", type=Path)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
publish(args)
|
||||
except Exception as error:
|
||||
print(f"FrameBridge Gitea publication: {type(error).__name__}: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run source tests with generated fixtures and an isolated application environment."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def main():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
qa = root / "source/qa"
|
||||
qa.mkdir(mode=0o700, exist_ok=True)
|
||||
info = qa.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
||||
raise SystemExit("The generated source/qa directory must be private and owned by the test user.")
|
||||
environment = {key: value for key, value in os.environ.items()
|
||||
if not key.startswith(("FRAMEBRIDGE_", "FBTERM_", "FB_"))}
|
||||
with tempfile.TemporaryDirectory(prefix="unit-test-home-", dir=qa) as home:
|
||||
environment.update(
|
||||
HOME=home, XDG_DATA_HOME=str(Path(home) / ".local/share"),
|
||||
XDG_CONFIG_HOME=str(Path(home) / ".config"),
|
||||
PYTHONPATH=str(root / "source/expanded"), PYTHONDONTWRITEBYTECODE="1")
|
||||
result = subprocess.run([sys.executable, "-m", "unittest", "discover", "-s",
|
||||
str(root / "source/tests")], cwd=root, env=environment)
|
||||
return result.returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Integration checks with disposable DEBs and a disposable signing identity."""
|
||||
import argparse
|
||||
import contextlib
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
spec = importlib.util.spec_from_file_location("apt_builder", Path(__file__).with_name("build_apt_repository.py"))
|
||||
builder = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(builder)
|
||||
|
||||
class RepositoryTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.temp = tempfile.TemporaryDirectory(prefix="framebridge-apt-tests-")
|
||||
cls.base = Path(cls.temp.name)
|
||||
cls.key_home = cls.base / "test-key"
|
||||
cls.key_home.mkdir(mode=0o700)
|
||||
subprocess.run(["gpg", "--no-options", "--homedir", str(cls.key_home), "--batch",
|
||||
"--pinentry-mode", "loopback", "--passphrase", "", "--quick-generate-key",
|
||||
"FrameBridge disposable test <test@invalid>", "ed25519", "sign", "0"],
|
||||
check=True, capture_output=True)
|
||||
result = subprocess.run(["gpg", "--no-options", "--homedir", str(cls.key_home),
|
||||
"--with-colons", "--list-keys"], check=True, capture_output=True).stdout.decode()
|
||||
cls.original_fingerprint = builder.FINGERPRINT
|
||||
builder.FINGERPRINT = next(x.split(":")[9] for x in result.splitlines() if x.startswith("fpr:"))
|
||||
cls.public_key = cls.base / "test.asc"
|
||||
cls.public_key.write_bytes(subprocess.run(
|
||||
["gpg", "--no-options", "--homedir", str(cls.key_home), "--armor", "--export", builder.FINGERPRINT],
|
||||
check=True, capture_output=True).stdout)
|
||||
cls.debs = {}
|
||||
for distro, (_, _, _, python) in builder.PROFILES.items():
|
||||
tree = cls.base / distro
|
||||
(tree / "DEBIAN").mkdir(parents=True)
|
||||
(tree / "DEBIAN").chmod(0o755)
|
||||
(tree / "DEBIAN/control").write_text(
|
||||
f"Package: {builder.PACKAGE}\nVersion: 0.3.3\nArchitecture: amd64\n"
|
||||
f"Maintainer: FrameBridge <test@invalid>\nDepends: python3 (>= {python})\n"
|
||||
f"Description: Disposable {distro} test package\n")
|
||||
(tree / "distribution.txt").write_text(distro)
|
||||
deb = cls.base / (distro + ".deb")
|
||||
subprocess.run(["dpkg-deb", "--build", str(tree), str(deb)], check=True, capture_output=True)
|
||||
cls.debs[distro] = deb
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
subprocess.run(["gpgconf", "--homedir", str(cls.key_home), "--kill", "gpg-agent"], capture_output=True)
|
||||
builder.FINGERPRINT = cls.original_fingerprint
|
||||
cls.temp.cleanup()
|
||||
|
||||
def setUp(self):
|
||||
self.output = Path(tempfile.mkdtemp(dir=self.base, prefix="output-")) / "repository"
|
||||
|
||||
def prepare(self, **changes):
|
||||
args = argparse.Namespace(output=self.output, version="0.3.3", public_key=self.public_key,
|
||||
epoch=1793199600, **self.debs)
|
||||
for key, value in changes.items():
|
||||
setattr(args, key, value)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
builder.prepare(args)
|
||||
raw = (self.output / "APT-INPUT-MANIFEST.json").read_bytes()
|
||||
return hashlib.sha256(raw).hexdigest(), json.loads(raw)
|
||||
|
||||
def sign_test_releases(self):
|
||||
for source, signed, clearsign in builder.signature_paths(self.output, sign_only=True):
|
||||
subprocess.run(["gpg", "--no-options", "--homedir", str(self.key_home), "--batch",
|
||||
"--pinentry-mode", "loopback", "--passphrase", "", "--local-user", builder.FINGERPRINT,
|
||||
"--digest-algo", "SHA256", "--output", str(signed),
|
||||
"--clearsign" if clearsign else "--detach-sign", str(source)],
|
||||
check=True, capture_output=True)
|
||||
|
||||
def test_actual_apt_update_accepts_three_signed_suites(self):
|
||||
expected, manifest = self.prepare()
|
||||
self.sign_test_releases()
|
||||
self.assertTrue(builder.verify_signatures(self.output, manifest)["verified"])
|
||||
for suite, *_ in builder.PROFILES.values():
|
||||
isolated = self.output.parent / ("apt-" + suite)
|
||||
isolated.mkdir()
|
||||
(isolated / "state/lists/partial").mkdir(parents=True)
|
||||
(isolated / "cache/archives/partial").mkdir(parents=True)
|
||||
source = isolated / "sources.list"
|
||||
source.write_text(f"deb [arch=amd64 signed-by={self.public_key}] file:{self.output} {suite} main\n")
|
||||
result = subprocess.run(["apt-get", "-o", f"Dir::State={isolated}/state",
|
||||
"-o", f"Dir::Cache={isolated}/cache", "-o", f"Dir::Etc::sourcelist={source}",
|
||||
"-o", "Dir::Etc::sourceparts=-", "-o", "Acquire::Languages=none",
|
||||
"-o", "Debug::NoLocking=1", "update"], capture_output=True, text=True, timeout=35)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("Reading package lists", result.stdout)
|
||||
self.assertEqual(builder.check_inputs(self.output, expected)["version"], "0.3.3")
|
||||
|
||||
def test_altered_package_is_rejected_before_signing(self):
|
||||
expected, manifest = self.prepare()
|
||||
p = self.output / manifest["artifacts"][1]["filename"]
|
||||
p.write_bytes(p.read_bytes() + b"changed")
|
||||
with self.assertRaisesRegex(ValueError, "Input changed"):
|
||||
builder.check_inputs(self.output, expected)
|
||||
|
||||
def test_altered_index_is_rejected(self):
|
||||
expected, _ = self.prepare()
|
||||
(self.output / "dists/noble/main/binary-amd64/Packages").write_text("altered")
|
||||
with self.assertRaisesRegex(ValueError, "Input changed"):
|
||||
builder.check_inputs(self.output, expected)
|
||||
|
||||
def test_wrong_version_does_not_create_repository(self):
|
||||
with self.assertRaisesRegex(ValueError, "wrong version"):
|
||||
self.prepare(version="0.3.4")
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_existing_output_never_overwritten(self):
|
||||
self.output.mkdir()
|
||||
sentinel = self.output / "sentinel"
|
||||
sentinel.write_text("published")
|
||||
with self.assertRaisesRegex(ValueError, "never overwrite"):
|
||||
self.prepare()
|
||||
self.assertEqual(sentinel.read_text(), "published")
|
||||
|
||||
def test_manifest_traversal_is_rejected(self):
|
||||
_, manifest = self.prepare()
|
||||
manifest["files"]["../outside"] = {"bytes": 0, "sha256": hashlib.sha256(b"").hexdigest()}
|
||||
raw = json.dumps(manifest).encode()
|
||||
(self.output / "APT-INPUT-MANIFEST.json").write_bytes(raw)
|
||||
with self.assertRaisesRegex(ValueError, "Unsafe manifest path"):
|
||||
builder.check_inputs(self.output, hashlib.sha256(raw).hexdigest())
|
||||
|
||||
def test_public_key_bundle_with_extra_primary_key_is_rejected(self):
|
||||
bundle = self.output.parent / "extra-primary-key.asc"
|
||||
bundle.write_bytes(self.public_key.read_bytes() + self.public_key.read_bytes())
|
||||
with self.assertRaisesRegex(ValueError, "exactly one official primary"):
|
||||
self.prepare(public_key=bundle)
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
|
||||
def test_one_release_manifest_covers_downloads_and_preserves_dependency_signatures(self):
|
||||
source = self.output.parent / "FrameBridge-0.3.3-source.tar.gz"
|
||||
source.write_bytes(b"disposable source-archive fixture")
|
||||
original = builder.DEPENDENCY_SOURCE_ASSETS
|
||||
dependencies = []
|
||||
signatures = {}
|
||||
try:
|
||||
builder.DEPENDENCY_SOURCE_ASSETS = {}
|
||||
for index, name in enumerate(original):
|
||||
path = self.output.parent / name
|
||||
data = ("disposable dependency " + str(index)).encode()
|
||||
path.write_bytes(data)
|
||||
builder.DEPENDENCY_SOURCE_ASSETS[name] = (len(data), hashlib.sha256(data).hexdigest())
|
||||
signature = path.with_name(name + ".asc")
|
||||
subprocess.run(["gpg", "--no-options", "--homedir", str(self.key_home), "--batch",
|
||||
"--pinentry-mode", "loopback", "--passphrase", "", "--local-user", builder.FINGERPRINT,
|
||||
"--armor", "--output", str(signature), "--detach-sign", str(path)], check=True, capture_output=True)
|
||||
signatures[name] = signature.read_bytes()
|
||||
dependencies.append(path)
|
||||
expected, manifest = self.prepare(source=source, dependency_source=dependencies)
|
||||
self.assertEqual(len(manifest["download_assets"]), 7)
|
||||
for path in dependencies:
|
||||
self.assertEqual((self.output / "downloads" / (path.name + ".asc")).read_bytes(),
|
||||
signatures[path.name])
|
||||
self.sign_test_releases()
|
||||
self.assertTrue(builder.verify_signatures(self.output, manifest)["verified"])
|
||||
self.assertEqual(builder.check_inputs(self.output, expected)["version"], "0.3.3")
|
||||
source_copy = self.output / "downloads" / source.name
|
||||
source_copy.write_bytes(b"altered")
|
||||
with self.assertRaisesRegex(ValueError, "Input changed"):
|
||||
builder.check_inputs(self.output, expected)
|
||||
finally:
|
||||
builder.DEPENDENCY_SOURCE_ASSETS = original
|
||||
|
||||
def test_changed_signed_release_fails_verification(self):
|
||||
_, manifest = self.prepare()
|
||||
self.sign_test_releases()
|
||||
path = self.output / "dists/trixie/InRelease"
|
||||
path.write_bytes(path.read_bytes().replace(b"Components: main", b"Components: evil"))
|
||||
with self.assertRaises(subprocess.CalledProcessError):
|
||||
builder.verify_signatures(self.output, manifest)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the memory-only credential transport without real credentials or networking."""
|
||||
import importlib.util
|
||||
import base64
|
||||
from unittest import mock
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
spec = importlib.util.spec_from_file_location("publisher", Path(__file__).with_name("publish_gitea.py"))
|
||||
publisher = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(publisher)
|
||||
|
||||
class AskpassTests(unittest.TestCase):
|
||||
def test_transport_keeps_disposable_token_out_of_script_and_environment(self):
|
||||
with tempfile.TemporaryDirectory(prefix="framebridge-askpass-test-") as tmp:
|
||||
token = "a" * 40 # Disposable test data, not a credential.
|
||||
server, stop, thread, client = publisher.askpass_server(Path(tmp), token)
|
||||
try:
|
||||
self.assertEqual(stat.S_IMODE(client.stat().st_mode), 0o700)
|
||||
self.assertEqual(stat.S_IMODE((Path(tmp) / "credential.sock").stat().st_mode), 0o600)
|
||||
self.assertNotIn(token, client.read_text())
|
||||
for prompt, expected in [("Username for 'http://127.0.0.1:3005':", b"gerardo\n"),
|
||||
("Password for 'http://gerardo@127.0.0.1:3005':", (token + "\n").encode())]:
|
||||
result = subprocess.run([str(client), prompt], capture_output=True, check=True, timeout=5)
|
||||
self.assertEqual(result.stdout, expected)
|
||||
self.assertEqual(result.stderr, b"")
|
||||
finally:
|
||||
stop.set()
|
||||
thread.join(timeout=2)
|
||||
server.close()
|
||||
|
||||
def test_unknown_prompt_returns_no_token(self):
|
||||
with tempfile.TemporaryDirectory(prefix="framebridge-askpass-test-") as tmp:
|
||||
server, stop, thread, client = publisher.askpass_server(Path(tmp), "b" * 40)
|
||||
try:
|
||||
result = subprocess.run([str(client), "Unexpected prompt"], capture_output=True, timeout=5)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(result.stdout, b"")
|
||||
finally:
|
||||
stop.set()
|
||||
thread.join(timeout=2)
|
||||
server.close()
|
||||
|
||||
def test_token_management_uses_normal_basic_auth_in_memory(self):
|
||||
response = mock.MagicMock()
|
||||
response.__enter__.return_value.status = 204
|
||||
response.__enter__.return_value.read.return_value = b""
|
||||
disposable = "c" * 40
|
||||
with mock.patch.object(publisher.urllib.request, "urlopen", return_value=response) as opener:
|
||||
status, _ = publisher.api_request("DELETE", "/users/gerardo/tokens/disposable", disposable)
|
||||
self.assertEqual(status, 204)
|
||||
request = opener.call_args.args[0]
|
||||
expected = "Basic " + base64.b64encode(("gerardo:" + disposable).encode()).decode()
|
||||
self.assertEqual(request.get_header("Authorization"), expected)
|
||||
self.assertNotIn(disposable, request.full_url)
|
||||
|
||||
def test_incomplete_journal_is_never_overwritten(self):
|
||||
with tempfile.TemporaryDirectory(prefix="framebridge-journal-test-") as tmp:
|
||||
target = Path(tmp) / "transaction.json"
|
||||
partial = target.with_suffix(".partial")
|
||||
partial.write_text("unreviewed incomplete state")
|
||||
with self.assertRaises(FileExistsError):
|
||||
publisher.record(target, {"phase": "NEW"})
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(partial.read_text(), "unreviewed incomplete state")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -2,3 +2,5 @@ dist/
|
||||
qa/
|
||||
release.json
|
||||
__pycache__/
|
||||
assets/framebridge-preview-0.3.1-original-r4.mp4
|
||||
assets/framebridge-preview-0.3.1-original-r4.jpg
|
||||
+47
-1
@@ -1,3 +1,49 @@
|
||||
# FrameBridge website source
|
||||
|
||||
The website implementation follows the project GPL-3.0-only license. Project video is delivered separately. Copy release.example.json to release.json, add the supplied conceptual preview to assets/framebridge-preview.mp4, then run python3 -I -S build_site.py and python3 -I -S qa_site.py. The generator reads ../source/docs/COMMAND-GUIDE.json in this source layout. Production signing metadata must be supplied by a maintainer; never fabricate fingerprints or signatures.
|
||||
The original visual design and renderer are retained. The main workflow is
|
||||
administration of a home Linux network by one human administrator. Compatible
|
||||
desktop access is an auxiliary tool.
|
||||
|
||||
The canonical source repository is the existing GERANAS Gitea project
|
||||
https://framebridge.betancourt.one/git/gerardo/framebridge. CLOUD provides the
|
||||
existing HTTPS reverse proxy; GERANAS serves the website, downloads and APT
|
||||
repository. Do not substitute a static Git mirror or another hosting backend.
|
||||
|
||||
Copy release.example.json to release.json, then fill it from the exact verified
|
||||
release inventory. Its three profiles are Debian 13 amd64/Python 3.13 (trixie),
|
||||
Ubuntu 24.04 amd64/Python 3.12 (noble), and Linux Mint 22.x amd64/Python 3.12
|
||||
(mint22). A preparing catalog offers no new download or installation commands.
|
||||
Use ready only after verifying all DEBs, corresponding source, dependency
|
||||
source archives, signatures, checksums and the official public fingerprint.
|
||||
Metadata describes release artifacts; it is not itself evidence that a public
|
||||
hostname is operational.
|
||||
|
||||
Project media is delivered separately. Copy the approved original R4 MP4 and
|
||||
JPEG into assets under their original filenames. The renderer verifies these
|
||||
identities before a ready build:
|
||||
|
||||
- framebridge-preview-0.3.1-original-r4.mp4:
|
||||
b2a8b0b302063edbe05d84fb6dadb7f303a6f0f1b6d79dc151287df4671adc5b
|
||||
- framebridge-preview-0.3.1-original-r4.jpg:
|
||||
2e867d4b6a22f81ecbefa293a88799b9e3c93f02aa7d1090c7f0a0dbfe44dc9f
|
||||
|
||||
The approved animation and original music remain unchanged. The video is
|
||||
identified as a 0.3.1 conceptual demonstration; it does not claim to be a live
|
||||
recording of the current release.
|
||||
|
||||
Run:
|
||||
|
||||
python3 -I -S build_site.py
|
||||
python3 -I -S qa_site.py
|
||||
python3 -I -S test_release_catalog.py
|
||||
|
||||
The generator reads ../source/docs/COMMAND-GUIDE.json and requires its version
|
||||
to match the release. QA checks local links/assets, loopback HTTP, the guarded
|
||||
ready renderer with a disposable synthetic inventory, and syntax of all three
|
||||
APT setup blocks. The test fixtures never sign, publish or install anything.
|
||||
Perform final HTTPS, Gitea clone and isolated APT verification through the
|
||||
actual public domain before reporting publication complete.
|
||||
|
||||
The website follows the project's GPL-3.0-only license. Third-party materials
|
||||
retain their own licenses and notices. Support is voluntary; the contact and
|
||||
PayPal account supplied by the project owner is gerardobh1128@gmail.com.
|
||||
+20
-64
@@ -1,25 +1,25 @@
|
||||
#!/usr/bin/python3
|
||||
"""Build the self-hosted FrameBridge website with the Python standard library."""
|
||||
from pathlib import Path
|
||||
import html,json,re,shutil,sys
|
||||
import html,json,re,shutil,sys,runpy,hashlib
|
||||
|
||||
BASE=Path(__file__).resolve().parent
|
||||
ROOT=BASE.parent
|
||||
DIST=BASE/'dist'
|
||||
META=json.loads((BASE/'release.json').read_text())
|
||||
META.setdefault('git_url','/git/framebridge/framebridge')
|
||||
META.setdefault('git_clone_url','https://framebridge.betancourt.one/git/framebridge/framebridge.git')
|
||||
META.setdefault('git_url','/git/gerardo/framebridge')
|
||||
META.setdefault('git_clone_url','https://framebridge.betancourt.one/git/gerardo/framebridge.git')
|
||||
E=lambda value:html.escape(str(value),quote=True)
|
||||
READY=META.get('state')=='ready'
|
||||
if READY:
|
||||
assert re.fullmatch(r'[a-fA-F0-9]{64}',META.get('sha256','')),'Ready release requires the exact package SHA-256.'
|
||||
assert re.fullmatch(r'[a-fA-F0-9]{40}|[a-fA-F0-9]{64}',META.get('signing_fingerprint','')),'Ready release requires a public signing fingerprint.'
|
||||
assert META.get('source_archive_url') and META.get('package_signature_url') and META.get('size_bytes'),'Ready release requires source, signature and size metadata.'
|
||||
deps=META.get('dependency_sources',[])
|
||||
assert len(deps)>=2 and all(item.get('url','').startswith('/downloads/') and re.fullmatch(r'[a-fA-F0-9]{64}',item.get('sha256','')) and item.get('size_bytes') for item in deps),'Ready release requires all dependency-source archives and their exact checksums.'
|
||||
for name in ['git_url','package_url','package_signature_url','checksums_url','checksums_signature_url','source_archive_url','signing_key_url']:
|
||||
value=META.get(name,'')
|
||||
assert not value or value.startswith('/') or value.startswith('https://framebridge.betancourt.one/'),'Only project-owned HTTPS or root-relative artifact URLs are allowed.'
|
||||
CAT=runpy.run_path(str(BASE/'release_catalog.py'))
|
||||
CAT['validate'](META,READY)
|
||||
VIDEO=CAT['VIDEO'];POSTER=CAT['POSTER']
|
||||
for name,expected in [(VIDEO,CAT['VIDEO_SHA256']),(POSTER,CAT['POSTER_SHA256'])]:
|
||||
asset=BASE/'assets'/name
|
||||
if asset.exists():
|
||||
assert hashlib.sha256(asset.read_bytes()).hexdigest()==expected,'Approved original media changed.'
|
||||
elif READY:
|
||||
raise ValueError('Approved original media is required before publication.')
|
||||
for path in ['assets','docs','download','blog/2026-10-26-opening-framebridge','community','support']:
|
||||
(DIST/path).mkdir(parents=True,exist_ok=True)
|
||||
for p in (BASE/'src').iterdir():
|
||||
@@ -48,59 +48,15 @@ def code(text):
|
||||
return '<div class="codebox"><pre><code>'+E(text)+'</code></pre></div>'
|
||||
|
||||
release_label='Official release' if READY else 'Release preparation'
|
||||
home='''<main id="content"><div class="wrap"><section class="hero"><div><div class="eyebrow">Linux systems · Your ChatGPT account</div><h1>Your computers.<br>A working conversation.</h1><p class="lead">Bring commands, persistent terminals and a compatible desktop into your conversation with ChatGPT. Work on Linux systems you control, through one bridge.</p><div class="actions"><a class="button primary" href="/download/">Get FrameBridge <span aria-hidden="true"> ↗</span></a><a class="button" href="#preview">Watch the concept</a></div><p class="micro">GNU GPLv3 · Debian 13 · Manual connection to your own account</p></div><div><div class="terminal" aria-label="Illustrative command workflow"><div class="terminal-top"><span>FRAMEBRIDGE / EXAMPLE WORKFLOW</span><span aria-hidden="true">— □ ×</span></div><div class="terminal-body"><div class="term-row"><p class="term-command"><span>$</span>fb connect</p><div class="term-note">Connect your own account through the guided terminal flow.</div></div><div class="term-row"><p class="term-command"><span>$</span>fb status</p><div class="term-note">See tools, connection and startup state in one place.</div></div><div class="term-row"><p class="term-command"><span>$</span>fb console</p><div class="term-note">Return to your work view: Bash and FBTERM activity.</div></div></div></div><p class="terminal-caption">Illustrative commands. This is not a screen capture.</p></div></section><div class="strip rule"><span>Local execution</span><span>Persistent terminals</span><span>Graphical + text tools</span><span>Central + nodes</span></div></div>
|
||||
<section class="section rule" id="preview"><div class="wrap"><div class="section-head"><div><p class="section-label">01 / A conversation takes shape</p><h2>From a blinking cursor<br>to a connected workflow.</h2></div><p>A conceptual look at independent tasks, consoles and connected computers. The software runs your tools on your systems; the ChatGPT model remains hosted.</p></div><div class="video-shell"><video controls preload="metadata" poster="/assets/video-poster.svg" aria-label="FrameBridge conceptual preview animation"><source src="/assets/framebridge-preview.mp4" type="video/mp4"><p>Your browser cannot play this video. <a href="/assets/framebridge-preview.mp4">Download the MP4 preview.</a></p></video></div><p class="video-caption"><strong>Conceptual animation — not a screen recording.</strong><span>Original RC7 concept preview. Features and commands are documented for the current release below.</span></p></div></section>
|
||||
<section class="section rule"><div class="wrap workflow"><div><p class="section-label">02 / The bridge</p><h2>Start with one machine.<br>Extend when you need to.</h2><p class="lead">A standalone computer can provide local tools. A central installation can also connect approved nodes.</p><div class="architecture"><div class="architecture-title">Where work happens</div><div class="architecture-line"><span>Your ChatGPT account</span><span class="arrow" aria-hidden="true">→</span><span>FrameBridge</span><span class="arrow" aria-hidden="true">→</span><span>Your Linux systems</span></div></div><p class="note">No phone pairing is required. Initial account consent and credentials stay in the normal browser or hidden terminal prompts.</p></div><ol class="workflow-list"><li><span class="step-no">01</span><div><h3>Install and connect</h3><p>Install the Debian package, then use the guided text interface to connect your own account. Advanced role setup is optional for ordinary standalone use.</p></div></li><li><span class="step-no">02</span><div><h3>Choose the right tool</h3><p>Use FBEXEC for commands, persistent terminals for ongoing shell work, and graphical CUA when a compatible desktop is available.</p></div></li><li><span class="step-no">03</span><div><h3>Keep the context</h3><p>Return to a live terminal session without rebuilding its shell state between calls. Running processes do not survive an operating-system reboot.</p></div></li><li><span class="step-no">04</span><div><h3>Add approved nodes</h3><p>Use the central instance for ChatGPT transport and select enrolled nodes explicitly. Nodes do not need a second ChatGPT tunnel.</p></div></li></ol></div></section>
|
||||
<section class="section rule"><div class="wrap"><p class="section-label">03 / A practical toolset</p><div class="feature-row"><h3>Execution</h3><p>Run authorized shell tasks on the selected machine and inspect their recorded results.</p><code>FBEXEC</code></div><div class="feature-row"><h3>Terminals</h3><p>Keep a real Bash session across calls. Inspect text, send input and return to ongoing work.</p><code>FBTERM / fb tty</code></div><div class="feature-row"><h3>Desktop and text</h3><p>Discover compatible graphical CUA automatically. Use text-terminal tools when a graphical desktop is unavailable.</p><code>CUA / CUA-TTY</code></div><div class="feature-row"><h3>People and agents</h3><p>The same command-line interface serves human operators and authorized ChatGPT agents, with discoverable usage guidance.</p><code>fb / framebridge</code></div><p class="note">Independent FBEXEC tasks have been used together in the laboratory when their objectives and changes did not overlap. This is observed practice, not an isolation guarantee. Use one controller at a time for each shared graphical desktop.</p></div></section>
|
||||
home='''<main id="content"><div class="wrap"><section class="hero"><div><div class="eyebrow">Home Linux networks · One administrator</div><h1>Your Linux network.<br>One place to administer it.</h1><p class="lead">Administer your own home Linux network through ChatGPT: inspect equipment, diagnose connections, manage services and run commands across enrolled computers. One administrator, one bridge.</p><div class="actions"><a class="button primary" href="/download/">Get FrameBridge <span aria-hidden="true"> ↗</span></a><a class="button" href="#preview">Watch the concept</a></div><p class="micro">GPL-3.0-only · Debian 13 · Ubuntu 24.04 · Linux Mint 22.x · amd64</p></div><div><div class="terminal" aria-label="Illustrative command workflow"><div class="terminal-top"><span>FRAMEBRIDGE / EXAMPLE WORKFLOW</span><span aria-hidden="true">— □ ×</span></div><div class="terminal-body"><div class="term-row"><p class="term-command"><span>$</span>fb connect</p><div class="term-note">Connect your own account through the guided terminal flow.</div></div><div class="term-row"><p class="term-command"><span>$</span>fb status</p><div class="term-note">See tools, connection and startup state in one place.</div></div><div class="term-row"><p class="term-command"><span>$</span>fb console</p><div class="term-note">Return to your work view: Bash and FBTERM activity.</div></div></div></div><p class="terminal-caption">Illustrative commands. This is not a screen capture.</p></div></section><div class="strip rule"><span>Local execution</span><span>Persistent terminals</span><span>Network administration</span><span>Central + nodes</span></div></div>
|
||||
<section class="section rule" id="preview"><div class="wrap"><div class="section-head"><div><p class="section-label">01 / A conversation takes shape</p><h2>From a blinking cursor<br>to a connected workflow.</h2></div><p>A conceptual look at independent tasks, consoles and connected computers. The software runs your tools on your systems; the ChatGPT model remains hosted.</p></div><div class="video-shell"><video controls preload="metadata" poster="/assets/framebridge-preview-0.3.1-original-r4.jpg" aria-label="FrameBridge conceptual preview animation"><source src="/assets/framebridge-preview-0.3.1-original-r4.mp4" type="video/mp4"><p>Your browser cannot play this video. <a href="/assets/framebridge-preview-0.3.1-original-r4.mp4">Download the MP4 preview.</a></p></video></div><p class="video-caption"><strong>Conceptual animation — not a screen recording.</strong><span>Approved 0.3.1 concept preview with its original music. Current release packages and commands are documented separately.</span></p></div></section>
|
||||
<section class="section rule"><div class="wrap workflow"><div><p class="section-label">02 / The bridge</p><h2>Start with one machine.<br>Extend when you need to.</h2><p class="lead">A standalone computer can provide local tools. A central installation can also connect approved nodes.</p><div class="architecture"><div class="architecture-title">Where work happens</div><div class="architecture-line"><span>Your ChatGPT account</span><span class="arrow" aria-hidden="true">→</span><span>FrameBridge</span><span class="arrow" aria-hidden="true">→</span><span>Your Linux systems</span></div></div><p class="note">No phone pairing is required. Initial account consent and credentials stay in the normal browser or hidden terminal prompts.</p></div><ol class="workflow-list"><li><span class="step-no">01</span><div><h3>Install and connect</h3><p>Install the package for your distribution, then use the guided text interface to connect your own account. Advanced role setup is optional for ordinary standalone use.</p></div></li><li><span class="step-no">02</span><div><h3>Choose the right tool</h3><p>Use FBEXEC for commands, persistent terminals for ongoing shell work, and graphical CUA when a compatible desktop is available.</p></div></li><li><span class="step-no">03</span><div><h3>Keep the context</h3><p>Return to a live terminal session without rebuilding its shell state between calls. Running processes do not survive an operating-system reboot.</p></div></li><li><span class="step-no">04</span><div><h3>Add approved nodes</h3><p>Use the central instance for ChatGPT transport and select enrolled nodes explicitly. Nodes do not need a second ChatGPT tunnel.</p></div></li></ol></div></section>
|
||||
<section class="section rule"><div class="wrap"><p class="section-label">03 / A practical toolset</p><div class="feature-row"><h3>Execution</h3><p>Run authorized shell tasks on the selected machine and inspect their recorded results.</p><code>FBEXEC</code></div><div class="feature-row"><h3>Terminals</h3><p>Keep a real Bash session across calls. Inspect text, send input and return to ongoing work.</p><code>FBTERM / fb tty</code></div><div class="feature-row"><h3>Desktop and text</h3><p>Use compatible graphical and text access as auxiliary tools for your own equipment. Commands, diagnostics and network administration remain the main workflow.</p><code>CUA / CUA-TTY</code></div><div class="feature-row"><h3>People and agents</h3><p>The same command-line interface serves human operators and authorized ChatGPT agents, with discoverable usage guidance.</p><code>fb / framebridge</code></div><p class="note">Independent FBEXEC tasks have been used together in the laboratory when their objectives and changes did not overlap. This is observed practice, not an isolation guarantee. Use one controller at a time for each shared graphical desktop.</p></div></section>
|
||||
<section class="section rule" id="download"><div class="wrap"><div class="release-panel"><div><p class="section-label">04 / Start working</p><h2>Free software.<br>Verifiable releases.</h2><p>Read the code, adapt it and contribute. Official downloads are identified by their published checksums and release signatures.</p><div class="actions"><a class="button primary" href="/download/">Download & verify</a><a class="button" href="/docs/">Read the guide</a></div></div><ul class="release-facts"><li><span>Version</span><strong>'''+E(META['version'])+'''</strong></li><li><span>Platform</span><strong>'''+E(META['distribution'])+'''</strong></li><li><span>License</span><strong>GNU GPLv3 only</strong></li><li><span>Channel</span><strong>'''+E(release_label)+'''</strong></li></ul></div></div></section>
|
||||
<section class="section rule"><div class="wrap community"><div><p class="section-label">05 / Built in the open</p><h2>A small project.<br>An open invitation.</h2><p>Good tools improve through use, careful fixes and clear documentation. Report a reproducible issue, improve the guide or propose a change in Git.</p><a class="text-link" href="/community/">How to contribute <span aria-hidden="true">→</span></a></div><div><p class="section-label">Development journal</p><a class="news-link" href="/blog/2026-10-26-opening-framebridge/"><time datetime="2026-10-26">26 October 2026 · Project notes</time><strong>Opening FrameBridge: a shared interface for people and agents.</strong><span>Read the development note →</span></a></div></div></section></main>'''
|
||||
(DIST/'index.html').write_text(page('Your computers. A working conversation.',home))
|
||||
(DIST/'index.html').write_text(page('Administer your home Linux network',home))
|
||||
|
||||
notice='' if READY else '<div class="release-notice"><strong>Release preparation.</strong> Download and installation commands will appear here once the package, corresponding source and signing information have been finalized. No placeholder checksum or key should be trusted.</div>'
|
||||
facts='<dl class="download-summary"><dt>Package</dt><dd>'+E(META['package_filename'])+'</dd><dt>Version</dt><dd>'+E(META['version'])+'</dd><dt>Platform</dt><dd>'+E(META['distribution'])+' · '+E(META['architecture'])+'</dd><dt>License</dt><dd>GNU GPLv3 only, with separate third-party notices</dd>'
|
||||
if READY:facts+='<dt>Package SHA-256</dt><dd class="hash">'+E(META['sha256'])+'</dd><dt>Signing-key fingerprint</dt><dd class="hash">'+E(META['signing_fingerprint'])+'</dd>'
|
||||
facts+='</dl>'
|
||||
manual=''
|
||||
apt=''
|
||||
if READY:
|
||||
def absolute(value):
|
||||
return value if value.startswith('https://') else META['site_url']+value
|
||||
manual_commands='''sudo apt install ca-certificates curl gnupg
|
||||
(
|
||||
set -eu
|
||||
verify_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$verify_dir"' EXIT
|
||||
curl -fsSL "'''+absolute(META['signing_key_url'])+'''" -o "$verify_dir/project.asc"
|
||||
expected="'''+META['signing_fingerprint'].upper()+'''"
|
||||
actual="$(gpg --show-keys --with-colons "$verify_dir/project.asc" 2>/dev/null | awk -F: '$1=="fpr" {print $10; exit}')"
|
||||
test "$actual" = "$expected" || { printf '%s\\n' "Signing-key fingerprint mismatch."; exit 1; }
|
||||
gpg --batch --dearmor --output "$verify_dir/project.gpg" "$verify_dir/project.asc"
|
||||
curl -fsSL "'''+absolute(META['package_url'])+'''" -o "$verify_dir/'''+META['package_filename']+'''"
|
||||
curl -fsSL "'''+absolute(META['package_signature_url'])+'''" -o "$verify_dir/package.asc"
|
||||
printf '%s %s\\n' "'''+META['sha256']+'''" "$verify_dir/'''+META['package_filename']+'''" | sha256sum --check
|
||||
gpgv --keyring "$verify_dir/project.gpg" "$verify_dir/package.asc" "$verify_dir/'''+META['package_filename']+'''"
|
||||
sudo apt install "$verify_dir/'''+META['package_filename']+'''"
|
||||
)'''
|
||||
manual='<div class="actions"><a class="button primary" href="'+E(META['package_url'])+'" download>Download .deb</a><a class="button" href="'+E(META['source_archive_url'])+'">Corresponding source</a></div><p class="micro"><a href="'+E(META['package_signature_url'])+'">Package signature</a> · <a href="'+E(META['checksums_url'])+'">SHA256SUMS</a> · <a href="'+E(META['checksums_signature_url'])+'">Signed checksums</a></p><h2>Download, verify and install</h2><p>This complete terminal flow checks the expected signing-key fingerprint, package checksum and detached signature before asking APT to install the package and resolve its dependencies.</p>'+code(manual_commands)
|
||||
source_items=''.join('<li><a href="'+E(item['url'])+'">'+E(item['label'])+'</a><br><code>'+E(item['filename'])+'</code><br><span class="hash">SHA-256: '+E(item['sha256'])+'</span></li>' for item in META['dependency_sources'])
|
||||
manual+='<h2>Complete source materials</h2><p>The matching FrameBridge source archive above and the following dependency-source materials are distributed together for this release. Preserve their license notices when redistributing.</p><ul>'+source_items+'</ul>'
|
||||
apt_commands='''sudo apt install ca-certificates curl gnupg
|
||||
(
|
||||
set -eu
|
||||
key_file="$(mktemp)"
|
||||
trap 'rm -f "$key_file"' EXIT
|
||||
curl -fsSL "'''+absolute(META['signing_key_url'])+'''" -o "$key_file"
|
||||
expected="'''+META['signing_fingerprint'].upper()+'''"
|
||||
actual="$(gpg --show-keys --with-colons "$key_file" 2>/dev/null | awk -F: '$1=="fpr" {print $10; exit}')"
|
||||
test "$actual" = "$expected" || { printf '%s\\n' "Signing-key fingerprint mismatch."; exit 1; }
|
||||
sudo install -d -m 0755 /etc/apt/keyrings
|
||||
sudo install -m 0644 "$key_file" /etc/apt/keyrings/framebridge.asc
|
||||
printf '%s\\n' "deb [signed-by=/etc/apt/keyrings/framebridge.asc] '''+META['apt_base']+''' trixie main" | sudo tee /etc/apt/sources.list.d/framebridge.list >/dev/null
|
||||
sudo apt update
|
||||
sudo apt install '''+META['package_name']+'''
|
||||
)'''
|
||||
apt='<h2>Install from the signed APT repository</h2><p>This project repository distributes FrameBridge; it is not a mirror of the full Debian archive. The signing key below is scoped to this repository with <code>Signed-By</code>.</p>'+code(apt_commands)+'<p class="aside-note">The fingerprint pins the key expected by this release page. Obtain it through this HTTPS site or another trusted project channel. Do not bypass APT signature errors.</p>'
|
||||
download='<main id="content"><div class="page-header"><div class="wrap"><p class="section-label">Download / Verify / Install</p><h1>Get FrameBridge.</h1><p class="lead">Official packages and corresponding source, with a clear chain from the release page to the file you install.</p></div></div><div class="wrap page-content download-grid"><div class="prose">'+notice+manual+apt+'<h2>After installation</h2><p>Run the normal connection flow in a terminal on the computer you are connecting:</p>'+code('fb connect\nfb status')+'<p>Use your own account. Complete browser consent normally and enter credentials only at the hidden terminal prompts. Keep secrets out of chats, issue reports and copied command arguments.</p><p>For diagnosis, use <code>fb doctor</code>. See the <a href="/docs/">versioned command guide</a> for advanced roles and operation details.</p></div><aside>'+facts+'<p class="aside-note">A signature identifies an official release. It does not prevent you from exercising the modification and redistribution rights provided by the GPL.</p><p class="aside-note">Keep independently modified builds clearly distinguishable. Only reviewed releases published through the project’s official channels should be described as official.</p><a class="text-link" href="'+E(META['git_url'])+'">Browse the source →</a></aside></div></main>'
|
||||
download=CAT['download'](META)
|
||||
(DIST/'download/index.html').write_text(page('Download and verify',download,'download'))
|
||||
|
||||
community='''<main id="content"><div class="page-header"><div class="wrap"><p class="section-label">Community / Contribution</p><h1>Make the bridge better.</h1><p class="lead">Useful bug reports, small patches and clear explanations are a good place to begin.</p></div></div><div class="wrap page-content"><article class="prose"><h2>Start with the existing behavior</h2><p>Read the versioned guide, check the installed version and look for an existing issue. A reproducible report should name the command, expected result, actual result and relevant environment.</p><p><strong>Remove credentials and personal data from logs before sharing them.</strong> Never include passwords, access keys, private keys, tokens or cookies.</p><h2>Propose a focused change</h2><p>Use an issue to explain the problem or a pull request to propose a patch. Include the reason for the change and proportionate validation. Keep unrelated edits separate so they can be reviewed.</p>'''+('<h3>Clone the project</h3>'+code('git clone '+META['git_clone_url']) if META.get('git_clone_url') else '')+'''<p><a class="button primary" href="'''+E(META['git_url'])+'''">Open the project in Git</a></p><h2>Official releases and free forks</h2><p>Maintainers review which changes become official FrameBridge releases. Published release checksums and signatures identify those artifacts.</p><p>GNU GPLv3 also allows private modifications and independently distributed forks, subject to its terms. Contributing changes upstream is welcome; it is not a condition for exercising those rights. Modified builds should be clearly marked and must not be presented as official project releases.</p><p><a href="https://www.gnu.org/licenses/gpl-3.0.html" rel="noreferrer">Read the GNU GPLv3 license.</a> Third-party components retain their existing notices and licenses.</p><h2>Using an agent to contribute</h2><p>The command interface and MCP guide are designed for both people and authorized agents. Normal operation uses installed commands and tools. Editing or rebuilding the application is a development task that should follow the owner’s instructions.</p><h2>Project credits</h2><p><strong>Programming:</strong> ChatGPT<br><strong>Original concept and project coordination:</strong> Gerardo Betancourt</p><p>FrameBridge is an independent project. The credit describes how the project was developed and does not imply endorsement by OpenAI.</p></article></div></main>'''
|
||||
@@ -108,7 +64,7 @@ community='''<main id="content"><div class="page-header"><div class="wrap"><p cl
|
||||
|
||||
post='''<main id="content"><div class="page-header"><div class="wrap"><p class="section-label">Development journal / 26 October 2026</p><h1>Opening FrameBridge.</h1><p class="lead">A shared interface for people and agents, and a small invitation to improve it together.</p></div></div><div class="wrap page-content"><article class="prose"><p>FrameBridge began with a practical goal: use a conversation with ChatGPT to do useful work on Linux computers we control. Commands, persistent terminals and a compatible desktop should fit into the same working context.</p><h2>Make the interface easier to understand</h2><p>The current development work brings everyday commands into a shorter vocabulary, keeps status observation separate from diagnosis, and explains the effects of connection and startup controls. The long command <code>framebridge</code> and its short alias <code>fb</code> use the same interface.</p><p>The agent also needs a reliable starting point. A versioned guide and a brief MCP invitation are intended to help it discover how the installed software works, which computer receives an operation and what a command will change.</p><h2>Keep the evidence honest</h2><p>The preview video is a conceptual animation, not a recording of a live session. Real demonstrations and captures must remain distinguishable from illustrations. Independent tasks have been used together in the laboratory, but that experience does not establish automatic isolation or exclusive desktop control.</p><h2>Open source, with identifiable releases</h2><p>The project is being prepared for GNU GPLv3 distribution. Sources and package artifacts should be published together, with signatures and checksums identifying official releases. Community members remain free to adapt and redistribute the software under the license.</p><p>The first contribution can be small: a clearer explanation, a reproducible issue or a focused fix. Review gives those changes a path into a later official version.</p><p><a class="text-link" href="/community/">Read the contribution guide →</a></p></article></div></main>'''
|
||||
(DIST/'blog/2026-10-26-opening-framebridge/index.html').write_text(page('Opening FrameBridge',post,'blog'))
|
||||
support='''<main id="content"><div class="page-header"><div class="wrap"><p class="section-label">Support FrameBridge</p><h1>Help keep the bridge working.</h1><p class="lead">Voluntary support can help sustain a free software project: its hosting, maintenance, documentation and development.</p></div></div><div class="wrap page-content"><article class="prose"><h2>What support can help with</h2><ul><li><strong>Hosting and distribution:</strong> keeping the source code, documentation and release files available.</li><li><strong>Maintenance:</strong> investigating reported problems and keeping the software usable.</li><li><strong>Documentation:</strong> improving clear instructions for people and agents.</li><li><strong>Development:</strong> building and reviewing focused improvements.</li></ul><h2>Funding channel</h2><p>Funding details will be published here when a channel is available.</p><p>Contributions are voluntary. FrameBridge remains free software under GNU GPLv3 only, whether or not you provide financial support.</p><h2>Support without a payment</h2><p>A useful bug report, a clearer explanation or a carefully scoped patch can be valuable contributions. You can also help by sharing honest demonstrations that distinguish real recordings from conceptual illustrations.</p><p><a class="text-link" href="/community/">Read the contribution guide →</a></p><h2>Review stays independent</h2><p>Financial support does not grant control over official releases, bypass code review or security checks, or imply a project endorsement. Official changes follow the same review and release process.</p><p><a class="text-link" href="'''+E(META['git_url'])+'''">Explore the source code →</a></p></article></div></main>'''
|
||||
support='''<main id="content"><div class="page-header"><div class="wrap"><p class="section-label">Support FrameBridge</p><h1>Help keep the bridge working.</h1><p class="lead">Voluntary support can help sustain a free software project: its hosting, maintenance, documentation and development.</p></div></div><div class="wrap page-content"><article class="prose"><h2>What support can help with</h2><ul><li><strong>Hosting and distribution:</strong> keeping the source code, documentation and release files available.</li><li><strong>Maintenance:</strong> investigating reported problems and keeping the software usable.</li><li><strong>Documentation:</strong> improving clear instructions for people and agents.</li><li><strong>Development:</strong> building and reviewing focused improvements.</li></ul><h2>Voluntary support and contact</h2><p><a class="button primary" href="https://www.paypal.com/donate/?business=gerardobh1128%40gmail.com&currency_code=USD" rel="noreferrer">Support through PayPal</a></p><p>Project contact and PayPal account: <a href="mailto:gerardobh1128@gmail.com">gerardobh1128@gmail.com</a>.</p><p>Contributions are voluntary. FrameBridge remains free software under GNU GPLv3 only, whether or not you provide financial support.</p><h2>Support without a payment</h2><p>A useful bug report, a clearer explanation or a carefully scoped patch can be valuable contributions. You can also help by sharing honest demonstrations that distinguish real recordings from conceptual illustrations.</p><p><a class="text-link" href="/community/">Read the contribution guide →</a></p><h2>Review stays independent</h2><p>Financial support does not grant control over official releases, bypass code review or security checks, or imply a project endorsement. Official changes follow the same review and release process.</p><p><a class="text-link" href="'''+E(META['git_url'])+'''">Explore the source code →</a></p></article></div></main>'''
|
||||
(DIST/'support/index.html').write_text(page('Support FrameBridge',support,'support'))
|
||||
|
||||
blog='''<main id="content"><div class="page-header"><div class="wrap"><p class="section-label">Development journal</p><h1>Notes from the bridge.</h1><p class="lead">Design decisions, release work and what we learn while using FrameBridge.</p></div></div><div class="wrap page-content narrow"><a class="news-link" href="/blog/2026-10-26-opening-framebridge/"><time datetime="2026-10-26">26 October 2026 · Project notes</time><strong>Opening FrameBridge: a shared interface for people and agents.</strong><span>Read the development note →</span></a></div></main>'''
|
||||
@@ -157,4 +113,4 @@ hub+='</article>'
|
||||
(DIST/'docs/index.html').write_text(page('Command guide','<main id="content" class="wrap docs-layout">'+docs_nav('index')+hub+'</main>','docs',True))
|
||||
(DIST/'404.html').write_text(page('Page not found','<main id="content" class="wrap page-content prose"><p class="section-label">404 / Page not found</p><h1>This path ends here.</h1><p>Return to <a href="/">FrameBridge</a> or open the <a href="/docs/">command guide</a>.</p></main>'))
|
||||
(DIST/'robots.txt').write_text('User-agent: *\n'+('Allow: /\n' if READY else 'Disallow: /\n'))
|
||||
print(json.dumps({'web_root':str(DIST),'release_state':META['state'],'pages':len(list(DIST.rglob('*.html'))),'guide_topics':len(guides),'video_present':(DIST/'assets/framebridge-preview.mp4').is_file(),'ready_metadata_validated':READY},indent=2))
|
||||
print(json.dumps({'web_root':str(DIST),'release_state':META['state'],'pages':len(list(DIST.rglob('*.html'))),'guide_topics':len(guides),'video_present':(DIST/'assets'/VIDEO).is_file(),'ready_metadata_validated':READY},indent=2))
|
||||
+21
-11
@@ -51,7 +51,7 @@ def main():
|
||||
if not u.path.startswith('/'):failures.append('Non-root relative URL '+value);continue
|
||||
target=resolve(u.path)
|
||||
if not target.exists():
|
||||
if u.path=='/assets/framebridge-preview.mp4':pending.append('Preview video asset pending')
|
||||
if u.path=='/assets/framebridge-preview-0.3.1-original-r4.mp4':pending.append('Preview video asset pending')
|
||||
else:failures.append('Missing local target '+value+' referenced by '+str(p.relative_to(DIST)))
|
||||
elif u.path in q.assets:http_paths.add(u.path)
|
||||
if u.fragment and target.is_file() and target.suffix=='.html':
|
||||
@@ -78,33 +78,43 @@ def main():
|
||||
with tempfile.TemporaryDirectory(prefix='framebridge-web-qa-') as td:
|
||||
temp=Path(td);web=temp/'web';web.mkdir()
|
||||
shutil.copyfile(BASE/'build_site.py',web/'build_site.py')
|
||||
shutil.copyfile(BASE/'release_catalog.py',web/'release_catalog.py')
|
||||
shutil.copytree(BASE/'src',web/'src')
|
||||
(web/'assets').mkdir()
|
||||
for p in (BASE/'assets').iterdir():
|
||||
if p.is_file() and p.suffix!='.mp4':shutil.copyfile(p,web/'assets'/p.name)
|
||||
fixture=dict(meta,state='ready',sha256='a'*64,signing_fingerprint='A'*40,size_bytes=1,source_archive_url='/downloads/SYNTHETIC-SOURCE.tar.gz')
|
||||
if p.is_file():shutil.copyfile(p,web/'assets'/p.name)
|
||||
fixture=json.loads(json.dumps(meta));fixture.update(state='ready',signing_fingerprint='2B1F01BC806C0B33CB6CEDDE52859CEBC91B8808',checksums_url='/downloads/TEST-SHA256SUMS',checksums_signature_url='/downloads/TEST-SHA256SUMS.asc')
|
||||
fixture['source']=dict(filename='TEST-SOURCE.tar.gz',url='/downloads/TEST-SOURCE.tar.gz',signature_url='/downloads/TEST-SOURCE.tar.gz.asc',sha256='a'*64,size_bytes=1)
|
||||
for i,profile in enumerate(fixture['profiles']):
|
||||
filename='TEST-profile-'+str(i)+'.deb'
|
||||
profile.update(filename=filename,url='/downloads/'+filename,signature_url='/downloads/'+filename+'.asc',sha256='a'*64,size_bytes=1)
|
||||
for dependency in fixture['dependency_sources']:
|
||||
dependency.setdefault('signature_url',dependency['url']+'.asc')
|
||||
(web/'release.json').write_text(json.dumps(fixture))
|
||||
result=subprocess.run(['/usr/bin/python3','-I','-S',str(web/'build_site.py')],capture_output=True,text=True,timeout=10)
|
||||
if result.returncode:failures.append('Synthetic-ready rendering failed: '+result.stderr[-600:])
|
||||
else:
|
||||
q=Inspect();q.feed((web/'dist/download/index.html').read_text())
|
||||
blocks=[x for x in q.code if '\n' in x]
|
||||
if len(blocks)!=3:failures.append('Expected manual verification, APT setup and after-install command blocks')
|
||||
for text in blocks:
|
||||
result=subprocess.run(['/bin/bash','-n'],input=text,text=True,capture_output=True,timeout=3)
|
||||
if result.returncode:failures.append('Rendered install block shell syntax: '+result.stderr.strip())
|
||||
manual=next((x for x in blocks if 'gpgv --keyring' in x),'')
|
||||
if not manual or 'sha256sum --check' not in manual or 'expected="' not in manual:failures.append('Manual flow missing required verification')
|
||||
apt=next((x for x in blocks if 'sources.list.d' in x),'')
|
||||
if 'signed-by=' not in apt or 'apt-key' in apt:failures.append('APT trust not scoped with Signed-By')
|
||||
status={'host':'GERAMEG','website_source':str(BASE),'web_root':str(DIST),'language':'English','buildless':True,'html_pages_checked':len(pages),'loopback_http_paths_checked':http_count,'static_failures':failures,'pending_assets':sorted(set(pending)),'infra_routes_expected':sorted(infra),'external_links':sorted(external),'no_external_runtime_dependencies':True,'copy_controls_progressive':True,'ready_branch_rendered_with_synthetic_fixture':True,'install_blocks_syntax_checked_not_executed':True,'release_state':meta.get('state'),'package_sha256_present':bool(meta.get('sha256')),'signing_fingerprint_present':bool(meta.get('signing_fingerprint')),'video_present':(DIST/'assets/framebridge-preview.mp4').is_file(),'visual_browser_QA_performed':False,'visual_browser_QA_limit':'No browser or user desktop was operated by this subtask. Root may inspect the staged site via the authorized GERAMEG surface.','production_network_or_services_changed':False,'site_ready_for_private_staging':not failures,'public_release_ready':not failures and not pending and meta.get('state')=='ready','homepage_sha256':hashlib.sha256((DIST/'index.html').read_bytes()).hexdigest()}
|
||||
apt_blocks=[x for x in blocks if 'sources.list.d/framebridge.sources' in x]
|
||||
if len(apt_blocks)!=3:failures.append('Expected three independent APT profile setup blocks')
|
||||
for apt in apt_blocks:
|
||||
if 'Signed-By:' not in apt or 'primary_count' not in apt or 'apt-key' in apt:
|
||||
failures.append('APT key trust must be pinned and scoped with Signed-By')
|
||||
for suite in ['trixie','noble','mint22']:
|
||||
if not any('Suites: '+suite in block for block in apt_blocks):
|
||||
failures.append('Missing APT suite '+suite)
|
||||
status={'host':'GERAMEG','website_source':str(BASE),'web_root':str(DIST),'language':'English','buildless':True,'html_pages_checked':len(pages),'loopback_http_paths_checked':http_count,'static_failures':failures,'pending_assets':sorted(set(pending)),'infra_routes_expected':sorted(infra),'external_links':sorted(external),'no_external_runtime_dependencies':True,'copy_controls_progressive':True,'ready_branch_rendered_with_synthetic_fixture':True,'install_blocks_syntax_checked_not_executed':True,'release_state':meta.get('state'),'package_sha256_present':all(p.get('sha256') for p in meta.get('profiles',[])),'signing_fingerprint_present':bool(meta.get('signing_fingerprint')),'video_present':(DIST/'assets/framebridge-preview-0.3.1-original-r4.mp4').is_file(),'visual_browser_QA_performed':False,'visual_browser_QA_limit':'No browser or user desktop was operated by this subtask. Root may inspect the staged site via the authorized GERAMEG surface.','production_network_or_services_changed':False,'site_ready_for_private_staging':not failures,'public_release_ready':not failures and not pending and meta.get('state')=='ready','homepage_sha256':hashlib.sha256((DIST/'index.html').read_bytes()).hexdigest()}
|
||||
visual=BASE/'qa/VISUAL-REVIEW.json'
|
||||
if visual.exists():
|
||||
status['visual_browser_QA_performed']=True
|
||||
status['visual_browser_QA_limit']=json.loads(visual.read_text())['limitations']
|
||||
status['visual_browser_QA_report']=str(visual)
|
||||
status.update(support_page='/support/',support_funding_channel_configured=bool(meta.get('support_payment_url')),support_payment_form_present='<form' in (DIST/'support/index.html').read_text(),license_identifier=meta.get('license'),dependency_source_archives=len(meta.get('dependency_sources',[])))
|
||||
video=DIST/'assets/framebridge-preview.mp4'
|
||||
status.update(support_page='/support/',support_funding_channel_configured='https://www.paypal.com/donate/' in (DIST/'support/index.html').read_text(),support_payment_form_present='<form' in (DIST/'support/index.html').read_text(),license_identifier=meta.get('license'),dependency_source_archives=len(meta.get('dependency_sources',[])))
|
||||
video=DIST/'assets/framebridge-preview-0.3.1-original-r4.mp4'
|
||||
if video.exists():
|
||||
status['video_sha256']=hashlib.sha256(video.read_bytes()).hexdigest()
|
||||
guide=json.loads((DIST/'assets/command-guide.json').read_text())
|
||||
|
||||
@@ -1,25 +1,6 @@
|
||||
{
|
||||
"version": "0.3.1",
|
||||
"state": "preparing",
|
||||
"channel": "Community release",
|
||||
"release_date": "2026-10-26",
|
||||
"distribution": "Debian 13 (trixie)",
|
||||
"architecture": "amd64",
|
||||
"package_name": "framebridge-original-expanded",
|
||||
"package_filename": "FrameBridge-0.3.1.deb",
|
||||
"package_url": "/downloads/FrameBridge-0.3.1.deb",
|
||||
"package_signature_url": "/downloads/FrameBridge-0.3.1.deb.asc",
|
||||
"checksums_url": "/downloads/SHA256SUMS",
|
||||
"checksums_signature_url": "/downloads/SHA256SUMS.asc",
|
||||
"source_archive_url": "",
|
||||
"sha256": "",
|
||||
"size_bytes": null,
|
||||
"signing_key_url": "/debian/framebridge-archive-keyring.asc",
|
||||
"signing_fingerprint": "",
|
||||
"apt_base": "https://framebridge.betancourt.one/debian",
|
||||
"git_url": "/git/framebridge/framebridge",
|
||||
"git_clone_url": "https://framebridge.betancourt.one/git/framebridge/framebridge.git",
|
||||
"site_url": "https://framebridge.betancourt.one",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependency_sources": [
|
||||
{
|
||||
@@ -27,14 +8,67 @@
|
||||
"filename": "FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz",
|
||||
"url": "/downloads/FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz",
|
||||
"sha256": "64540a85bd65eac3f394d8c0a967cacbc00e112676d57d644dcd1bb691c1926a",
|
||||
"size_bytes": 82606998
|
||||
"size_bytes": 82606998,
|
||||
"signature_url": "/downloads/FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz.asc"
|
||||
},
|
||||
{
|
||||
"label": "Tunnel client dependency sources",
|
||||
"filename": "FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz",
|
||||
"url": "/downloads/FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz",
|
||||
"sha256": "6445d4f0e773aba833fe5c610fbae54102b92de2ce636438f769f21e890c2bd9",
|
||||
"size_bytes": 26941745
|
||||
"size_bytes": 26941745,
|
||||
"signature_url": "/downloads/FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz.asc"
|
||||
}
|
||||
],
|
||||
"version": "0.3.3",
|
||||
"state": "preparing",
|
||||
"release_date": "2026-10-28",
|
||||
"distribution": "Debian 13 \u00b7 Ubuntu 24.04 \u00b7 Linux Mint 22.x",
|
||||
"architecture": "amd64",
|
||||
"site_url": "https://framebridge.betancourt.one",
|
||||
"git_url": "/git/gerardo/framebridge",
|
||||
"git_clone_url": "https://framebridge.betancourt.one/git/gerardo/framebridge.git",
|
||||
"signing_key_url": "/debian/framebridge-archive-keyring.asc",
|
||||
"signing_fingerprint": "",
|
||||
"checksums_url": "",
|
||||
"checksums_signature_url": "",
|
||||
"source": {},
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "debian13-amd64-py313",
|
||||
"suite": "trixie",
|
||||
"label": "Debian 13",
|
||||
"python": "3.13",
|
||||
"architecture": "amd64",
|
||||
"filename": "",
|
||||
"url": "",
|
||||
"signature_url": "",
|
||||
"sha256": "",
|
||||
"size_bytes": null
|
||||
},
|
||||
{
|
||||
"profile_id": "ubuntu24.04-amd64-py312",
|
||||
"suite": "noble",
|
||||
"label": "Ubuntu 24.04",
|
||||
"python": "3.12",
|
||||
"architecture": "amd64",
|
||||
"filename": "",
|
||||
"url": "",
|
||||
"signature_url": "",
|
||||
"sha256": "",
|
||||
"size_bytes": null
|
||||
},
|
||||
{
|
||||
"profile_id": "mint22-amd64-py312",
|
||||
"suite": "mint22",
|
||||
"label": "Linux Mint 22.x",
|
||||
"python": "3.12",
|
||||
"architecture": "amd64",
|
||||
"filename": "",
|
||||
"url": "",
|
||||
"signature_url": "",
|
||||
"sha256": "",
|
||||
"size_bytes": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Validated release catalog and download content for the original website."""
|
||||
import html
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
E = lambda value: html.escape(str(value), quote=True)
|
||||
FINGERPRINT = "2B1F01BC806C0B33CB6CEDDE52859CEBC91B8808"
|
||||
PROFILES = (
|
||||
("debian13-amd64-py313", "trixie", "Debian 13", "3.13"),
|
||||
("ubuntu24.04-amd64-py312", "noble", "Ubuntu 24.04", "3.12"),
|
||||
("mint22-amd64-py312", "mint22", "Linux Mint 22.x", "3.12"),
|
||||
)
|
||||
VIDEO = "framebridge-preview-0.3.1-original-r4.mp4"
|
||||
POSTER = "framebridge-preview-0.3.1-original-r4.jpg"
|
||||
VIDEO_SHA256 = "b2a8b0b302063edbe05d84fb6dadb7f303a6f0f1b6d79dc151287df4671adc5b"
|
||||
POSTER_SHA256 = "2e867d4b6a22f81ecbefa293a88799b9e3c93f02aa7d1090c7f0a0dbfe44dc9f"
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
def project_url(value, prefix=None):
|
||||
require(isinstance(value, str), "Artifact URL must be text")
|
||||
url = urlsplit(value)
|
||||
require(not url.query and not url.fragment and not url.username and not url.password,
|
||||
"Artifact URL must not contain credentials, a query or a fragment")
|
||||
require((not url.scheme and not url.netloc and url.path.startswith("/") and not value.startswith("//"))
|
||||
or (url.scheme == "https" and url.netloc == "framebridge.betancourt.one"),
|
||||
"Only canonical HTTPS or root-relative project URLs are allowed")
|
||||
require(".." not in url.path.split("/"), "Artifact URL path traversal")
|
||||
require(re.fullmatch(r"/[A-Za-z0-9/._+~:-]*",url.path), "Unsafe artifact URL characters")
|
||||
if prefix:
|
||||
require(url.path.startswith(prefix), "Unexpected artifact route")
|
||||
return value
|
||||
|
||||
def asset(spec):
|
||||
require(isinstance(spec, dict), "Artifact metadata missing")
|
||||
require(re.fullmatch("[a-f0-9]{64}", spec.get("sha256", "")), "Exact artifact SHA-256 required")
|
||||
require(isinstance(spec.get("size_bytes"), int) and spec["size_bytes"] > 0, "Exact artifact size required")
|
||||
require(re.fullmatch("[A-Za-z0-9][A-Za-z0-9._+~-]*", spec.get("filename", "")), "Safe artifact filename required")
|
||||
project_url(spec.get("url", ""), "/downloads/")
|
||||
project_url(spec.get("signature_url", ""), "/downloads/")
|
||||
|
||||
def validate(meta, ready):
|
||||
require(re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", meta.get("version", "")), "Release version must be numeric")
|
||||
require(meta.get("state") in ("preparing", "ready"), "Unknown release state")
|
||||
require(meta.get("site_url") == "https://framebridge.betancourt.one", "Canonical site origin required")
|
||||
require(meta.get("package_name") == "framebridge-original-expanded", "Unexpected binary package")
|
||||
require(meta.get("git_url") == "/git/gerardo/framebridge", "Use existing canonical Gitea repository")
|
||||
require(meta.get("git_clone_url") == "https://framebridge.betancourt.one/git/gerardo/framebridge.git",
|
||||
"Use existing canonical Gitea clone URL")
|
||||
expected = {x[0]: x[1:] for x in PROFILES}
|
||||
profiles = meta.get("profiles", [])
|
||||
require(isinstance(profiles, list) and len(profiles) == 3, "Exactly three profiles required")
|
||||
require(len({p.get("profile_id") for p in profiles}) == 3, "Duplicate profile")
|
||||
for profile in profiles:
|
||||
identity = profile.get("profile_id")
|
||||
require(identity in expected, "Unexpected profile")
|
||||
suite, label, python = expected[identity]
|
||||
require((profile.get("suite"), profile.get("label"), profile.get("python"),
|
||||
profile.get("architecture")) == (suite, label, python, "amd64"),
|
||||
"Profile identity does not match its tested distribution")
|
||||
if ready:
|
||||
asset(profile)
|
||||
if ready:
|
||||
require(meta.get("signing_fingerprint") == FINGERPRINT, "Unexpected official signing fingerprint")
|
||||
asset(meta.get("source"))
|
||||
deps = meta.get("dependency_sources", [])
|
||||
require(isinstance(deps, list) and len(deps) >= 2, "Dependency corresponding-source metadata required")
|
||||
for dependency in deps:
|
||||
asset(dependency)
|
||||
for key, prefix in (("signing_key_url", "/debian/"), ("checksums_url", "/downloads/"),
|
||||
("checksums_signature_url", "/downloads/")):
|
||||
project_url(meta.get(key, ""), prefix)
|
||||
|
||||
def code(text):
|
||||
return '<div class="codebox"><pre><code>' + E(text) + '</code></pre></div>'
|
||||
|
||||
def apt_commands(meta, profile):
|
||||
require(meta.get("state") == "ready", "Do not offer installation commands for an unsigned preparation")
|
||||
validate(meta, True)
|
||||
key_url = meta["site_url"] + meta["signing_key_url"] if meta["signing_key_url"].startswith("/") else meta["signing_key_url"]
|
||||
return """sudo apt install ca-certificates curl gnupg
|
||||
(
|
||||
set -eu
|
||||
key_file="$(mktemp)"
|
||||
trap 'rm -f "$key_file"' EXIT
|
||||
curl -fsSL '""" + key_url + """' -o "$key_file"
|
||||
key_info="$(gpg --batch --show-keys --with-colons "$key_file" 2>/dev/null)"
|
||||
actual="$(printf '%s\\n' "$key_info" | awk -F: '$1=="fpr" {print $10; exit}')"
|
||||
primary_count="$(printf '%s\\n' "$key_info" | awk -F: '$1=="pub" {n++} END {print n+0}')"
|
||||
test "$primary_count" -eq 1
|
||||
test "$actual" = '""" + FINGERPRINT + """'
|
||||
sudo install -d -m 0755 /etc/apt/keyrings
|
||||
sudo install -m 0644 "$key_file" /etc/apt/keyrings/framebridge-archive-keyring.asc
|
||||
printf '%s\\n' \\
|
||||
'Types: deb' \\
|
||||
'URIs: https://framebridge.betancourt.one/debian/' \\
|
||||
'Suites: """ + profile["suite"] + """' \\
|
||||
'Components: main' \\
|
||||
'Architectures: amd64' \\
|
||||
'Signed-By: /etc/apt/keyrings/framebridge-archive-keyring.asc' \\
|
||||
| sudo tee /etc/apt/sources.list.d/framebridge.sources >/dev/null
|
||||
sudo apt update
|
||||
sudo apt install framebridge-original-expanded
|
||||
)"""
|
||||
|
||||
def download(meta):
|
||||
ready = meta["state"] == "ready"
|
||||
body = '<main id="content"><div class="page-header"><div class="wrap"><p class="section-label">Download / Verify / Install</p><h1>Choose your Linux package.</h1><p class="lead">One network administration tool. Three distribution profiles. Updates through signed APT repositories.</p></div></div><div class="wrap page-content"><article class="prose">'
|
||||
body += '<p>FrameBridge helps one administrator manage their own home Linux network: inventory, services, storage, diagnostics, commands and persistent terminals. Desktop access is an auxiliary tool.</p>'
|
||||
body += '<h2>Match the target computer</h2>' + code("cat /etc/os-release\ndpkg --print-architecture\npython3 --version")
|
||||
body += '<p>The supported profiles below use amd64 and the distribution system Python. Select one matching suite for each computer.</p>'
|
||||
if not ready:
|
||||
body += '<div class="release-notice" role="status"><strong>Release preparation.</strong> Version ' + E(meta["version"]) + ' is being prepared. Download links and installation commands appear after the exact artifacts and signatures are verified.</div>'
|
||||
body += '</article><div class="profiles">'
|
||||
for profile in meta["profiles"]:
|
||||
body += '<section class="profile-card" id="' + E(profile["profile_id"]) + '"><p class="section-label">amd64 · Python ' + E(profile["python"]) + '</p><h2>' + E(profile["label"]) + '</h2><p>APT suite: <code>' + E(profile["suite"]) + '</code></p>'
|
||||
if ready:
|
||||
body += '<p class="hash">SHA-256: ' + E(profile["sha256"]) + '</p><p class="micro">' + E(profile["filename"]) + ' · ' + format(profile["size_bytes"], ",") + ' bytes</p><div class="actions"><a class="button primary" href="' + E(profile["url"]) + '" download>Download .deb</a><a class="button" href="' + E(profile["signature_url"]) + '">Package signature</a></div><details><summary>Install the signed APT channel</summary>' + code(apt_commands(meta, profile)) + '</details>'
|
||||
else:
|
||||
body += '<p class="micro">Version ' + E(meta["version"]) + ' · package and signature pending.</p>'
|
||||
body += '</section>'
|
||||
body += '</div><article class="prose">'
|
||||
if ready:
|
||||
body += '<h2>Release verification and source</h2><p>Expected signing-key fingerprint:</p><p class="hash">' + FINGERPRINT + '</p><p><a href="' + E(meta["checksums_url"]) + '">SHA256SUMS</a> · <a href="' + E(meta["checksums_signature_url"]) + '">Checksum signature</a> · <a href="' + E(meta["signing_key_url"]) + '">Public key</a></p><p><a href="' + E(meta["source"]["url"]) + '">Corresponding FrameBridge source</a> · <a href="' + E(meta["source"]["signature_url"]) + '">Source signature</a></p><p class="hash">SHA-256: ' + E(meta["source"]["sha256"]) + '</p><ul>'
|
||||
for dep in meta["dependency_sources"]:
|
||||
body += '<li><a href="' + E(dep["url"]) + '">' + E(dep.get("label", dep["filename"])) + '</a> · <a href="' + E(dep["signature_url"]) + '">Signature</a><br><span class="hash">SHA-256: ' + E(dep["sha256"]) + '</span></li>'
|
||||
body += '</ul><p>Third-party components retain their original licenses and notices. Unchanged dependency archives retain their historical filenames.</p><h2>Normal updates</h2>' + code("sudo apt update\napt-cache policy framebridge-original-expanded\nsudo apt install --only-upgrade framebridge-original-expanded\nfb status")
|
||||
body += '<p>APT verifies the repository signature and package hashes. Keep the selected distribution suite and its scoped Signed-By key.</p>'
|
||||
body += '<h2>Connect and work</h2><p>After installation, connect from the normal terminal on that computer:</p>' + code("fb connect\nfb status\nfb console") + '<p>One central installation can connect enrolled Linux nodes. Nodes use the central connection. Enter passwords and connection keys only in the normal local prompts.</p><p><a href="/docs/">Read the shared command guide</a> · <a href="' + E(meta["git_url"]) + '">Browse the canonical source repository</a></p></article></div></main>'
|
||||
return body
|
||||
@@ -7,3 +7,5 @@
|
||||
.docs{color-scheme:light;--bg:#f3f3ec;--panel:#e9ede5;--ink:#1c3024;--muted:#526357;--line:#cbd5c8;--accent:#795118;--mint:#3c7153}.docs .site-header{background:#19251e;color:#f3f3ec;border-color:#33443b}.docs .brand .prompt,.docs .cursor{color:#eac27b}.docs .site-header nav a{color:#c3cfc3}.docs .site-header nav a[aria-current=page]{color:#fff}.docs .nav-download{border-color:#566b5b}.docs-layout{display:grid;grid-template-columns:230px minmax(0,1fr);gap:65px;padding:50px 0 85px}.docs-nav{border-right:1px solid var(--line);padding-right:28px;font-size:13px}.docs-nav p{font:11px var(--mono);text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}.docs-nav ul{list-style:none;padding:0;margin:15px 0 28px}.docs-nav a{display:block;text-decoration:none;padding:7px 0;color:var(--muted)}.docs-nav a[aria-current=page]{color:var(--ink);font-weight:650}.docs article{max-width:780px}.docs h1{font-size:46px;max-width:none}.docs .prose p,.docs .prose li{color:var(--muted)}.docs .codebox{background:#e7ece3}.docs .copy-button{background:#f3f3ec}.docs .section-label{margin-bottom:15px}.docs .site-footer{background:#e9ede5}.docs .credits strong{color:#1c3024}.topic-links{display:grid;grid-template-columns:1fr 1fr;gap:0 35px;margin:30px 0}.topic-link{display:block;padding:18px 0;border-top:1px solid var(--line);text-decoration:none}.topic-link strong{display:block;margin-bottom:6px}.topic-link span{font-size:13px;color:var(--muted)}
|
||||
@keyframes blink{50%{opacity:0}}@media(max-width:1000px){.hero{gap:45px}.header-inner{min-height:90px}nav{gap:17px}.brand{font-size:21px}.feature-row{grid-template-columns:170px 1fr 1fr;gap:25px}.release-panel{gap:35px}.architecture-line{font-size:12px;gap:10px}.docs-layout{grid-template-columns:190px minmax(0,1fr);gap:35px}}
|
||||
@media(max-width:760px){.wrap{width:calc(100% - 40px)}body{font-size:16px}.header-inner{align-items:flex-start;flex-direction:column;gap:14px;padding:21px 0 17px}.brand{font-size:22px}nav{gap:14px;width:100%;font-size:12px;overflow-x:auto;padding:3px 0 7px}.nav-download{padding:5px 10px}.hero{display:block;padding:50px 0 48px}.hero h1{font-size:57px;max-width:11ch}.hero .lead{font-size:18px}.terminal{margin:45px 8px 0 0;box-shadow:8px 9px 0 #1b2821}.terminal-caption{text-align:left}.terminal-body{padding:25px}.strip{flex-wrap:wrap;gap:12px 20px;font-size:10px;padding:20px 0}.section{padding:50px 0}.section-head,.workflow,.community,.download-grid{display:block}.section-head{margin-bottom:25px}.section-head p{padding:0}.video-caption{display:block}.video-caption span{display:block;margin-top:5px}.workflow-list{margin-top:30px}.architecture{padding:25px}.architecture-line{flex-direction:column;align-items:flex-start;gap:10px}.architecture-line .arrow{transform:rotate(90deg);margin-left:6px}.feature-row{grid-template-columns:1fr;gap:12px;padding:24px 0}.feature-row code{font-size:11px}.release-panel{display:block;padding:28px}.release-panel h2{font-size:34px}.release-facts{margin-top:30px}.community>div+div{margin-top:35px}.footer-main,.footer-bottom{display:block}.footer-links{margin:25px 0}.footer-bottom p+p{margin-top:12px}.page-header{padding:40px 0 25px}.page-header h1{font-size:43px}.page-content{padding:30px 0 55px}.download-grid aside{margin-top:40px}.docs-layout{display:block;padding:30px 0 55px}.docs-nav{border-right:0;border-bottom:1px solid var(--line);padding:0 0 16px;margin-bottom:30px}.docs-nav ul{display:flex;flex-wrap:wrap;gap:3px 20px;margin:10px 0}.docs-nav a{padding:3px 0}.docs h1{font-size:37px}.topic-links{grid-template-columns:1fr}.codebox{padding:20px 16px}pre{font-size:12px}.codebox pre{padding-right:0;padding-top:20px}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}.cursor{animation:none}}
|
||||
|
||||
.profiles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin:40px 0}.profile-card{min-width:0;border:1px solid var(--line,#31433b);padding:24px}.profile-card h2{font-size:1.6rem}.profile-card .actions{gap:10px}.profile-card details{margin-top:24px}.profile-card pre{font-size:.75rem;overflow:auto}@media(max-width:1000px){.profiles{grid-template-columns:1fr}.profile-card{padding:20px}}
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/python3
|
||||
"""Catalog guards and rendered shell syntax; no signing or installation."""
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
BASE = Path(__file__).resolve().parent
|
||||
catalog = runpy.run_path(str(BASE / "release_catalog.py"))
|
||||
|
||||
class CatalogTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.meta = json.loads((BASE / "release.example.json").read_text())
|
||||
|
||||
def ready(self):
|
||||
meta = copy.deepcopy(self.meta)
|
||||
meta.update(state="ready", signing_fingerprint=catalog["FINGERPRINT"],
|
||||
checksums_url="/downloads/TEST-SHA256SUMS",
|
||||
checksums_signature_url="/downloads/TEST-SHA256SUMS.asc")
|
||||
meta["source"] = dict(filename="TEST-source.tar.gz", url="/downloads/TEST-source.tar.gz",
|
||||
signature_url="/downloads/TEST-source.tar.gz.asc", sha256="a"*64, size_bytes=1)
|
||||
for index, profile in enumerate(meta["profiles"]):
|
||||
name = "TEST-profile-" + str(index) + ".deb"
|
||||
profile.update(filename=name, url="/downloads/"+name,
|
||||
signature_url="/downloads/"+name+".asc", sha256="a"*64, size_bytes=1)
|
||||
return meta
|
||||
|
||||
def test_preparation_has_no_install_or_download_claim(self):
|
||||
catalog["validate"](self.meta, False)
|
||||
text = catalog["download"](self.meta)
|
||||
self.assertIn("Release preparation", text)
|
||||
self.assertNotIn("sudo apt install", text)
|
||||
self.assertNotIn("Download .deb", text)
|
||||
|
||||
def test_three_profile_shell_commands_are_valid_and_key_scoped(self):
|
||||
meta = self.ready()
|
||||
catalog["validate"](meta, True)
|
||||
for profile in meta["profiles"]:
|
||||
commands = catalog["apt_commands"](meta, profile)
|
||||
result = subprocess.run(["bash", "-n"], input=commands, text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("Suites: " + profile["suite"], commands)
|
||||
self.assertIn("Signed-By:", commands)
|
||||
self.assertIn("primary_count", commands)
|
||||
self.assertNotIn("trusted=yes", commands)
|
||||
self.assertNotIn("apt-key", commands)
|
||||
|
||||
def test_wrong_distribution_metadata_is_rejected(self):
|
||||
self.meta["profiles"][1]["label"] = "Ubuntu 26.04"
|
||||
with self.assertRaisesRegex(ValueError, "Profile identity"):
|
||||
catalog["validate"](self.meta, False)
|
||||
|
||||
def test_duplicate_profiles_are_rejected(self):
|
||||
self.meta["profiles"][1] = self.meta["profiles"][0]
|
||||
with self.assertRaisesRegex(ValueError, "Duplicate profile"):
|
||||
catalog["validate"](self.meta, False)
|
||||
|
||||
def test_extra_shell_characters_in_key_url_are_rejected(self):
|
||||
meta = self.ready()
|
||||
meta["signing_key_url"] = "/debian/key';echo-malicious"
|
||||
with self.assertRaises(ValueError):
|
||||
catalog["validate"](meta, True)
|
||||
|
||||
def test_missing_artifact_checksum_is_rejected(self):
|
||||
meta = self.ready()
|
||||
meta["profiles"][2]["sha256"] = ""
|
||||
with self.assertRaisesRegex(ValueError, "SHA-256"):
|
||||
catalog["validate"](meta, True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in new issue
Block a user