Remote Web Access — Owner-Only, Self-Hosted Control#
This document defines how the owner reaches OpenProgram's existing Web UI from the same machine, a trusted LAN or VPN, an SSH tunnel, or an owner-operated HTTPS reverse proxy. The English text is normative; the Chinese translation and the standalone HTML page present the same design. Related code:
openprogram/webui/owner_auth.py,openprogram/backend_endpoint.py,openprogram/webui/server.py,openprogram/_cli_cmds/web.py,web/lib/net/owner-auth-bootstrap.ts, andopenprogram/agent/authority.py. Related designs: speaker identity, permission model, and MCP server.
OpenProgram keeps one authority model in every deployment mode: a process-lifetime instance token authenticates the active profile/state instance's single owner, and every authenticated Web request receives that owner tier and its fixed capability set. OpenProgram does not operate a public relay and does not turn the Web UI into a multi-user application.
1. Method and scope#
1.1 One active state instance, one owner#
The Web UI exposes sessions, files, processes, credentials, settings, tools, approvals, and agent actions. These functions are one administrative boundary, not separate project roles. The Web authentication question is therefore: "does this request possess the current Web process token?" It is not "which registered user sent this request?"
Successful authentication maps a request to the stable principal
owner/install/<16hex> from openprogram/agent/authority.py and to the
owner tier with its fixed capability set. Despite the principal's retained identifier format,
its owner.json record lives under the profile-aware state directory. The
single-owner boundary therefore applies to the active profile/state instance,
not globally across every OpenProgram profile installed for one OS account.
Token possession establishes authorization as that singleton owner; it does
not prove which physical person is operating the browser. OpenProgram stores no
Web user table, password database, membership, role assignment, or per-project
ACL.
Shared human participation remains a channel concern. Telegram, Discord, and
other channel messages retain speaker attribution and the restricted
paired authority tier. A channel participant never becomes the Web
owner because a channel turn exists.
1.2 Four supported access modes#
| Mode | OpenProgram bind | Browser path | Required protection |
|---|---|---|---|
| Same machine | 127.0.0.1 |
Direct loopback URL | Instance token, Host/Origin checks |
| Trusted LAN or encrypted VPN | Explicit web.host, often 0.0.0.0 |
Direct address | Instance token, non-empty exact origins, Host checks, and HTTPS or an encrypted network; direct HTTP is warning-only and limited to the enumerated local/overlay ranges in section 5.5 |
| SSH tunnel | 127.0.0.1 |
Local forwarded port | Instance token; SSH supplies transport encryption |
| Public owner domain | 127.0.0.1 behind a same-host proxy |
Owner-managed HTTPS URL | Instance token, exact origin/Host, trusted loopback proxy, HTTPS |
web.host is necessary only for direct LAN or VPN access. It is not necessary
for an SSH tunnel or a reverse proxy running on the OpenProgram host; both
connect to the loopback listener.
1.3 Product boundaries#
OpenProgram supplies application authentication and validates the browser boundary. It does not:
- issue or renew TLS certificates;
- operate a public relay, hosted tunnel, rendezvous service, or sharing URL;
- create Web accounts, registration, invitations, RBAC, or project permissions;
- accept an identity-aware proxy, Tailscale identity, or OAuth provider as a substitute for the instance token;
- provide an unauthenticated or
--insecuremode.
SSH, a VPN, nginx, Caddy, and certificate automation remain independently operated transport and deployment components. Authentication is still enabled when any of them is present.
2. Current implementation and threat model#
2.1 What exists now#
_web_config() in openprogram/webui/server.py defaults to 127.0.0.1 and
loads web.host plus web.allowed_origins. create_app() uses a FastAPI
lifespan context and installs OwnerAuthMiddleware from
openprogram/webui/owner_auth.py for HTTP and WebSocket ASGI scopes. The
middleware validates the canonical request origin before route dispatch,
applies one cookie-or-Bearer authentication policy to protected HTTP, SSE, and
WebSocket traffic, and attaches the active profile's owner authority only after
authentication. The WebSocket check occurs before websocket.accept.
OwnerAuthState generates a 32-byte process token, acquires the per-state
web.lock, writes the owner-only web/token plus the token-free
web/access.json policy snapshot, derives the profile-specific HttpOnly
cookie, and removes owned state on close. canonicalize_origin() and
resolve_effective_origins() validate exact origins, add only the applicable
loopback defaults, and reject a non-loopback bind with no configured origin.
Uvicorn starts with proxy_headers=False; only OwnerAuthMiddleware may use a
single X-Forwarded-Proto supplied by an immediate loopback peer.
The public POST /api/auth/bootstrap exchanges the fragment token for the
derived cookie. The frontend coordinator in
web/lib/net/owner-auth-bootstrap.ts removes the fragment synchronously,
performs that exchange before mounting the application subtree, and does not
use Web Storage. openprogram web auth-url --base-url ... first verifies the
active listener through the nonce/HMAC ownership challenge, then prints a
fragment URL only for an effective origin. /healthz now returns only
{"status":"ok"}; authenticated operational diagnostics are available at
/api/diagnostics.
Credential responses are masked by default in part of the provider API, while two production paths still return plaintext:
GET /api/providers/{provider}/accounts/{name}/reveal;GET /api/config/key/{env_var}?reveal=1.
The remaining implementation gaps are narrower than the final contract. The two reveal paths and their frontend controls still exist. Startup reporting does not yet emit every field and direct-HTTP warning specified in section 6.1. The current executable coverage includes middleware, token lifecycle, bootstrap coordination, CLI URL generation, HTTP, and WebSocket cases, but not the complete browser, SSE, restart, multi-profile, or nginx/Caddy acceptance matrix in section 6.3.
2.2 Why loopback still requires a token#
Loopback limits which network interfaces accept connections; it does not authenticate a browser request or another local process.
| Caller | Residual threat | Required control |
|---|---|---|
| Arbitrary Web page | It can send some HTTP requests to localhost; WebSocket is not protected by the HTTP same-origin read rule | Token plus exact Origin/Host checks before any action |
| DNS-rebinding page | It can make a hostile name resolve to a loopback address and present a foreign Host | Fail-closed Host authority validation |
| Local process or another OS account | It can omit Origin, as curl and native clients do |
Bearer token; missing Origin conveys no trust |
| LAN peer after external bind | It can connect directly to every current route and /ws |
Mandatory token, exact origins, Host validation, protected transport |
| Reverse-proxy client | It presents a public Host while the backend remains loopback | Explicit public origin plus a narrowly trusted loopback proxy |
Jupyter Notebook 4.3 made token authentication the default and generated the token used by its automatically opened browser, preserving ordinary zero-input startup while authenticating a browser-accessible local execution environment (4.x changelog). Current Jupyter Server documentation retains token/cookie authentication and requires HTTPS for public deployment. This is a precedent for the user experience, not a claim that both products have identical code or threat sets.
2.3 Security invariants#
The design and implementation preserve these invariants:
- No protected application state, session data, project or user file bytes, secret metadata, SSE event, or WebSocket frame is returned before authentication.
- No HTTP, SSE, or WebSocket action executes before authentication.
- A missing
Originnever establishes trust; it is allowed only after valid Bearer authentication, or for a safe cookie-authenticated request whose method does not require an Origin. - Cookie authentication retains CSRF controls. The token complements Host and Origin validation; it does not replace them.
- A non-loopback direct bind with incomplete origin configuration fails before the server starts accepting connections.
- No route returns a stored secret or the Web instance token after initial entry.
3. Reference framework comparison#
3.1 Survey boundary#
The survey covers the open-source systems already used in OpenProgram's design corpus and additional systems with directly relevant Web deployment, authentication, proxy, or secret-handling behavior. An absent capability is recorded explicitly. This is not an exhaustive enumeration of every public agent repository.
| System | Verified design | Remote-access consequence | Use in OpenProgram |
|---|---|---|---|
| OpenClaw | Loopback-first gateway; authenticated non-loopback operation; explicit Control UI origins; SSH and reverse-proxy guidance; Host/DNS-rebinding and trusted-proxy controls (remote access, security, Control UI) | Covers local, tunnel, and owner-operated remote deployment | Adopt exact origins, fragment bootstrap, SSH/proxy patterns, and proxy trust limits; retain the token even when an external identity layer exists |
| Jupyter Server | Notebook 4.3 enabled token authentication by default and supplied the generated token to the automatically opened browser; current Server uses token/cookie authentication, requires HTTPS for public deployment, and retains XSRF plus WebSocket Origin checks (4.x changelog, security, public server) | Closest established single-owner browser experience | Adopt automatic token entry and cookie transition; replace query-token entry with a fragment |
| Hermes Agent | Dashboard defaults to loopback; a non-loopback bind requires a password or OAuth provider and fails startup without one; --insecure is deprecated and cannot disable that gate; Desktop reuses an authenticated session for WebSocket through a single-use ticket; key listings are redacted, although an authenticated rate-limited reveal route remains (dashboard, WS tickets) |
Strong fail-closed external-bind, session/WS, and redaction precedent, but with a user-auth provider model and secret retrieval OpenProgram does not need | Adopt external fail-closed behavior and redacted secret views; use OpenProgram's instance token and same-origin cookie instead of auth-provider accounts or a second WebSocket ticket; reject reveal |
| Agent Zero | Local UI, optional single login, session cookie, CSRF and WebSocket Origin checks; supports reverse proxies and built-in third-party tunnels; settings use masked placeholders (installation, VPS deployment) | Useful cookie/CSRF/Origin composition and secret-update behavior | Adopt the combined checks and masked-only update contract; reject optional authentication and built-in public tunnels |
| OpenHands | Agent Canvas uses a session API key across HTTP and WebSocket and documents SSH plus nginx/HTTPS self-hosting (self-hosting); other OpenHands editions add account-oriented controls | Shows an instance-key deployment without requiring RBAC for the local product | Adopt the common HTTP/WS key and loopback backend; reject token exposure in public HTML or browser storage and authenticated plaintext-secret retrieval |
| opencode | Server/Web modes default to loopback; Basic authentication is optional; configured CORS origins and a short-lived PTY WebSocket ticket exist (server, network options, PTY ticket) | Good local default, insufficient external fail-closed rule when the password is absent | Adopt loopback default; the HttpOnly cookie already avoids long-lived WebSocket query credentials, so no second ticket system is needed |
| Open WebUI | Multi-user accounts and roles; official nginx/Caddy guidance covers HTTPS, WebSocket Upgrade, and SSE buffering (nginx, Caddy) | Deployment mechanics apply, identity model does not | Adopt proxy mechanics; reject signup, accounts, JWT user sessions, groups, and RBAC |
| Dify | Workspace/account roles and reverse-proxy deployment; credential responses use obfuscation and hidden-value update semantics | Strong secret-response precedent inside a different identity model | Adopt masked-only secret responses and explicit replacement; reject tenant and role layers |
| LibreChat | Registration, administrators, user/group/role access, JWT sessions, and documented nginx HTTPS/WebSocket deployment (authentication, nginx) | Confirms what a real multi-user design requires | Use only the proxy configuration details; do not implement its identity data model |
| AnythingLLM | Single-user and multi-user modes are distinct; single-user requests bypass authentication when no password token is configured, while multi-user mode adds role checks (authentication middleware, role middleware) | A single shared credential is compatible with one owner, but optional authentication is not | Keep the single-owner concept and make authentication non-optional |
| AutoGen Studio | Loopback default; default authentication type is none; optional OAuth/JWT support; project documentation calls it a research prototype | Not a production remote-access baseline | Adopt only loopback default; reject query/localStorage credentials, post-accept WebSocket auth, and optional authentication |
| SWE-agent | The current repository documents command-line agent execution and does not ship an owner-operated Web control UI (documentation tree) | The target remote-Web capability is absent | Record as not applicable to this authentication design |
| pi-mono | The coding-agent package documents a TUI, SDK, and process-oriented RPC mode, but its package documentation does not define an owner-operated remote Web control server authentication contract | Related transport and UI components exist; the target capability is absent | Record as not applicable to this authentication design |
| pi-ai | Provider transport library, not an agent control UI | No remote Web ownership or deployment boundary | Record as not applicable |
| WeClaw | Its documented HTTP API defaults to 127.0.0.1:18011 and permits changing the listen address, but the project does not document an owner Web control UI browser-authentication contract |
External HTTP is present, but the target browser surface is absent | Do not use it as a remote-Web security precedent |
| Codex CLI | codex app-server has stdio and experimental WebSocket transports, and its remote-control flow uses enrollment and pairing; it is not an owner-hosted browser UI (app-server) |
Protocol and managed remote-control concerns differ from this self-hosted page | Do not adopt its service-mediated remote-control path because OpenProgram does not operate a relay |
3.2 Patterns that remain separate#
Three recurring designs solve different requirements and must not be combined without need:
- Instance credential: Jupyter, OpenClaw, and OpenHands use a server- or session-level credential suitable for one administrative owner.
- Application accounts: Open WebUI, Dify, LibreChat, and multi-user AnythingLLM need registration, durable sessions, roles, and resource policy.
- Managed remote control: products with pairing, hosted tunnels, or relay services manage device enrollment and public connectivity outside the local process.
OpenProgram uses the first design. The second introduces state and policy that the product does not need. The third requires a service OpenProgram explicitly does not operate.
4. What OpenProgram adopts, modifies, and rejects#
| Source pattern | Treatment | OpenProgram form |
|---|---|---|
| Jupyter's automatic launch-token experience | Modify | Put the token in a URL fragment, exchange it once for an HttpOnly cookie, and remove the fragment before any authenticated fetch |
| OpenClaw's explicit origins, SSH guidance, and proxy trust boundary | Adopt | Exact canonical origins, parsed Host authorities, loopback proxy trust, and no Host fallback |
| High-entropy process credential | Adopt | Generate 32 random bytes with the standard library for each Web process, store them owner-only, and compare decoded bytes with hmac.compare_digest |
| Agent Zero's cookie + CSRF + Origin + WebSocket checks | Adopt | Require all four where a browser cookie is used; reject unsafe cookie requests without a valid Origin |
| Dify and Agent Zero masked secret updates | Adopt | Return masks only; omit an unchanged secret; require a new value for explicit replacement |
| opencode's optional Basic authentication | Reject | There is no authentication-off combination and no warning-only external mode |
| Open WebUI/LibreChat account and JWT model | Reject | No user database, refresh tokens, roles, groups, or project ACL |
| OpenClaw trusted-proxy or Tailscale identity as authentication | Modify | A proxy may provide transport or an additional control, but the OpenProgram token remains mandatory |
| Agent Zero built-in tunnels and managed remote-control relays | Reject | Document SSH and owner-managed HTTPS only; OpenProgram does not create public endpoints |
| Plaintext secret reveal after login | Reject | Owner authentication authorizes replacement and use, not retrieval through the UI |
The resulting design has one root secret in two transport forms, one browser
bootstrap, one application authorization mapping, and one set of HTTP/WS/SSE
checks. The root secret is the per-start instance token; it reaches the server
either as a Bearer header (native HTTP, SSE, WebSocket, and internal clients)
or as a profile-scoped HttpOnly cookie the browser receives in exchange for the
fragment token. Both forms carry the same secret and pass the same checks, so
there is no second WebSocket ticket, user-session database, or OAuth flow.
5. Final remote-access design#
5.1 Token lifecycle#
At each Web server start, OpenProgram generates exactly 32 random bytes with
secrets.token_bytes(32). Its external form is unpadded base64url and is
therefore exactly 43 ASCII characters. Before writing it, the process acquires
an owner-only exclusive operating-system lock at <state-dir>/web.lock. One active Web
process is permitted per profile/state directory; a second process fails
without reading, replacing, or invalidating the first process's token. The
token:
- exists for one Web process lifetime and changes after restart;
- is atomically written to
<state-dir>/web/tokenbefore the listener becomes ready; - is accompanied by an owner-only
<state-dir>/web/access.jsonsnapshot with exactlyversion,bind_host,port, canonicaleffective_origins, andtoken_fingerprint; the snapshot contains no token; - is created with owner-only permissions and passed through
openprogram._compat.restrict_to_user()for cross-platform hardening; - is never accepted from configuration, a command-line argument, or an environment variable;
- is decoded to exactly 32 bytes before comparison and compared only with
hmac.compare_digest; - is never printed by normal server logs, exception messages, access logs, telemetry, or Web responses;
- is represented in logs only as
sha256:<sha256(raw_token_bytes).hexdigest()[:12]>.
The file write reuses the atomic temp-file, os.replace, and permission
patterns already used by openprogram/mcp/token_storage.py. Startup fails if
OpenProgram cannot acquire the lock or create and read back the token file
safely. read_active_web_access() returns ActiveWebAccess only when the
snapshot schema and origins are valid and its fingerprint matches the live
token file. If binding
or later startup fails, the process removes the token only while it still owns
the lock and the file still contains its token; it removes access.json only
when the stored fingerprint is its own. Orderly shutdown applies the same
ownership checks. Unlocked stale files are replaced atomically after the next
process acquires the lock.
5.2 Credential forms#
All forms derive from the same process token:
| Client | Credential | Transport rule |
|---|---|---|
| Browser after bootstrap | openprogram_owner_<owner-id-suffix> HttpOnly cookie containing base64url_no_pad(HMAC-SHA256(key=raw_token_bytes, msg=b"openprogram-web-cookie-v1")) |
<owner-id-suffix> is the 16-hex suffix of the active profile's principal; SameSite=Strict, Path=/, no Domain; Secure when the trusted effective scheme is HTTPS; session cookie with no persistent expiry |
| Native HTTP/SSE | Authorization: Bearer <token> |
Header only; query parameters are rejected |
| Native WebSocket | Authorization: Bearer <token> during the upgrade |
Header only |
| Browser WebSocket | The same HttpOnly cookie | Browser supplies the cookie and Origin during the upgrade |
The per-profile name prevents cookies for simultaneous profile servers on
different ports from overwriting one another; cookies themselves are not
port-scoped. The cookie value is also exactly 43 base64url characters. It is
not a user session and has no database row. Its expected value is recomputed
from the current token, so a server restart invalidates it. Authentication maps
either valid form to owner_authority(owner_principal_id), using the principal
captured for the active profile when OwnerAuthState starts. On protected routes other than
the bootstrap endpoint, an Authorization header selects only the Bearer path:
a non-Bearer scheme, malformed value, or incorrect token returns 401 and
never falls back to a valid cookie.
5.3 Fragment bootstrap#
The normal local launch remains zero-input:
CLI Browser Web server
| start, read token | |
| open /#token=<token> -->| |
| | GET / (fragment absent)|
| |------------------------>|
| | public static shell |
| |<------------------------|
| | read token in memory |
| | history.replaceState() |
| | POST /api/auth/bootstrap|
| | token in request body ->|
| | Set-Cookie: HttpOnly |
| |<------------------------|
| | authenticated HTTP/WS/SSE
The frontend reads the fragment into memory and removes #token=... with
history.replaceState before sending the bootstrap request or any other data
request. The fragment is not part of the HTTP request, proxy access log,
Referer header, or server route. The bootstrap endpoint:
- accepts
POSTonly; - requires a valid exact Origin and valid Host;
- rejects every request containing an
Authorizationheader with the same401response used for a missing or incorrect body token; - requires
Content-Type: application/json, a body no larger than 256 bytes, and exactly the object{"token":"<43-character unpadded base64url>"}; unknown keys, duplicate keys, malformed base64url, and every other token length are rejected; - decodes the value to 32 bytes and compares it in constant time;
- returns
204plus the cookie on success; - returns the same
401body for a missing or incorrect token; - applies
Cache-Control: no-storeand never includes the token in a response.
The public remote command is explicit:
openprogram web auth-url --base-url https://agent.example.com
It first verifies that the worker PID/port files, access.json, and listening
process agree by sending a fresh random nonce to GET /api/auth/challenge and
checking the returned token-HMAC proof locally. The owner token is never sent
to the probed port. Only after that check does the command read the live token
file and write one full fragment URL to the invoking terminal. It does not
write the URL to application logs. --base-url must be a canonical effective
origin frozen in access.json and must contain no path, query, fragment, or
user information. HTTP is accepted only for exact localhost while the actual
listener is loopback, or for an IP literal in the explicit local/overlay range
set defined in section 5.5; every other DNS name requires HTTPS.
5.4 Route policy#
Only these requests can reach route handling without the normal authentication middleware:
- the static application shell and immutable static assets;
POST /api/auth/bootstrap, which performs its own token validation;GET /api/auth/challenge?nonce=<43-character-base64url>with an optionalrevision=<40-lowercase-hex>constraint, which returns only a versioned token-HMAC proof of the supplied nonce after Host validation;GET /healthz, reduced to a non-identifying liveness response such as{"status":"ok"}.
The ownership challenge accepts exactly one 32-byte unpadded-base64url nonce
and at most one revision. When present, the revision must be the 40-character
lowercase revision currently served by the process. Its successful response is
exactly 200 {"proof":"<43-character-base64url>"}, where the decoded proof is
HMAC-SHA256(key=raw_token_bytes, msg=b"openprogram-web-challenge-v1\0" + raw_nonce_bytes + b"\0" + revision_ascii). The ownership probe sends no credential; the endpoint does
not use a credential to construct the proof, emits no token or diagnostics,
and cannot substitute for normal request authentication.
Every other HTTP route, raw file response, provider route, diagnostic route, SSE stream, and WebSocket upgrade requires a valid cookie or Bearer token. Detailed health fields move behind authentication. Static HTML contains no token, configuration secret, session identifier, user data, or dynamic credential material.
The static shell sends a Content Security Policy containing at least
object-src 'none', base-uri 'none', and frame-ancestors 'none'. Executable
scripts are limited to same-origin files and build-generated hashes or nonces;
third-party scripts and unsafe-eval are not permitted. The shell also sends
X-Frame-Options: DENY, Referrer-Policy: no-referrer, and
X-Content-Type-Options: nosniff.
Authentication failures use stable generic responses:
- missing or invalid credential: HTTP
401with{"error":"authentication_required"}andWWW-Authenticate: Bearer realm="OpenProgram"; - invalid Host, Origin, or browser request context: HTTP
403with{"error":"request_origin_rejected"}; - malformed or revision-mismatched ownership challenge: HTTP
400with{"error":"invalid_challenge"}; - invalid non-loopback startup configuration: no listener is started.
Bootstrap and ownership-challenge responses, authentication failures,
protected API responses, credential-status responses, and SSE responses send
Cache-Control: no-store.
Only content-addressed immutable static assets use long-lived caching.
WebSocket authentication and Host/Origin validation occur before
websocket.accept. A missing or invalid credential produces an HTTP 401
upgrade denial, not an accepted socket followed by an application close frame.
5.5 Host, Origin, CSRF, and transport matrix#
OpenProgram reads Web configuration from the active profile's
<state-dir>/config.json. allowed_origins entries are full canonical
origins:
{
"web": {
"host": "0.0.0.0",
"allowed_origins": [
"https://agent.example.com",
"http://192.168.1.20:18100"
]
}
}
Each entry must contain only scheme://host[:port]. Wildcards, paths, query
strings, fragments, user information, opaque origins, and malformed IPv6 are
invalid. Only http and https are valid schemes. DNS names are IDNA- and
case-normalized, IPv6 literals are bracketed, and default ports are removed.
Unspecified and multicast IP literals are invalid origins, so bind addresses
such as 0.0.0.0 and :: cannot appear in allowed_origins.
The server computes effective_origins as the union of validated configured
origins and a narrow loopback default. When the actual listener is loopback,
the default contains http://localhost:<actual-port> and the origin formed
from the actual bound literal, such as http://127.0.0.1:<actual-port> or
http://[::1]:<actual-port>. A literal is not added unless the server is
actually listening on it. A non-loopback listener receives no implicit
origin. An SSH forward using a different local port must add that exact local
Origin to allowed_origins. No request Host value is ever promoted into the
set.
For every request, OpenProgram requires exactly one syntactically valid Host
authority; duplicate, comma-joined, user-information, unspecified-address,
and multicast-address forms are rejected. It constructs request_origin from
the browser-equivalent transport scheme and parsed Host: http and ws map
to http, while https and wss map to https. Only a loopback peer may
replace that scheme through a single valid X-Forwarded-Proto value.
request_origin must be in effective_origins. When an Origin header is
required or present, it must parse to the same canonical origin as
request_origin; membership elsewhere
in the configured set is not sufficient. This single comparison implements
both exact browser-origin and DNS-rebinding checks.
The only safe methods are GET, HEAD, and OPTIONS. Every such route must be
free of state changes; HEAD mirrors GET without a body and OPTIONS only
reports protocol metadata. Every mutation uses POST, PUT, PATCH, or
DELETE.
| Request form | Credential | Origin rule | Host rule |
|---|---|---|---|
| Cookie, unsafe HTTP method | Required | Exact request_origin required; missing Origin rejected |
request_origin must be effective |
| Cookie, WebSocket | Required before accept | Exact request_origin required; missing Origin rejected |
request_origin must be effective |
| Cookie, safe HTTP/SSE | Required | Explicit Origin must equal request_origin; same-origin navigation may omit it |
request_origin must be effective |
| Bearer HTTP/SSE | Required | May omit Origin; if present it must equal request_origin |
request_origin must be effective |
| Bearer WebSocket | Required before accept | Native client may omit Origin; if present it must equal request_origin |
request_origin must be effective |
| Listener-ownership challenge | No credential; bounded nonce and optional revision only | May omit Origin; if present it must equal request_origin |
request_origin must be effective |
| Fragment bootstrap | Token in body | Exact request_origin required |
request_origin must be effective |
Sec-Fetch-Site: cross-site remains a rejection signal for browser requests.
CORS headers control which browser code can read a response; they are not used
as authentication. A same-site value is not sufficient without token, Host,
and the applicable Origin rule.
A direct non-loopback bind requires at least one valid configured origin and
fails closed otherwise. Direct HTTP is accepted only when ipaddress confirms
membership in one of these explicit networks: IPv4 loopback 127.0.0.0/8,
RFC 1918 10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16, IPv4 link-local
169.254.0.0/16, RFC 6598 shared space 100.64.0.0/10, IPv6 loopback
::1/128, IPv6 ULA fc00::/7, or IPv6 link-local fe80::/10. IPv6 zone
identifiers are not accepted in origins. An allowlist rather than is_private
is used because that property includes unusable or reserved addresses and
excludes RFC 6598. Unspecified, multicast, documentation, benchmarking,
reserved, and globally routable addresses are therefore rejected for HTTP.
Every accepted non-loopback HTTP origin emits a prominent startup warning
because a network observer can read the bearer credential. RFC 6598 is accepted
for owner-configured encrypted overlays such as Tailscale, but the address alone
does not prove encryption; the owner is responsible for using it only over the
encrypted overlay. HTTPS remains the normal choice for any network whose
transport protection is uncertain. Exact localhost on an actual loopback
listener is the only HTTP DNS-name exception; every other DNS origin requires
HTTPS.
5.6 Reverse-proxy trust#
A same-host nginx or Caddy process may terminate HTTPS while OpenProgram stays
on 127.0.0.1. The Web server starts Uvicorn with proxy_headers=False, so
Uvicorn cannot rewrite the ASGI client or scheme before OpenProgram evaluates
the raw socket peer. OpenProgram never trusts X-Forwarded-For and trusts
X-Forwarded-Proto only when that raw immediate peer is loopback. The header
must contain exactly one http or https value; lists and every other value
are rejected. Forwarded headers from any non-loopback peer are ignored. The
proxy preserves the public Host, overwrites the effective scheme, supports
WebSocket Upgrade, and disables response buffering for SSE. The resulting
request_origin must still match an effective origin. A bootstrap request for
a DNS or public-IP origin returns 403 unless the trusted effective scheme is
HTTPS, so a missing proxy header cannot create an insecure owner cookie. The
proxy does not replace the instance token.
The OpenProgram configuration for a same-host proxy remains loopback:
{
"web": {
"host": "127.0.0.1",
"allowed_origins": ["https://agent.example.com"]
}
}
Minimal nginx shape:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name agent.example.com;
# ssl_certificate and ssl_certificate_key are owner-managed.
location / {
proxy_pass http://127.0.0.1:18100;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Forwarded "";
proxy_set_header X-Forwarded-Host "";
proxy_set_header X-Forwarded-For "";
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_read_timeout 3600s;
}
}
Minimal Caddy shape:
agent.example.com {
reverse_proxy 127.0.0.1:18100
}
Caddy manages certificates only because the owner selected Caddy; OpenProgram
does not call its certificate APIs. In both examples the configured origin is
https://agent.example.com and the OpenProgram backend remains loopback.
5.7 Direct and tunneled examples#
SSH tunnel:
ssh -N -L 18100:127.0.0.1:18100 owner@remote-host
OpenProgram remains bound to 127.0.0.1. After the tunnel is active, the owner
prints a live URL on the remote host with:
openprogram web auth-url --base-url http://127.0.0.1:18100
This example keeps the same local and remote port, so the implicit loopback
origin applies. If the local side uses a different port, that exact
http://localhost:<local-port> or loopback-literal origin must be added to
allowed_origins before auth-url will accept it.
For a direct LAN or VPN address, the owner explicitly configures both bind and origin:
{
"web": {
"host": "0.0.0.0",
"allowed_origins": ["http://192.168.1.20:18100"]
}
}
The browser uses the address present in allowed_origins; 0.0.0.0 is a bind
address and is never a browser origin.
5.8 Secret handling#
Stored provider credentials are entry-only through the Web UI:
GET /api/config/key/{env_var}returns exactly{"has_value":false,"masked":""}when unset and, when set, returns{"has_value":true,"masked":"sk-…abc4"}with the actual mask;env_varmust be a declared provider or search credential name, and an unknown name returns404;GET /api/providers/{provider}/accountskeeps non-secret account metadata, but represents an API-key credential only throughhas_valueandmasked_key;identity,can_reveal,value, and every full-key field are absent.
The two storage shapes use separate mutation contracts:
| Target | Preserve | Replace | Delete |
|---|---|---|---|
| Config/environment key | Omit its name from the api_keys map |
POST /api/config with exactly {"api_keys":{"ENV_VAR":"new printable-ASCII value"}} or a multi-key map; listed values are required and success is 200 {"saved":true} |
DELETE /api/config/key/{env_var} with no body; idempotently removes the saved entry and current-process environment value, then returns 204 |
| API-key account | Account metadata operations such as use, rename, reorder, and rotation do not accept or change api_key |
POST /api/providers/{provider}/accounts/{name}/update with exactly {"api_key":"new printable-ASCII value","validate":true}; validate is the only optional field and defaults to true; success is exactly 200 {"ok":true} |
POST /api/providers/{provider}/accounts/remove with exactly {"id":"account-name"} deletes the whole credential pool; success is 200 {"removed":true,"name":"account-name","cleared_active":<bool>} |
Unknown fields, unknown credential names, null, empty strings, non-printable
or non-ASCII values, and every displayed mask return 400 without mutation.
Replacing a key that validation definitively rejects returns 400; transient
or offline unknown validation is non-blocking and the save succeeds. Account
replacement returns 404 when
the provider/account does not exist or is not an API-key account. Account
deletion returns 404 for a missing or unknown id. Deleting a config key also
removes its live-process environment value; a value supplied again by the
parent environment can reappear only after a later process restart.
Provider detail, API-key settings, and account manager remove every reveal button and reveal request. The backend never interprets a displayed mask as a secret value.
The stable mask is the first three ASCII characters, U+2026, and the last four
characters only for values of at least twelve characters, which hides at least
five characters. Values shorter than twelve, and values whose visible
characters are not ASCII, use the fixed string ••••••••.
The mask therefore does not encode the original length for short credentials.
It is presentation-only and is never accepted in a write payload.
The account reveal route is removed and returns 404. The masked config-key
status route remains, but a request containing the reveal query parameter is
rejected with 404; it never changes the response to plaintext. The unrelated
project-file reveal action remains subject to normal file authorization and Web
authentication; it is not a credential-retrieval endpoint.
6. Implementation contract and acceptance tests#
6.1 Startup contract#
The server validates configuration, generates and secures the token, writes and
validates the frozen access.json snapshot, computes the token fingerprint,
and only then starts accepting connections. The startup log states:
- the actual bind address and whether it is loopback;
- the configured public origins;
- whether trusted loopback-proxy scheme handling is active;
- the token fingerprint;
- a warning for direct non-loopback HTTP.
It never states the token. A malformed origin, non-loopback bind without an
origin, unsafe public HTTP origin, unsafe token file, or occupied Web-process
lock is a startup error.
Uvicorn proxy-header rewriting is disabled in the server configuration before
the listener starts; only the common OpenProgram ASGI policy interprets the raw
peer and X-Forwarded-Proto.
6.2 Request pipeline#
The common ASGI order is:
request
-> immediate peer, trusted effective scheme, canonical Host/request_origin
-> route + method + credential-source classification
-> Origin / Sec-Fetch-Site / CSRF policy
-> public, ownership-challenge, bootstrap, cookie, or Bearer rule
-> owner authority attachment
-> HTTP route, SSE generator, or WebSocket accept
Public static routes skip credential authentication but not Host and browser context validation. The bootstrap route replaces the common credential check with its constant-time body-token exchange. The ownership-challenge route performs only its bounded nonce/revision proof contract and grants no authority. No application route implements an independent authentication interpretation.
6.3 Required tests#
The feature is complete only when these behaviors are executable tests:
- Loopback HTTP, SSE, and WebSocket access without a credential fails.
- A correct Bearer token succeeds without
Origin; an incorrect token returns the same401shape and performs no action. - Browser bootstrap clears the fragment before other fetches, accepts only the exact bounded JSON schema, sends no token in URL/query/Referer, derives the specified HMAC cookie, rejects every mixed body-token plus Authorization request, and can then open HTTP, SSE, and WS.
- Cookie-authenticated unsafe HTTP and WebSocket requests reject missing, opaque, cross-site, and unlisted origins before action or socket acceptance.
- A foreign, duplicate, comma-joined, unspecified, or multicast Host is rejected on loopback, direct external bind, and reverse-proxy deployments; HTTP/WS and HTTPS/WSS produce the same browser-equivalent origin pairs.
- Default loopback accepts only its implicit
localhostand actual-literal origins; a different SSH local port is rejected until explicitly configured. - Non-loopback bind without a non-empty valid origin list refuses startup; the explicit local/overlay HTTP ranges are accepted with warnings, while unspecified, multicast, documentation, benchmarking, reserved, and global literals are rejected for HTTP.
- Uvicorn proxy-header rewriting is disabled; forwarded scheme is honored
from the raw loopback peer only and ignored from every non-loopback peer;
forged
X-Forwarded-Fornever changes trust, a public bootstrap with a non-HTTPS effective scheme is rejected, and direct WS plus Caddy-proxied WSS produce the expected browser-equivalent origin. - A second Web process for the same state directory cannot modify the live token or access snapshot; bind failure removes only the files owned by the failing process.
- Restart rotates the token and invalidates the prior Bearer token and cookie.
- An invalid or malformed Authorization header never falls back to a valid cookie.
- Static assets and the reduced liveness response contain no process token, session data, filesystem data, credential data, or detailed diagnostics.
- Security headers reject framing; protected/auth/credential responses are
no-store, and401responses advertise the Bearer realm. - Both reveal forms return
404; config-key replace/preserve/DELETE and account replace/preserve/remove execute the exact schemas and status codes specified above; 8–11-character credentials use the fixed mask, masks are never accepted, and frontend builds and types contain no reveal action or full-secret response field. - nginx and Caddy smoke deployments carry authenticated HTTP, SSE, and WebSocket traffic over HTTPS while the backend remains loopback.
- Channel messages retain their
pairedauthority tier and do not inherit Web owner authority. - Two profile servers on the same loopback hostname and different ports use distinct cookie names, authenticate independently, and ignore each other's cookie.
- The listener-ownership probe checks worker PID/port plus the frozen access snapshot, sends only a fresh 32-byte nonce, validates the exact versioned HMAC proof, optionally binds the proof to the served revision, disables ambient HTTP proxies, and never sends or logs the owner token. A foreign listener, stale snapshot, mismatched fingerprint, port, proof, or revision is not treated as the active owner server.
7. Implementation status#
Design statements are not implementation evidence. Status is based on current production paths and tests.
Implemented#
| Item | Evidence |
|---|---|
| Default loopback bind | _web_config() in openprogram/webui/server.py defaults to 127.0.0.1 |
| FastAPI lifespan | create_app() uses _lifespan; deprecated @app.on_event handlers are absent |
| Stable per-profile owner principal and explicit owner/paired authority tiers | openprogram/agent/authority.py; Web, TUI, desktop, runtime, and paired channel entry points attach a tier; tests/unit/test_authority_scope.py and permission tests cover the fixed tier table |
| Owner process credential | OwnerAuthState in openprogram/webui/owner_auth.py generates the 32-byte token, holds <state-dir>/web.lock, atomically writes the owner-only <state-dir>/web/token, derives the profile-specific cookie, compares decoded tokens with hmac.compare_digest, and removes only its owned state; test_process_token_is_owner_only_locked_and_replaced_after_release covers lock, mode, replacement, redacted representation, and rotation after release |
| Canonical effective origins | canonicalize_origin() and resolve_effective_origins() validate exact origins, enforce the explicit HTTP network set, add narrow loopback defaults, and fail a non-loopback bind without an origin; parameterized owner-auth tests cover accepted and rejected forms |
| Common owner-auth boundary | OwnerAuthMiddleware is installed by create_app() before routing, protects HTTP and WebSocket ASGI scopes, applies cookie/Bearer selection, Host/Origin/CSRF checks, generic 401/403 responses, no-store headers, and owner-authority attachment; owner-auth tests cover HTTP mutation and pre-accept WebSocket cases |
| Fragment bootstrap backend and frontend coordinator | POST /api/auth/bootstrap, web/lib/net/owner-auth-bootstrap.ts, and OwnerAuthBoundary implement body-token exchange, synchronous fragment removal, no Web Storage, and application gating; web/scripts/check-owner-auth-bootstrap.mjs, TypeScript checking, and the production Web build exercise the frontend contract |
| Authenticated URL command | openprogram web auth-url --base-url ..., build_owner_auth_url(), and tests/unit/test_web_auth_url.py cover active-server lookup and effective-origin validation; normal CLI browser launch uses the same fragment URL |
| Minimal public liveness | /healthz returns only {"status":"ok"} with no-store; operational fields are on protected /api/diagnostics; integration and owner-auth tests cover both routes |
| Raw-peer proxy boundary | Uvicorn is configured with proxy_headers=False; OwnerAuthMiddleware accepts a single forwarded scheme only from the immediate loopback peer and tests the HTTPS-origin match |
| Secret non-retrievability | Both plaintext reveal forms are gone: the account reveal route is removed and GET /api/config/key/{env_var}?reveal=1 returns 404; _credential_secrets supplies the single mask and the declared-name check; /api/config, /api/settings, /api/config/verify, DELETE /api/config/key/{env_var}, and the account routes accept only their exact bounded schemas and never return a full secret; the frontend has no reveal action, control, or response type. MCP server credentials follow the same contract: MCPServerConfig.to_storage_dict() holds full values for the config file while to_response_dict() masks every env and headers value plus the bearer token and OAuth client secret, so /api/mcp/servers, /api/mcp/servers/{name}, /api/mcp/catalog, and /api/mcp/catalog/diff return {has_value, masked} rather than values; PATCH /api/mcp/servers/{name} applies preserve (omitted) / replace (new value) / delete (explicit empty) and persists only after the restart succeeds; mcp_servers.json is written 0600 through a temp file, fsync, and os.replace; tests/unit/test_mcp_secret_non_retrievability.py and web/scripts/check-secret-non-retrieval.mjs cover the response, permission, preserve, and frontend-display halves |
| Internal client authentication | resolve_backend_endpoint() returns a challenge-verified BackendEndpoint (base URL, WebSocket URL, origin, and token) so the credential is read only after the listener proves it holds the same token; cli_ink.py passes it to the TUI environment, _cli_cmds/mcp.py uses it for the MCP CLI, and the Node client sends the Bearer header only to backend URLs (cli/src/utils/backend.ts, cli/tests/backendAuth.test.ts) |
| Startup reporting | start_server() prints the bind address, binding scope, effective origins, forwarded-scheme trust boundary, and token fingerprint, and warns when an effective origin is non-loopback plaintext HTTP; test_startup_logs_warn_about_plaintext_http_for_remote_origins asserts each field |
| Real-listener transport acceptance | tests/unit/test_web_owner_auth_listener.py binds a real Uvicorn socket on an ephemeral port and covers authenticated and unauthenticated HTTP, SSE authentication with no-store, the raw WebSocket handshake rejected with 401 and Bearer-realm/no-store headers before accept, bind-failure cleanup, wire-level token rotation, two-profile cookie isolation, the reverse-proxy origin matrix, and a sweep proving the token appears in no response body, header, log, or rendered page |
Partially implemented#
| Item | Implemented part | Missing part |
|---|---|---|
| Browser-level audit | The shell is served with CSP, framing, referrer, content-type, and cache headers, and server-side tests assert that responses and rendered HTML carry no token | No browser-driven audit yet walks every exported asset and navigation path to prove none embeds dynamic data |
| Deployment operations | The reverse-proxy contract is covered by a real-listener origin matrix over X-Forwarded-Proto and X-Forwarded-Host, and the raw-peer boundary is enforced |
nginx and Caddy HTTPS/WS/SSE smoke deployments are untested |
Explicitly out of scope#
| Item | Boundary |
|---|---|
| Web accounts, signup, invitations, and user sessions | One active profile/state instance has one owner principal |
| RBAC, groups, tenant/workspace roles, and project permissions | The Web UI is one full-capability owner administrative interface |
| OAuth/OIDC/SSO and identity-aware proxy authentication | They do not replace the mandatory instance token |
| Built-in TLS, ACME, certificate storage, and renewal | The owner operates nginx, Caddy, a VPN, or SSH |
| Public relay, hosted tunnel, rendezvous, pairing service, and public share URL | OpenProgram makes no outbound registration for Web reachability |
Authentication-off or --insecure mode |
Token authentication is always enabled, including loopback |
| Long-lived token in query parameters, localStorage, or public HTML | Fragment bootstrap and HttpOnly cookie are the only browser entry path |
| Plaintext retrieval of stored provider credentials | Replacement is supported; reveal is not |