Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,45 @@ jobs:
working-directory: packages/headless-npm
run: npm pack --dry-run

python-sdk:
name: Python SDK (${{ matrix.os }}, ${{ matrix.python }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
python: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: packages/headless-python/pyproject.toml
- name: Install Python SDK development tools
working-directory: packages/headless-python
run: python -m pip install -e '.[dev]'
- name: Verify generated protocol contract
working-directory: packages/headless-python
run: python scripts/generate.py --check
- name: Format and lint
working-directory: packages/headless-python
run: |
ruff check .
ruff format --check .
- name: Static typing
working-directory: packages/headless-python
run: mypy
- name: Unit and lifecycle tests
working-directory: packages/headless-python
run: pytest
- name: Build and verify exact package contents
working-directory: packages/headless-python
run: |
python -m build
python scripts/verify_package.py

protocol:
name: Protocol suite
runs-on: ubuntu-latest
Expand Down Expand Up @@ -149,6 +188,11 @@ jobs:
with:
node-version: 22
cache: pnpm
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.14"
cache: pip
cache-dependency-path: packages/headless-python/pyproject.toml
- run: pnpm install --frozen-lockfile --filter @lockintime/headless
- name: Build app
run: ./apps/headless/build.sh
Expand All @@ -161,6 +205,13 @@ jobs:
HEADLESS_TEST_CLI: ${{ github.workspace }}/apps/headless/Headless.app/Contents/Resources/bin/headless
HEADLESS_TEST_HOST: ${{ github.workspace }}/apps/headless/Headless.app/Contents/MacOS/Headless
run: node packages/headless-npm/test/macos-swift-integration.mjs
- name: Install Python SDK
run: python -m pip install ./packages/headless-python
- name: Swift CLI to Python SDK integration
env:
HEADLESS_TEST_CLI: ${{ github.workspace }}/apps/headless/Headless.app/Contents/Resources/bin/headless
HEADLESS_TEST_HOST: ${{ github.workspace }}/apps/headless/Headless.app/Contents/MacOS/Headless
run: python packages/headless-python/tests/swift_integration.py

macos-e2e:
name: macOS E2E (WKWebView)
Expand Down
102 changes: 102 additions & 0 deletions .github/workflows/python-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
name: Publish Python SDK

on:
push:
tags:
- "python-v*"
pull_request:
paths:
- ".github/workflows/python-release.yml"
- "packages/headless-python/**"
- "sdk/protocol-fixtures.json"
- "sdk/protocol-schema.json"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: python-release-${{ github.ref }}
cancel-in-progress: false

jobs:
build:
name: Verify and build Python SDK
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# v7.0.1 at this verified commit.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: 0
# v6.3.0 at this verified commit.
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: "3.11"
cache: pip
cache-dependency-path: packages/headless-python/pyproject.toml
- name: Verify tag matches package version
if: startsWith(github.ref, 'refs/tags/python-v')
working-directory: packages/headless-python
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
package_version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
test "$RELEASE_TAG" = "python-v$package_version"
git fetch --no-tags origin main:refs/remotes/origin/main
git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main || {
echo "Python SDK releases must point to a commit already merged into main" >&2
exit 64
}
- name: Install development tools
working-directory: packages/headless-python
run: python -m pip install -e '.[dev]'
- name: Verify source and tests
working-directory: packages/headless-python
run: |
python scripts/generate.py --check
ruff check .
ruff format --check .
mypy
pytest
- name: Build exact distributions
working-directory: packages/headless-python
run: |
python -m build
python scripts/verify_package.py
- name: Install and import built distributions
working-directory: packages/headless-python
run: |
python -m venv "$RUNNER_TEMP/python-wheel"
"$RUNNER_TEMP/python-wheel/bin/python" -m pip install --no-deps dist/*.whl
"$RUNNER_TEMP/python-wheel/bin/python" -c 'import headless_sdk; print(headless_sdk.__version__)'
python -m venv "$RUNNER_TEMP/python-sdist"
"$RUNNER_TEMP/python-sdist/bin/python" -m pip install --no-deps dist/*.tar.gz
"$RUNNER_TEMP/python-sdist/bin/python" -c 'import headless_sdk; print(headless_sdk.__version__)'
# v7.0.1 at this verified commit.
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: python-distributions
path: packages/headless-python/dist/
if-no-files-found: error
retention-days: 7

publish:
name: Publish to PyPI
needs: build
if: startsWith(github.ref, 'refs/tags/python-v')
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/lockintime-headless/
permissions:
id-token: write
steps:
# v8 at this verified commit; every action in the OIDC-enabled job is immutable.
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
name: python-distributions
path: dist
# release/v1 at this verified commit; keep OIDC authority immutable.
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
2 changes: 1 addition & 1 deletion apps/headless/Dockerfile.linux
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ CMD ["/usr/local/bin/headless", "help"]
FROM runtime-base AS test
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends busybox expect gnome-keyring \
&& apt-get install -y --no-install-recommends busybox expect gnome-keyring python3 \
&& rm -rf /var/lib/apt/lists/*
RUN install -d -m 0700 -o headless -g headless /run/user/10001
COPY Tests/Fixtures /opt/headless/fixtures
Expand Down
6 changes: 6 additions & 0 deletions apps/headless/Tests/linux-docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ trap restore_evidence_owner EXIT INT TERM
docker run --rm --name headless-p1-e2e --shm-size=1g --cap-add=SYS_ADMIN \
-e HEADLESS_EVIDENCE_DIR=/evidence -v "$EVIDENCE_DIR:/evidence" \
headless-p1-test /opt/headless/linux-e2e.sh
docker run --rm --name headless-python-sdk-integration --shm-size=1g --cap-add=SYS_ADMIN \
-e PYTHONPATH=/opt/python-sdk/src \
-e HEADLESS_TEST_CLI=/usr/local/bin/headless \
-e HEADLESS_TEST_HOST=/usr/local/bin/headless-host \
-v "$PWD/../../packages/headless-python:/opt/python-sdk:ro" \
headless-p1-test python3 /opt/python-sdk/tests/swift_integration.py
restore_evidence_owner
trap - EXIT INT TERM
(
Expand Down
8 changes: 8 additions & 0 deletions packages/headless-python/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
.mypy_cache/
.pytest_cache/
.ruff_cache/
build/
dist/
*.egg-info/
22 changes: 22 additions & 0 deletions packages/headless-python/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
MIT License

Copyright (c) 2026 LockInTime
Copyright (c) 2026 Antiwork, Inc. (original chromeless foundation)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
150 changes: 150 additions & 0 deletions packages/headless-python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Headless Python SDK

`lockintime-headless` is the zero-runtime-dependency Python client for the local
[Headless](https://github.com/LockInTime/headless) browser host. It supports CPython
3.11 through 3.14 on macOS and Linux.

The SDK connects directly to Headless's private per-user Unix socket. It never opens
a TCP port, evaluates arbitrary JavaScript, or receives a credential password. The
typed API and runtime validators are generated from the repository's canonical SDK
schema. SDK versions use semantic versioning independently of the wire protocol.

## Install

```sh
python -m pip install lockintime-headless
```

The `headless` CLI must already be installed and available on `PATH` when using
`launch()`. `connect()` only attaches to an existing host and never shuts it down.

## Synchronous client

```python
from headless_sdk import Untrusted, connect

with connect() as client:
client.session_create(name="research")
session = client.session("research")
page = session.visit(url="https://example.com")
assert isinstance(page, Untrusted)
print(page.value["title"])
```

The equivalent CLI flow is:

```sh
headless start --background
headless session create research
headless --session research visit https://example.com
```

## Asynchronous client

```python
import asyncio

from headless_sdk import aconnect


async def main() -> None:
async with await aconnect() as client:
session = await client.open_session("research")
page = await session.inspect(text=True)
print(page.value["text"])


asyncio.run(main())
```

Pass an `asyncio.Event` as `cancel=` or cancel the calling task. A cancellation or
timeout before any bytes are written is retry-safe. Once request bytes have been
written, timeout, cancellation, transport, framing, or response-validation failures
raise `OperationOutcomeUnknown`. Do not retry that operation until you inspect host
state.

## Supervised host ownership

```python
from headless_sdk import launch

with launch(allow=["example.com"]) as host:
print(host.client.host_status["pid"])
```

`launch()` runs `headless start --supervised`, keeps the owner pipe open, and grants
ownership only after the startup response PID matches the connected host PID.
Closing the wrapper terminates and reaps only that owned launcher. It cannot adopt
or stop a concurrently started shared host. Omit `presentation` to preserve the
platform default. On macOS, pass `presentation="background"` or
`presentation="foreground"` for an explicit override. Custom executable paths must
be absolute.

## Authentication and untrusted data

Page-derived results are wrapped in `Untrusted[T]`; validate them before using them
in privileged operations. `AUTH_REQUIRED` becomes `AuthenticationRequiredError` and
its details are also untrusted. Login accepts only a challenge plus credential alias,
or interactive mode:

```python
from headless_sdk import AuthenticationRequiredError

try:
session.click(role="button", name="Continue")
except AuthenticationRequiredError as error:
aliases = error.details.value["accounts"]
session.auth_login(
challenge=error.details.value["challenge"],
account=aliases[0]["alias"],
)
```

There is intentionally no password parameter. Password enrollment remains a trusted
CLI or native UI operation. Sensitive cookie and storage values still require both
the command flag and the host's diagnostics environment gate.

## Compatibility and security limits

- The SDK requires the exact bundled wire protocol version. A mismatch fails before
result decoding.
- Capabilities are negotiated on connect. Missing commands raise
`UnsupportedCapabilityError`; the SDK does not emulate them.
- Socket paths must be direct children of `/tmp/headless-<uid>`. The directory and
socket must be owned by the current user, non-symlinks, and private (`0700` and
`0600`, respectively).
- The transport accepts exactly one newline-terminated JSON response up to 1 MiB.
- The same-user boundary prevents other OS users from connecting. It does not defend
against a malicious process already running as your OS account.
- `connect()` never owns the host. Stop shared hosts only through an explicit user
action.

Report security problems according to the repository
[security policy](https://github.com/LockInTime/headless/security/policy). Do not put
secrets, cookies, private artifacts, or credential material in reports.

## Release controls

Python packages publish only from a `python-v<package-version>` tag whose commit is
already merged into `main`. The GitHub `pypi` environment allows only `python-v*` tags
and requires independent reviewer approval with administrator bypass disabled. PyPI
trusted publishing is bound to that environment; no long-lived package token is stored
in the repository.

## Development checks

From `packages/headless-python`:

```sh
python -m pip install -e '.[dev]'
python scripts/generate.py --check
ruff check .
ruff format --check .
mypy
pytest
python -m build
python scripts/verify_package.py
```

Publishing uses PyPI trusted publishing from reviewed `python-v*` tags. The package
has its own semantic version and is not released by Headless product `v*` tags.
Loading