Host control (files, commands, power)¶
Your service runs inside WSL and needs to act on the machine hosting it:
read a file under C:\Users\..., run a PowerShell command, open the native
file picker, power the computer down at the end of the day.
tempest_fastapi_sdk.hostbridge is that layer — the one that used to live
as a separate service in every project that needed it.
Nothing here mounts itself: create_app does not know this module, and the
router exists because a service asked for it.
The system, file and command surface needs no extra at all — it is the
standard library and FastAPI. Reading PDFs needs [pdf-read], and reading
them with layout and tables needs [pdf-layout].
This is a shell on that machine
Whoever reaches these methods runs commands as the host user and powers
the machine off. Two things follow, and neither is optional:
allowed_base_paths starts empty — denying every path until someone
configures it — and make_hostbridge_router requires auth
dependencies, refusing an empty list. The write side (commands, writes,
deletes, power) is behind destructive=True and off by default.
The first read¶
HostBridge takes a HostBridgeConfig and nothing else. Everything is
async, and whatever touches the disk or a process runs off the event loop:
import asyncio
from tempest_fastapi_sdk.hostbridge import HostBridge, HostBridgeConfig
async def main() -> None:
bridge = HostBridge(
HostBridgeConfig(allowed_base_paths=("/mnt/c/Users/me/Documents",)),
)
info = await bridge.host_info()
print(info.computer_name, info.os_version)
file = await bridge.read_text("C:\\Users\\me\\Documents\\notes.txt")
print(file.size_bytes, file.content[:80])
asyncio.run(main())
Look at that path: it was written in Windows form and it worked. The
bridge takes both — C:\Users\me and /mnt/c/Users/me — and normalizes
before checking, through wslpath where it exists.
Order matters: resolve first, check second
The path is resolved (.. collapsed, symlinks followed) before it is
compared against allowed_base_paths. Checking before resolving is the
classic bug: /mnt/c/Users/me/../../../etc/passwd starts inside an
allowed base and ends outside it.
Configuration from the environment¶
In a service, these options belong on Settings. Compose the mixin and
splat the mapper — the field → argument translation lives in one place:
from tempest_fastapi_sdk.settings import HostBridgeSettings, ServerSettings
from tempest_fastapi_sdk.hostbridge import HostBridge, HostBridgeConfig
class Settings(HostBridgeSettings, ServerSettings):
"""Only what is specific to this service is declared here."""
settings = Settings()
bridge = HostBridge(HostBridgeConfig(**settings.hostbridge_kwargs()))
The environment variables are the field names themselves:
HOST_ALLOWED_BASE_PATHS, HOST_POWERSHELL_BINARY, HOST_CMD_BINARY,
HOST_COMMAND_TIMEOUT and HOST_MAX_FILE_READ_BYTES.
HOST_ALLOWED_BASE_PATHS has no permissive default
Empty denies everything. That is the safe direction to fail: a bridge that can run PowerShell should not also be able to read the whole disk because nobody remembered to configure it.
Exposing it over HTTP¶
make_hostbridge_router returns an APIRouter ready for include_router.
It requires the dependency list — the signature is what forces the auth
decision, instead of a warning in the docs:
from fastapi import Depends, FastAPI
from tempest_fastapi_sdk import make_token_dependency, register_exception_handlers
from tempest_fastapi_sdk.hostbridge import (
HostBridge,
HostBridgeConfig,
make_hostbridge_router,
)
app = FastAPI()
register_exception_handlers(app)
bridge = HostBridge(
HostBridgeConfig(allowed_base_paths=("/mnt/c/Users/me/Documents",)),
)
app.include_router(
make_hostbridge_router(
bridge,
dependencies=[Depends(make_token_dependency("a-long-secret"))],
)
)
That mounts the side that reads:
| Method | Route | What it does |
|---|---|---|
GET |
/system/info |
Machine name, user, OS, uptime |
GET |
/system/files |
Read a text file |
GET |
/system/files/list |
List a directory |
GET |
/system/files/pdf |
Extract a PDF, page by page |
POST |
/system/files/pick |
Open the native file picker |
The side that writes — POST /system/exec, POST/DELETE
/system/files, POST /system/{shutdown,restart,abort,lock,logoff} — only
appears with destructive=True:
from fastapi import Depends
from tempest_fastapi_sdk import make_token_dependency
from tempest_fastapi_sdk.hostbridge import (
HostBridge,
HostBridgeConfig,
make_hostbridge_router,
)
bridge = HostBridge(
HostBridgeConfig(allowed_base_paths=("/mnt/c/Users/me/Documents",)),
)
router = make_hostbridge_router(
bridge,
dependencies=[Depends(make_token_dependency("a-long-secret"))],
destructive=True,
)
Turn the destructive side on only with someone there to confirm
An assistant that runs commands without human confirmation is one that deletes the wrong folder exactly once. The pattern that works is holding the call until the user approves it, showing the exact arguments.
Errors, with a code¶
Every failure raises one of the SDK's envelope exceptions, so a service that
already calls register_exception_handlers answers {detail, code, details}
with no extra wiring — in PT-BR or en-US, per Accept-Language:
| Code | Status | When |
|---|---|---|
HOST_INVALID_PATH |
400 | Path outside the bases, or untranslatable |
HOST_FILE_NOT_FOUND |
404 | No file there |
HOST_FILE_TOO_LARGE |
413 | Over max_file_read_bytes |
HOST_FILE_DECODE_FAILED |
400 | Bytes do not decode — a PDF or an image |
HOST_COMMAND_FAILED |
500 | The command exited non-zero |
HOST_COMMAND_TIMEOUT |
504 | The command ran past its timeout and was killed |
HOST_UNAVAILABLE |
503 | The executable the action calls (PowerShell, wslpath, shutdown.exe) does not exist here — one missing is enough |
HOST_UNAVAILABLE is what tells "this machine has no Windows host" apart
from "the action failed": on a plain Linux container the caller degrades
instead of reporting an error.
Reading a PDF from the host¶
import asyncio
from tempest_fastapi_sdk.hostbridge import HostBridge, HostBridgeConfig
from tempest_fastapi_sdk.pdf import PdfExtractor
async def main() -> None:
bridge = HostBridge(
HostBridgeConfig(allowed_base_paths=("/mnt/c/Users/me/Documents",)),
)
document = await bridge.read_pdf(
"C:\\Users\\me\\Documents\\contract.pdf",
extractor=PdfExtractor.LAYOUT,
)
for page in document.pages:
print(page.page_number, len(page.tables), page.text[:60])
asyncio.run(main())
PdfExtractor.TEXT is pypdf — fast, plain prose, [pdf-read].
PdfExtractor.LAYOUT is pdfplumber, [pdf-layout]: slower, but it
recovers columns and fills page.tables.
There is no OCR here
A scanned page is an image with no text layer: it comes back with
text="" rather than being dropped — entry n is always page n.
Check for it and route those files elsewhere; handing a model an empty
page is how a confident answer gets invented about something nobody read.
Running commands¶
import asyncio
from tempest_fastapi_sdk.hostbridge import (
CommandSchema,
HostBridge,
HostBridgeConfig,
)
async def main() -> None:
bridge = HostBridge(HostBridgeConfig())
result = await bridge.run_command(
CommandSchema(command="Get-Date -Format o", shell="powershell", timeout=10),
)
print(result.return_code, result.stdout.strip(), result.duration_seconds)
asyncio.run(main())
Elevation (UAC) does not happen from here
A command that needs administrator rights fails. Wrap it through gsudo
or a pre-created Task Scheduler entry on the Windows side.
Recap¶
HostBridge+HostBridgeConfiggive you the whole surface;HostBridgeSettingsis the same thing from the environment, viahostbridge_kwargs().- An empty
allowed_base_pathsdenies everything, and a path is resolved before it is checked. make_hostbridge_routerrequires auth by signature, and only mounts the write side withdestructive=True.- Every failure carries a
code—HOST_UNAVAILABLEsays this machine has no host to control. - PDFs:
[pdf-read]for text,[pdf-layout]for columns and tables, no OCR in either.