|
| 1 | +# ADR: OpenShell-Compatible Gateway WebSocket Authentication |
| 2 | + |
| 3 | +- **Status:** Proposed |
| 4 | +- **Date:** 2026-06-06 |
| 5 | +- **Author:** OpenAB POC contributors |
| 6 | +- **Related:** [ADR: Custom Gateway](./custom-gateway.md), [OpenShell](../openshell.md), [Google Chat](../google-chat.md) |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +## 1. Context |
| 11 | + |
| 12 | +OpenAB's Custom Gateway lets OAB connect outbound over WebSocket while the |
| 13 | +gateway owns inbound webhooks and platform credentials. This works well for |
| 14 | +Google Chat, LINE, Telegram, and other webhook platforms. |
| 15 | + |
| 16 | +When OAB runs inside an NVIDIA OpenShell sandbox, gateway authentication has an |
| 17 | +extra constraint: long-lived secrets should not be placed in the sandbox as raw |
| 18 | +environment variables or raw config values. |
| 19 | + |
| 20 | +OpenShell provider credentials appear in the sandbox as resolver placeholders, |
| 21 | +for example: |
| 22 | + |
| 23 | +```text |
| 24 | +provider-OPENSHELL-RESOLVE-ENV-GATEWAY_WS_TOKEN |
| 25 | +openshell:resolve:env:... |
| 26 | +``` |
| 27 | + |
| 28 | +OpenShell resolves those placeholders at the network boundary only when the |
| 29 | +policy proxy can inspect the request. This allows the agent process to use a |
| 30 | +credential without ever seeing the raw value. |
| 31 | + |
| 32 | +## 2. Problem |
| 33 | + |
| 34 | +The original gateway auth path appended the token to the WebSocket URL: |
| 35 | + |
| 36 | +```text |
| 37 | +ws://gateway/ws?token=<token> |
| 38 | +``` |
| 39 | + |
| 40 | +That is simple for ordinary Docker/Kubernetes deployments, but it is a poor fit |
| 41 | +for OpenShell provider credentials: |
| 42 | + |
| 43 | +- The sandbox config would otherwise need a raw gateway token. |
| 44 | +- WebSocket handshake query/header credential rewrite was unreliable in the |
| 45 | + OpenShell bridge path used by this POC. |
| 46 | +- A Rust WebSocket client may not honor the injected HTTP proxy environment, so |
| 47 | + local OpenShell/Docker setups sometimes need a small local bridge to reach the |
| 48 | + host gateway. |
| 49 | + |
| 50 | +During the POC, these routes were tested and rejected as the primary OpenShell |
| 51 | +path: |
| 52 | + |
| 53 | +```text |
| 54 | +[gateway].token = "${GATEWAY_WS_TOKEN}" |
| 55 | +[gateway].token = "openshell:resolve:env:GATEWAY_WS_TOKEN" |
| 56 | +[gateway].token = "provider-OPENSHELL-RESOLVE-ENV-GATEWAY_WS_TOKEN" |
| 57 | +ws://.../ws?token=<placeholder> |
| 58 | +Authorization: Bearer <placeholder> |
| 59 | +``` |
| 60 | + |
| 61 | +OpenShell can rewrite placeholders in normal HTTP requests. For example, a |
| 62 | +`curl` request with `Authorization: Bearer <placeholder>` to the gateway's HTTP |
| 63 | +endpoint proves header rewrite works when the request is inspected as REST. |
| 64 | +However, WebSocket auth in the upgrade handshake is not the right compatibility |
| 65 | +point for the OpenShell setup. |
| 66 | + |
| 67 | +OpenShell's documented WebSocket credential rewrite applies to client-to-server |
| 68 | +WebSocket text frames after the `101 Switching Protocols` upgrade. |
| 69 | + |
| 70 | +## 3. Decision |
| 71 | + |
| 72 | +Add a first-text-frame gateway authentication message. |
| 73 | + |
| 74 | +OAB connects to the gateway WebSocket without putting the gateway token in the |
| 75 | +URL query string. Immediately after the WebSocket upgrade succeeds, OAB sends: |
| 76 | + |
| 77 | +```json |
| 78 | +{ |
| 79 | + "schema": "openab.gateway.auth.v1", |
| 80 | + "token": "provider-OPENSHELL-RESOLVE-ENV-GATEWAY_WS_TOKEN" |
| 81 | +} |
| 82 | +``` |
| 83 | + |
| 84 | +When running under OpenShell with: |
| 85 | + |
| 86 | +```text |
| 87 | +websocket_credential_rewrite: true |
| 88 | +``` |
| 89 | + |
| 90 | +the policy proxy rewrites the provider placeholder inside that client-to-server |
| 91 | +text frame before forwarding it to the gateway. |
| 92 | + |
| 93 | +The gateway behavior is: |
| 94 | + |
| 95 | +1. If `GATEWAY_WS_TOKEN` is not set, accept WebSocket clients with a warning. |
| 96 | +2. If a legacy query token or `Authorization: Bearer` token is present, validate |
| 97 | + it before upgrade handling continues. |
| 98 | +3. Otherwise, accept the upgrade and require the first client text frame to be |
| 99 | + `openab.gateway.auth.v1` with the expected token. |
| 100 | +4. If the first frame is missing, malformed, or invalid, close the socket and |
| 101 | + do not forward events. |
| 102 | + |
| 103 | +This keeps the old query-token path compatible while enabling a hardened |
| 104 | +OpenShell path. |
| 105 | + |
| 106 | +## 4. Reference Architecture |
| 107 | + |
| 108 | +```text |
| 109 | + Google Workspace |
| 110 | + +-----------------------+ |
| 111 | + | Google Chat | |
| 112 | + | signed HTTPS webhook | |
| 113 | + +-----------+-----------+ |
| 114 | + | |
| 115 | + v |
| 116 | ++-----------------------------------------------------------------------+ |
| 117 | +| Host / trusted runtime | |
| 118 | +| | |
| 119 | +| +-----------------------------+ +---------------------------+ | |
| 120 | +| | openab-gateway | | Host secret/token broker | | |
| 121 | +| | | | | | |
| 122 | +| | - owns webhook endpoint | | - stores SA JSON | | |
| 123 | +| | - verifies Google Chat JWT | | - mints short-lived | | |
| 124 | +| | - owns Google Chat SA JSON | | Google access tokens | | |
| 125 | +| | - owns raw GATEWAY_WS_TOKEN | | - updates OpenShell | | |
| 126 | +| +--------------+--------------+ | provider credentials | | |
| 127 | +| ^ +-------------+-------------+ | |
| 128 | +| | | | |
| 129 | +| | validates raw token | stores raw | |
| 130 | +| | after rewrite | provider data | |
| 131 | +| | v | |
| 132 | +| +--------------+--------------------------------------+-------------+ | |
| 133 | +| | OpenShell provider store | | |
| 134 | +| | GATEWAY_WS_TOKEN, GOOGLE_ACCESS_TOKEN | | |
| 135 | +| +--------------+--------------------------------------+-------------+ | |
| 136 | ++-----------------+--------------------------------------+---------------+ |
| 137 | + | | |
| 138 | + | WebSocket 101 + text-frame rewrite | |
| 139 | + | REST header rewrite for Google APIs | |
| 140 | + v v |
| 141 | ++-----------------------------------------------------------------------+ |
| 142 | +| OpenShell sandbox | |
| 143 | +| | |
| 144 | +| +-----------------------------+ +----------------------------+ | |
| 145 | +| | openab | | curl / Google API tools | | |
| 146 | +| | | | | | |
| 147 | +| | - config has placeholders | | - Authorization header | | |
| 148 | +| | - opens ws://gateway/ws | | contains placeholder | | |
| 149 | +| | - first text frame sends | | - OpenShell rewrites | | |
| 150 | +| | openab.gateway.auth.v1 | | token at egress | | |
| 151 | +| +--------------+--------------+ +----------------------------+ | |
| 152 | +| | | |
| 153 | +| | stdio ACP | |
| 154 | +| v | |
| 155 | +| +-----------------------------+ | |
| 156 | +| | kiro-cli | | |
| 157 | +| | | | |
| 158 | +| | - agent-owned auth state | | |
| 159 | +| | remains local credential | | |
| 160 | +| | material | | |
| 161 | +| +-----------------------------+ | |
| 162 | ++-----------------------------------------------------------------------+ |
| 163 | +``` |
| 164 | + |
| 165 | +Credential flow: |
| 166 | + |
| 167 | +```text |
| 168 | +Host/OpenShell provider stores raw GATEWAY_WS_TOKEN |
| 169 | + -> sandbox config stores provider placeholder only |
| 170 | + -> OAB sends placeholder in first WebSocket text frame |
| 171 | + -> OpenShell rewrites placeholder at network boundary |
| 172 | + -> gateway validates raw token |
| 173 | +``` |
| 174 | + |
| 175 | +Google API credential flow: |
| 176 | + |
| 177 | +```text |
| 178 | +service-account JSON stays on host/gateway side |
| 179 | + -> host broker mints short-lived GOOGLE_ACCESS_TOKEN |
| 180 | + -> OpenShell provider stores raw short-lived token |
| 181 | + -> sandbox command sends provider placeholder in HTTP Authorization header |
| 182 | + -> OpenShell rewrites placeholder at egress |
| 183 | + -> Google API receives raw short-lived token |
| 184 | +``` |
| 185 | + |
| 186 | +## 5. Change From Earlier `openshell.md` Guidance |
| 187 | + |
| 188 | +The earlier OpenShell quick-start pattern focused on running OAB inside an |
| 189 | +OpenShell sandbox with provider placeholders for simple outbound APIs such as |
| 190 | +Discord. It did not cover webhook platforms where a Custom Gateway receives |
| 191 | +inbound traffic, owns platform credentials, and authenticates OAB over |
| 192 | +WebSocket. |
| 193 | + |
| 194 | +The new recommendation keeps that provider-placeholder principle but changes |
| 195 | +where secrets live and how WebSocket auth is performed. |
| 196 | + |
| 197 | +| Area | Earlier `openshell.md` pattern | New Google Chat/Kiro gateway pattern | |
| 198 | +|---|---|---| |
| 199 | +| Platform surface | Direct bot API from sandbox, for example Discord gateway/API | Google Chat HTTPS webhook terminates at `openab-gateway` outside OpenShell | |
| 200 | +| Platform credentials | Provider placeholder in sandbox config for the bot token | Google Chat service-account JSON stays in the gateway/host layer | |
| 201 | +| Gateway auth | Not applicable, or token could be appended to `ws://.../ws?token=...` in non-OpenShell deployments | OAB sends `openab.gateway.auth.v1` as first WebSocket text frame so OpenShell can rewrite the provider placeholder after upgrade | |
| 202 | +| `.env` / local secret practice | Easy to accidentally fall back to raw env/config values during local setup | Sandbox config should contain placeholders only; raw values live in OpenShell provider, gateway env, or host token broker | |
| 203 | +| Google Drive/Sheets/Docs access | Not covered | Host keeps service-account JSON, mints short-lived Google token, passes only a provider placeholder through sandbox egress | |
| 204 | +| Agent-owned auth state | Not addressed | Explicitly documented as a remaining limitation: Kiro's own login cache is still local credential material | |
| 205 | + |
| 206 | +This is not a claim that OpenShell can make all agent secrets disappear. It is a |
| 207 | +more precise split: |
| 208 | + |
| 209 | +```text |
| 210 | +Injected operator/platform secrets |
| 211 | + -> use provider placeholders, gateway ownership, or host token broker |
| 212 | +
|
| 213 | +Agent CLI's own login cache |
| 214 | + -> remains agent-owned credential material unless the CLI supports brokering |
| 215 | +``` |
| 216 | + |
| 217 | +## 6. Consequences |
| 218 | + |
| 219 | +Positive: |
| 220 | + |
| 221 | +- The sandbox does not need the raw gateway token. |
| 222 | +- OpenShell can enforce which binary may send the credential and to which |
| 223 | + endpoint. |
| 224 | +- Gateway authentication remains enabled for OpenShell deployments. |
| 225 | +- Existing query-token deployments continue to work. |
| 226 | + |
| 227 | +Tradeoffs: |
| 228 | + |
| 229 | +- The gateway briefly accepts the WebSocket upgrade before authentication |
| 230 | + completes. It must not send events until the first-frame auth succeeds. |
| 231 | +- A malformed or unauthenticated client consumes a socket briefly until the |
| 232 | + auth timeout expires or the gateway closes it. |
| 233 | +- Operators need a WebSocket policy with credential rewrite enabled. |
| 234 | + |
| 235 | +## 7. Operational Notes |
| 236 | + |
| 237 | +Minimal OpenShell endpoint shape: |
| 238 | + |
| 239 | +```bash |
| 240 | +openshell policy update oab \ |
| 241 | + --add-endpoint gateway-host:8080:read-write:websocket:enforce:websocket-credential-rewrite \ |
| 242 | + --binary /usr/local/bin/openab \ |
| 243 | + --wait |
| 244 | + |
| 245 | +openshell policy update oab \ |
| 246 | + --add-allow 'gateway-host:8080:GET:/ws' \ |
| 247 | + --wait |
| 248 | +``` |
| 249 | + |
| 250 | +For local Docker Desktop setups where direct TCP to the host gateway is not |
| 251 | +available to the Rust WebSocket client, a small bridge may be used inside the |
| 252 | +sandbox. Even then, policy should classify the upstream gateway endpoint rather |
| 253 | +than treating the OpenShell HTTP proxy as the application endpoint. |
| 254 | + |
| 255 | +## 8. Security Boundary |
| 256 | + |
| 257 | +Do not pass long-lived platform secrets or service-account JSON files into the |
| 258 | +agent sandbox. Keep those in the host gateway or in a host-side token broker. |
| 259 | + |
| 260 | +For Google Drive/Sheets/Docs access, prefer: |
| 261 | + |
| 262 | +```text |
| 263 | +service-account JSON on host |
| 264 | + -> host mints short-lived OAuth access token |
| 265 | + -> OpenShell provider stores GOOGLE_ACCESS_TOKEN |
| 266 | + -> sandbox uses provider placeholder in Authorization header |
| 267 | + -> OpenShell rewrites at egress |
| 268 | +``` |
| 269 | + |
| 270 | +This avoids exposing the long-lived service-account private key to the agent. |
| 271 | + |
| 272 | +## 9. Limitation: Agent-Owned Credential Stores |
| 273 | + |
| 274 | +OpenShell provider placeholders protect credentials that the operator injects |
| 275 | +through OpenShell, such as gateway tokens or short-lived Google API access |
| 276 | +tokens. They do not automatically protect credentials that an agent CLI creates |
| 277 | +and persists for itself. |
| 278 | + |
| 279 | +For example, Kiro CLI maintains its own local authentication state. In the POC, |
| 280 | +Kiro could still boot after `.aws/sso/cache/*.json` was removed, but failed when |
| 281 | +`.local/share/kiro-cli/data.sqlite3` was removed. This means the decisive Kiro |
| 282 | +auth state is not only the AWS SSO cache files; it also lives in Kiro's own |
| 283 | +SQLite state under `.local/share/kiro-cli/`. |
| 284 | + |
| 285 | +Observed credential-bearing paths include: |
| 286 | + |
| 287 | +```text |
| 288 | +/sandbox/.local/share/kiro-cli/data.sqlite3 |
| 289 | +/sandbox/.aws/sso/cache/*.json |
| 290 | +``` |
| 291 | + |
| 292 | +Those files are agent-owned credential stores, not OpenShell provider |
| 293 | +credentials. Replacing their JSON fields with provider placeholder strings is |
| 294 | +not a natural fix unless the agent CLI itself supports placeholder resolution or |
| 295 | +external credential brokering. Otherwise, the CLI expects its native local token |
| 296 | +format and needs readable auth state to start. |
| 297 | + |
| 298 | +Operational consequence: |
| 299 | + |
| 300 | +- Provider placeholders are appropriate for injected secrets controlled by |
| 301 | + OpenAB, the gateway, or a host-side broker. |
| 302 | +- They are not sufficient to isolate a third-party agent's own login cache. |
| 303 | +- Treat agent auth state such as Kiro's `data.sqlite3` as high-risk credential |
| 304 | + material. |
| 305 | +- Use a dedicated low-privilege agent identity per bot, avoid shared sandboxes, |
| 306 | + and prefer an agent-supported auth proxy/token broker when available. |
0 commit comments