diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml index d0367c7..d2c468b 100644 --- a/.github/workflows/dependency-audit.yml +++ b/.github/workflows/dependency-audit.yml @@ -24,15 +24,15 @@ jobs: runs-on: databrickslabs-protected-runner-group steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Install audit tools run: pip install pip-audit==2.9.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 553eb23..2940511 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,10 +14,13 @@ jobs: runs-on: databrickslabs-protected-runner-group permissions: contents: write + # id-token: write is required for cosign keyless signing via GitHub OIDC. + # Without it, cosign falls back to interactive auth and the workflow hangs. + id-token: write steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -111,10 +114,52 @@ jobs: git tag -a "$TAG" -m "Release $TAG" git push origin "$TAG" + # ----- Supply-chain provenance: SBOM + cosign keyless signature --------- + # Generates a CycloneDX SBOM from the repo (Python + npm package metadata), + # then signs it with cosign using a short-lived OIDC token from GitHub. + # Verifiers can confirm the SBOM came from this workflow at this tag via: + # cosign verify-blob --bundle coda-sbom.cdx.json.cosign.bundle \ + # --certificate-identity-regexp 'https://github.com/databrickslabs/coding-agents-databricks-apps/.+' \ + # --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + # coda-sbom.cdx.json + - name: Generate CycloneDX SBOM + uses: anchore/sbom-action@9f7302141466aa6482940f15371237e9d9f4c34a # v0.20.5 + with: + path: . + format: cyclonedx-json + output-file: coda-sbom.cdx.json + # Don't auto-upload; we attach via softprops below for one consistent release. + upload-artifact: false + upload-release-assets: false + + - name: Install cosign + uses: sigstore/cosign-installer@d7d6e07ee54d2049ce5cdfc7eed4d6a6ccd80f5b # v3.5.0 + with: + cosign-release: v2.4.1 + + - name: Sign SBOM with cosign (keyless OIDC) + run: | + # --yes auto-confirms the Sigstore transparency log entry (Rekor). + # The resulting bundle contains the signature + certificate + Rekor + # inclusion proof in one self-contained file β€” easier for downstream + # verifiers than separate .sig/.cert files. + cosign sign-blob --yes \ + --bundle coda-sbom.cdx.json.cosign.bundle \ + coda-sbom.cdx.json + # Sanity check: verify what we just signed before publishing. + cosign verify-blob \ + --bundle coda-sbom.cdx.json.cosign.bundle \ + --certificate-identity-regexp 'https://github.com/${{ github.repository }}/.+' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + coda-sbom.cdx.json + - name: Create GitHub Release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 with: tag_name: "${{ steps.version.outputs.TAG }}" name: "${{ steps.version.outputs.TAG }}" body: ${{ steps.notes.outputs.NOTES }} prerelease: ${{ inputs.prerelease }} + files: | + coda-sbom.cdx.json + coda-sbom.cdx.json.cosign.bundle diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dfe1c32..17f02fc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,15 +14,15 @@ jobs: runs-on: databrickslabs-protected-runner-group steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Run tests run: uv run pytest tests/ -v diff --git a/.github/workflows/update-lockfile.yml b/.github/workflows/update-lockfile.yml index 3ba1977..db6d396 100644 --- a/.github/workflows/update-lockfile.yml +++ b/.github/workflows/update-lockfile.yml @@ -14,15 +14,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Regenerate requirements.lock run: uv pip compile requirements.txt -o requirements.lock --generate-hashes diff --git a/README.md b/README.md index 21a5b6e..2d764c0 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,22 @@ --- +## πŸ’¬ Project Support + +Please note that this project is provided for your exploration only and is not +formally supported by Databricks with Service Level Agreements (SLAs). It is +provided AS-IS, and we do not make any guarantees. Please do not submit a +support ticket relating to any issues arising from the use of this project. + +Any issues discovered through the use of this project should be filed as GitHub +[Issues on this repository](https://github.com/databrickslabs/coding-agents-databricks-apps/issues). + +See [LICENSE.md](LICENSE.md) for full terms, including the warranty disclaimer +and limitation of liability. See [NOTICE.md](NOTICE.md) for third-party software +attribution. + +--- +
@@ -155,6 +171,77 @@ Tracing setup is skipped gracefully when `APP_OWNER` is not set (e.g., local dev --- +## Omnigent Host Integration + +CoDA can register itself as a persistent **[Omnigent](https://github.com/omnigent-ai/omnigent) agent host** β€” an always-on target the Omnigent server can drive coding-agent sessions into. Those sessions run *inside this container* and use the same filesystem as browser terminals. They authenticate to Databricks as the CoDA app service principal, not as the interactive browser user, so their Unity Catalog authority may differ. A deployed CoDA app becomes both an interactive terminal **and** a headless host that survives restarts and redeploys. + +**Off by default.** With `OMNIGENTS_SERVER_URL` unset, none of this runs and CoDA behaves exactly as before. This is opt-in, environment-specific wiring β€” the committed `app.yaml` keeps it commented out. + +### Turning it on + +Set three variables in your deployed `app.yaml` (see `app.yaml.lakemeter` for a ready-to-copy overlay template): + +```yaml +# app.yaml +env: + # The Omnigent server this app registers against on boot. + - name: OMNIGENTS_SERVER_URL + value: "https://..databricksapps.com" + # UC Volume holding the omnigent host wheels (app SP needs READ_VOLUME). + - name: OMNIGENTS_WHEEL_SPEC + value: "/Volumes///artifacts/wheels" + # Optional: force-reinstall the host CLI on boot while rolling out a new wheel. + - name: OMNIGENTS_FORCE_REINSTALL + value: "1" +``` + +Before deploying, grant the CoDA app service principal `CAN_USE` on the +Omnigent server app plus `USE_CATALOG`, `USE_SCHEMA`, `READ_VOLUME`, and +`WRITE_VOLUME` on the wheel-volume path. The repository's grant target applies +the complete prerequisite set: + +```bash +make grant-omnigent-host PROFILE= APP_NAME= +``` + +On boot, `initialize_app()` calls `start_host()`, which β€” only when `OMNIGENTS_SERVER_URL` is set β€” installs the `omnigents host` CLI from the wheel volume and launches it as a supervised background process that dials the server over an outbound WSS tunnel. + +### Two credentials, two jobs + +The non-obvious part of this design is that the host uses **two separate credentials** (see `omnigents_host.py`): + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ CoDA container ───────────────────────┐ +β”‚ β”‚ +β”‚ omnigents host ──WSS tunnel──► Omnigent server β”‚ +β”‚ β”‚ (auth: app-SP OAuth token) β”‚ +β”‚ β”‚ β”‚ +β”‚ └── spawns runner ──► AI Gateway β”‚ +β”‚ (auth: CoDA's ANTHROPIC_* creds) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +* **Host tunnel and runners** authenticate to the server through short-lived app-SP OAuth tokens. CoDA captures the SP credentials before stripping them from the environment, keeps the client secret only in Flask process memory, and exposes fresh tokens through a loopback-only broker. The on-disk `[omnigents-host]` profile contains only the workspace host; spawned Omnigent runners receive a refresh command, not a static bearer or client secret. +* **Harness LLM** β€” the runner the host spawns authenticates to AI Gateway via CoDA's already-injected `ANTHROPIC_*` env. No new LLM credential is minted. + +### Runtime controls + +Beyond boot registration, the host can be driven at runtime: + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/api/omnigents-status` | GET | Host-integration state (FR-9 observability) | +| `/api/omnigent-host/status` | GET | Current runtime host state | +| `/api/omnigent-host/connect` | POST | Start a host tunnel for a supplied `server_url` | +| `/api/omnigent-host/disconnect` | POST | Stop the active host tunnel | +| `/api/omnigent-host/share` | POST | Share the SP-owned host with a connecting user | + +### Related + +`ENABLE_SP_APIKEYHELPER=true` enables the same loopback-broker boundary for agent gateway calls: helpers fetch short-lived app-SP OAuth tokens without persisting the SP client secret or a static token in terminal-visible configuration. + +--- + ## Quick Start ### Deploy to Databricks Apps @@ -262,12 +349,12 @@ Open [http://localhost:8000](http://localhost:8000) β€” type `claude`, `codex`, | Endpoint | Method | Description | |----------|--------|-------------| | `/` | GET | Terminal UI with inline setup progress | -| `/health` | GET | Health check with session count and setup status | -| `/api/setup-status` | GET | Setup progress for the UI | -| `/api/app-state` | GET | Persisted app state (owner, last rotation) | +| `/health` | GET | Liveness probe. Exempt from the SSO gate so the platform can reach it. Unauthenticated callers get only `{"status": "healthy"\|"degraded"}`; the owner additionally gets version, session count, setup status and PAT-rotator state | +| `/api/setup-status` | GET | Setup progress for the UI (owner-gated) | +| `/api/app-state` | GET | Persisted app state (owner, last rotation) (owner-gated) | | `/api/version` | GET | App version | | `/api/sessions` | GET | List active (non-exited) sessions with metadata | -| `/api/pat-status` | GET | Whether a valid, usable PAT is currently configured | +| `/api/pat-status` | GET | Whether a valid, usable PAT is currently configured (owner-gated) | | `/api/configure-pat` | POST | Interactive first-session PAT setup (owner-gated via SSO) | | `/api/inject-pat` | POST | Programmatic PAT injection for scripted provisioning (shared-secret gated; disabled unless `CODA_BOOTSTRAP_SECRET` is set). Also requires a workspace **OAuth bearer** for the Apps edge β€” a PAT bearer 401s at the platform edge before reaching the app | | `/api/session` | POST | Create new terminal session | @@ -321,6 +408,10 @@ Open [http://localhost:8000](http://localhost:8000) β€” type `claude`, `codex`, | `MLFLOW_TRACING_ENABLED` | No | Set to `"true"` to enable MLflow tracing for Claude, Codex, and Gemini in one switch (default `"false"`) | | `CLAUDE_CODE_OTEL_ENABLED` | No | Set to `"true"` to enable Claude Code OTEL export to Unity Catalog (default `"false"`) | | `CLAUDE_CODE_OTEL_CATALOG_SCHEMA` | No | Target `.` for `claude_otel_spans`, `claude_otel_logs`, and `claude_otel_metrics` | +| `OMNIGENTS_SERVER_URL` | No | Omnigent server to register against on boot. Unset = host integration off (default). See [Omnigent Host Integration](#omnigent-host-integration) | +| `OMNIGENTS_WHEEL_SPEC` | No | UC Volume path holding the `omnigents host` wheels (app SP needs `READ_VOLUME`). Required when `OMNIGENTS_SERVER_URL` is set | +| `OMNIGENTS_FORCE_REINSTALL` | No | Set `"1"` to reinstall the host CLI on boot (for rolling out a new wheel); otherwise `uv tool install` no-ops on an existing binary | +| `ENABLE_SP_APIKEYHELPER` | No | Set `"true"` to broker short-lived app-SP OAuth tokens over loopback without persisting the client secret in terminal-visible configuration | | `DEEPWIKI_MCP_URL` | No | Override or disable the DeepWiki MCP server (set to `""` to remove) | | `EXA_MCP_URL` | No | Override or disable the Exa MCP server (set to `""` to remove) | | `TEAM_MEMORY_MCP_URL` | No | Optional shared-org-memory MCP server URL | @@ -330,6 +421,8 @@ Open [http://localhost:8000](http://localhost:8000) β€” type `claude`, `codex`, Single-user app β€” the owner is resolved via the app's service principal and Apps API (`app.creator`), with no PAT required at deploy time. Authorization checks `X-Forwarded-Email` against `app.creator`. On first terminal session, the user pastes a short-lived PAT interactively. Tokens auto-rotate every 10 minutes (15-minute lifetime), with old tokens proactively revoked. On restart, the user re-pastes (no persistence by design). +Each GitHub Release ships a signed CycloneDX SBOM β€” see [docs/SECURITY.md](./docs/SECURITY.md) for verification steps. + ### Gunicorn Production uses `workers=1` (PTY state is process-local), `threads=16` (concurrent polling + WebSocket), `gthread` worker class, `timeout=60` (long-lived WebSocket connections). diff --git a/app.py b/app.py index df636be..bb74b34 100644 --- a/app.py +++ b/app.py @@ -1218,8 +1218,26 @@ def cleanup_stale_sessions(): @app.before_request def authorize_request(): """Check authorization before processing any request.""" - # Skip auth for health check, setup status, and Socket.IO (has own auth via connect event) - if request.path in ("/health", "/api/setup-status", "/api/pat-status", "/api/configure-pat", "/api/inject-pat", "/api/app-state") or request.path.startswith("/socket.io"): + # Auth-exempt: + # /health β€” liveness probe. Stays reachable for the platform, + # but trims its body for unauthenticated callers; + # see health() for what each audience sees. + # /api/configure-pat β€” owner-gates itself in-handler (cannot use the + # before_request gate; needed during bootstrap before + # app_owner is resolved). See configure_pat() guard. + # /api/inject-pat β€” gated on the CODA_BOOTSTRAP_SECRET shared secret, + # and 404s when that env var is unset. Provisioning + # scripts have no SSO session, so it can't use the + # SSO gate. See inject_pat(). + # /socket.io/* β€” has own auth gate via the 'connect' WS event + # + # Previously exempt but now owner-gated (closed unauth info-disclosure + # surface): /api/setup-status, /api/pat-status, /api/app-state. All three + # are only polled by the frontend, which loads from "/" (auth'd) so already + # has SSO cookies β€” no functional regression. + if request.path in ( + "/health", "/api/configure-pat", "/api/inject-pat", + ) or request.path.startswith("/socket.io"): return None authorized, user = check_authorization() @@ -1319,6 +1337,17 @@ def attach_session(): @app.route("/health") def health(): + # Two audiences, two response shapes: + # + # unauthenticated β€” {"status": "healthy"|"degraded"} and nothing else. + # Version, session counts, setup state and rotator internals all enable + # version-targeted exploit selection or leak the app's auth posture to + # anyone who can reach the URL. + # the owner β€” the full diagnostic payload below. + # + # `status` itself stays visible to everyone: a liveness probe that can't + # report unhealthiness is useless, and "degraded" is the signal that makes + # a zombie app (worker answering, PAT rotation dead) observable at all. with sessions_lock: session_count = len(sessions) with setup_lock: @@ -1352,8 +1381,14 @@ def health(): pat_rotator.token is not None and auth.get("rotator_alive") is False ) + status = "degraded" if degraded else "healthy" + + authorized, _ = check_authorization() + if not authorized: + return jsonify({"status": status}) + return jsonify({ - "status": "degraded" if degraded else "healthy", + "status": status, "version": APP_VERSION, "setup_status": current_setup_status, "active_sessions": session_count, diff --git a/app.yaml.lakemeter b/app.yaml.lakemeter index 6c41743..e497072 100644 --- a/app.yaml.lakemeter +++ b/app.yaml.lakemeter @@ -22,12 +22,21 @@ env: value: databricks-claude-opus-4-8 - name: HERMES_FALLBACK_MODEL value: databricks-claude-opus-4-8 - # Set ENABLE_HERMES=false to skip Hermes Agent install. Other CLIs are unaffected. + # Per-CLI install toggles. Each ENABLE_* defaults to true when absent, and an + # Apps overlay replaces app.yaml wholesale rather than merging β€” so list all + # of them explicitly or an omitted toggle silently means "install it". - name: ENABLE_HERMES value: "true" - # Set ENABLE_PI=false to skip installing the Pi coding agent. Other CLIs are unaffected. - name: ENABLE_PI value: "true" + - name: ENABLE_OPENCODE + value: "true" + # Codex needs a Responses-API (*-codex) endpoint and Gemini needs a served + # Gemini model; neither is available here, so skip their installs. + - name: ENABLE_CODEX + value: "false" + - name: ENABLE_GEMINI + value: "false" - name: CLAUDE_CODE_DISABLE_AUTO_MEMORY value: 0 - name: MAX_CONCURRENT_SESSIONS diff --git a/app.yaml.template b/app.yaml.template index d3890db..bbce970 100644 --- a/app.yaml.template +++ b/app.yaml.template @@ -16,14 +16,27 @@ env: value: databricks-claude-opus-4-8 - name: HERMES_FALLBACK_MODEL value: databricks-claude-opus-4-8 - # Set ENABLE_HERMES=false to skip Hermes Agent install. - # Other CLIs are unaffected. + # Per-CLI install toggles. Each ENABLE_* defaults to true when the variable + # is absent, so list all of them here explicitly: an Apps overlay *replaces* + # app.yaml rather than merging with it, and an omitted toggle silently falls + # back to "install it". + # + # Set any to "false" to skip that CLI's install; the others are unaffected. - name: ENABLE_HERMES value: "true" - # Set ENABLE_PI=false to skip installing the Pi coding agent. - # Other CLIs are unaffected. - name: ENABLE_PI value: "true" + - name: ENABLE_OPENCODE + value: "true" + # Codex and Gemini default to "false" here because most workspaces don't + # serve endpoints they can talk to β€” Codex needs a Responses-API (*-codex) + # endpoint and Gemini needs a served Gemini model. Installing them anyway + # just costs boot time and produces agents that fail on first request. + # Flip to "true" once your gateway serves a compatible endpoint. + - name: ENABLE_CODEX + value: "false" + - name: ENABLE_GEMINI + value: "false" #OPTIONAL: Use the new Databricks AI Gateway if you have access (recommended), otherwise it will default to the older endpoint - name: DATABRICKS_GATEWAY_HOST value: https://.ai-gateway..cloud.databricks.com diff --git a/cli_auth.py b/cli_auth.py index 89d70d5..51886d8 100644 --- a/cli_auth.py +++ b/cli_auth.py @@ -2,11 +2,17 @@ Called by pat_rotator._persist_token() every 10 minutes. Lightweight β€” just swaps token values in existing files, no installs or script runs. + +All writes are atomic (write to `.tmp`, then `os.replace`) so a Hermes / OpenCode +/ Codex invocation that reads the file mid-update sees the old token whole or +the new token whole β€” never a half-written file. Errors other than "file does +not exist" surface as warnings rather than being silently swallowed. """ import json import os import re +import stat import logging from claude_otel import refresh_claude_otel_token @@ -18,6 +24,28 @@ _HOME = "/app/python/source_code" +def _atomic_write_text(path, content): + """Write `content` to `path` atomically via tmp file + rename. + + Prevents the read-while-rewriting race that bit Hermes specifically: + Hermes reads `~/.hermes/config.yaml` on every invocation, so a bare + open(path, 'w') by the rotator could leave the file in a partial state + visible to a concurrent Hermes call β†’ 403 Invalid access token. + """ + tmp = f"{path}.tmp" + with open(tmp, "w") as f: + f.write(content) + # os.replace() installs the *tmp* file's inode, so it also installs the + # tmp file's permissions. Without this, an atomic rewrite would silently + # widen a hardened config back to the umask default β€” e.g. undoing the + # 0600 that setup_hermes.py applies to ~/.hermes/config.yaml. + try: + os.chmod(tmp, stat.S_IMODE(os.stat(path).st_mode)) + except OSError: + pass # target missing/unreadable β€” callers already guard on existence + os.replace(tmp, path) + + def update_cli_tokens(token): """Update the literal token in all CLI config files.""" _update_claude(token) @@ -31,6 +59,8 @@ def update_cli_tokens(token): def _update_claude(token): """Update Claude tokens in ~/.claude/settings.json.""" path = os.path.join(_HOME, ".claude", "settings.json") + if not os.path.exists(path): + return # setup_claude.py hasn't run yet try: with open(path) as f: settings = json.load(f) @@ -46,10 +76,9 @@ def _update_claude(token): if refresh_claude_otel_token(settings, token): changed = True if changed: - with open(path, "w") as f: - json.dump(settings, f, indent=2) - except (OSError, json.JSONDecodeError): - pass # file doesn't exist yet β€” initial setup hasn't run + _atomic_write_text(path, json.dumps(settings, indent=2)) + except (OSError, json.JSONDecodeError) as e: + logger.warning("Failed to update Claude token in %s: %s", path, e) def _update_pi(token): @@ -67,6 +96,8 @@ def _update_pi(token): static apiKey is still rewritten, for backward compatibility.) """ path = os.path.join(_HOME, ".pi", "agent", "models.json") + if not os.path.exists(path): + return # setup_pi.py hasn't run yet try: with open(path) as f: config = json.load(f) @@ -77,10 +108,9 @@ def _update_pi(token): and not str(provider["apiKey"]).startswith("!") ): provider["apiKey"] = token - with open(path, "w") as f: - json.dump(config, f, indent=2) - except (OSError, json.JSONDecodeError): - pass # file doesn't exist yet β€” initial setup hasn't run + _atomic_write_text(path, json.dumps(config, indent=2)) + except (OSError, json.JSONDecodeError) as e: + logger.warning("Failed to update pi token in %s: %s", path, e) def _update_codex(token): @@ -92,6 +122,8 @@ def _update_codex(token): def _update_opencode(token): """Update api_key values in ~/.local/share/opencode/auth.json.""" path = os.path.join(_HOME, ".local", "share", "opencode", "auth.json") + if not os.path.exists(path): + return # setup_opencode.py hasn't run yet try: with open(path) as f: auth = json.load(f) @@ -101,10 +133,9 @@ def _update_opencode(token): provider["api_key"] = token changed = True if changed: - with open(path, "w") as f: - json.dump(auth, f, indent=2) - except (OSError, json.JSONDecodeError): - pass + _atomic_write_text(path, json.dumps(auth, indent=2)) + except (OSError, json.JSONDecodeError) as e: + logger.warning("Failed to update OpenCode token in %s: %s", path, e) def _update_gemini(token): @@ -116,6 +147,8 @@ def _update_gemini(token): def _update_hermes(token): """Update api_key lines in ~/.hermes/config.yaml.""" path = os.path.join(_HOME, ".hermes", "config.yaml") + if not os.path.exists(path): + return # setup_hermes.py hasn't run yet try: with open(path) as f: content = f.read() @@ -126,14 +159,15 @@ def _update_hermes(token): flags=re.MULTILINE ) if new_content != content: - with open(path, "w") as f: - f.write(new_content) - except OSError: - pass + _atomic_write_text(path, new_content) + except OSError as e: + logger.warning("Failed to update Hermes token in %s: %s", path, e) def _replace_dotenv_key(path, key, value): """Replace a KEY=value line in a dotenv file.""" + if not os.path.exists(path): + return # caller's setup script hasn't run yet try: with open(path) as f: content = f.read() @@ -144,7 +178,6 @@ def _replace_dotenv_key(path, key, value): flags=re.MULTILINE ) if new_content != content: - with open(path, "w") as f: - f.write(new_content) - except OSError: - pass + _atomic_write_text(path, new_content) + except OSError as e: + logger.warning("Failed to update %s in %s: %s", key, path, e) diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..56ab729 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,23 @@ +# Security + +## Verifying release provenance + +Each GitHub Release ships with: + +- `coda-sbom.cdx.json` β€” CycloneDX SBOM of every Python + npm dependency (generated by [syft](https://github.com/anchore/syft)). +- `coda-sbom.cdx.json.cosign.bundle` β€” [Sigstore](https://www.sigstore.dev/) keyless signature bundle (cert + signature + Rekor inclusion proof in one file). + +To verify a release came from this repo's release workflow: + +```bash +TAG=v1.0.0 # the release you downloaded +gh release download "$TAG" -p 'coda-sbom.cdx.json*' + +cosign verify-blob \ + --bundle coda-sbom.cdx.json.cosign.bundle \ + --certificate-identity-regexp 'https://github.com/databrickslabs/coding-agents-databricks-apps/.+' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + coda-sbom.cdx.json +``` + +Signing uses GitHub's OIDC token β€” no long-lived signing keys exist. The signing identity is anchored to the workflow path + tag ref, and a public transparency-log entry is recorded in Rekor. diff --git a/docs/auth-and-identity.md b/docs/auth-and-identity.md index 8d3c9f3..29a8ef2 100644 --- a/docs/auth-and-identity.md +++ b/docs/auth-and-identity.md @@ -10,8 +10,8 @@ A CoDA container carries **two separate Databricks identities**: | Identity | What it is | What it's used for | |---|---|---| -| **The user** (e.g. `user@example.com`) via the PAT in `~/.databrickscfg [DEFAULT]` | A **user** personal access token (`dapi…`), kept fresh by the PAT rotator | **Everything the terminal / CLI does**: `databricks` commands, `git`, `gh`, workspace/UC ops, file writes. Also the token the content-filter proxy injects for OpenCode / Hermes / Codex model calls. | -| **The app service principal** (e.g. `app-4n8qml coda-02`, an OAuth client_id) | The Databricks App's own SP, no PAT | **Claude & Pi model inference** (via the `apiKeyHelper` / `!command` that mints an **SP-OAuth** token from the `omnigents-host` profile), plus the Omnigent host registration / tunnel. | +| **The user** (e.g. `user@example.com`) via the PAT in `~/.databrickscfg [DEFAULT]` | A **user** personal access token (`dapi…`), kept fresh by the PAT rotator | **Everything the terminal / CLI does**: `databricks` commands, `git`, `gh`, workspace/UC ops, and file writes. | +| **The app service principal** (e.g. `app-4n8qml coda-02`, an OAuth client_id) | The Databricks App's own SP, no PAT | **Agent model inference**, Omnigent host registration, and spawned-runner callbacks via short-lived OAuth tokens from the loopback broker. | ### So: when Claude runs a command on this box, who is it? @@ -34,44 +34,24 @@ token helper). The shell/tools do not. |---|---|---| | **Claude** | `apiKeyHelper` in `~/.claude/settings.json` (shared `token_helper.py`) | yes | | **Pi** | `!command` apiKey in `~/.pi/agent/models.json` (same `token_helper.py`) | yes | -| **OpenCode** | `baseURL` β†’ local **content-filter proxy** (`127.0.0.1:4000`), which injects a fresh token per request | ⚠️ only after a PAT is injected once | -| **Hermes** | routes via the **content-filter proxy** too (same fresh-token injection) | ⚠️ only after a PAT is injected once | -| **Codex** | content-filter proxy | ⚠️ same as above | +| **OpenCode** | `baseURL` β†’ local **content-filter proxy** (`127.0.0.1:4000`), which injects a fresh token per request | yes | +| **Hermes** | routes via the **content-filter proxy** too (same fresh-token injection) | yes | +| **Codex** | content-filter proxy | yes | -### Why Claude/Pi are zero-PAT but OpenCode/Hermes aren't +### Secret boundary -- **Claude & Pi** resolve their bearer through `token_helper.py`, which mints an - **SP-OAuth** token directly from the `omnigents-host` profile (falling back to - a PAT only if that profile is absent). No user PAT required. -- **OpenCode / Hermes / Codex** route through `content_filter_proxy.py`, whose - `_get_fresh_token()` reads the current token from **`~/.databrickscfg`** - (`content_filter_proxy.py:54`, injected at `:569-573`). The PAT rotator keeps - that file fresh β€” **but only after a PAT has been bootstrapped**. On the pure - SP-OAuth host path there is no PAT, so `~/.databrickscfg` has no token to read - until you inject one in the UI. That's the one-time PAT injection. -- Once injected, the proxy keeps OpenCode/Hermes fresh across PAT rotation with - no further injection (that's the dynamic-refresh mechanism β€” it's not static). - -## Known gap / future fix (not done β€” intentional) - -To make **OpenCode / Hermes** zero-PAT like Claude/Pi, teach -`content_filter_proxy._get_fresh_token()` to **fall back to minting an SP-OAuth -token** from the `omnigents-host` profile (the same source `token_helper.py` -uses) when no PAT is present in `~/.databrickscfg`. Small, well-scoped change: - -- `content_filter_proxy.py` β€” add an SP-OAuth mint (via `databricks.sdk` `Config(profile="omnigents-host").authenticate()`) as the fallback in `_get_fresh_token()`, cached with a short TTL like the current path. -- No change needed to `setup_opencode.py` / `setup_hermes.py` β€” they already - route through the proxy; only the proxy's token source needs the fallback. - -Decision (2026-07-11): **left as-is.** "Claude/Pi work with no PAT; Hermes/OpenCode -work after a one-time PAT injection" is acceptable for the workshop. +- The Flask process alone retains the app-SP client secret. +- A loopback-only broker mints short-lived OAuth tokens on demand. +- The `[omnigents-host]` profile stores only `host`; it has no client ID, + client secret, or static token. +- Agent helpers, the content-filter proxy, the host tunnel, and spawned + Omnigent runners obtain fresh tokens without exposing the client secret to a + browser terminal. ## Key files -- `token_helper.py` β€” shared SP-OAuth/PAT resolver for Claude (`apiKeyHelper`) and Pi (`!command`). -- `setup_claude.py` / `setup_pi.py` β€” wire the helper (default-on; opt out via `DISABLE_SP_APIKEYHELPER`). -- `content_filter_proxy.py` β€” local proxy for OpenCode/Hermes/Codex; `_get_fresh_token()` (`:54`) + header injection (`:569-573`). -- `setup_opencode.py` / `setup_hermes.py` β€” point the agent at the proxy (`127.0.0.1:4000`). -- `pat_rotator.py` β€” mints/rotates the user PAT, writes `~/.databrickscfg`, fans out via `cli_auth.py`. -- `cli_auth.py` β€” on rotation, refreshes static tokens in each agent's config (skips the `!command` / helper-owned ones). -- `omnigents_host.py` β€” host path: `_ensure_{claude,pi,opencode}_settings()` re-run setup with a minted SP bearer on host-connect. +- `sp_token_broker.py` β€” loopback-only app-SP token broker. +- `token_helper.py` β€” shared broker/SP-OAuth/PAT resolver for agent helpers. +- `content_filter_proxy.py` β€” local proxy for OpenCode/Hermes/Codex. +- `omnigents_host.py` β€” host supervision and spawned-runner refresh wiring. +- `pat_rotator.py` β€” optional user-PAT fallback and rotation. diff --git a/docs/plans/2025-02-03-bundled-skills-design.md b/docs/plans/2025-02-03-bundled-skills-design.md deleted file mode 100644 index ee9f95f..0000000 --- a/docs/plans/2025-02-03-bundled-skills-design.md +++ /dev/null @@ -1,170 +0,0 @@ -# Pre-bundled Databricks Skills & Superpowers Plugin - -**Date:** 2025-02-03 -**Status:** Approved - -## Overview - -Bundle Databricks skills and the superpowers plugin into the Claude Code on Databricks app so users have immediate access to Databricks-specific knowledge and development workflows. - -## Goals - -- Users get 16 Databricks skills out of the box (no manual installation) -- Users get the full superpowers plugin (TDD, debugging, brainstorming, etc.) -- Skills are version-controlled with the app -- Welcome message shows available capabilities - -## Directory Structure - -``` -xterm-experiment/ -β”œβ”€β”€ .claude/ -β”‚ β”œβ”€β”€ skills/ # Databricks skills (16) -β”‚ β”‚ β”œβ”€β”€ agent-bricks/ -β”‚ β”‚ β”œβ”€β”€ aibi-dashboards/ -β”‚ β”‚ β”œβ”€β”€ asset-bundles/ -β”‚ β”‚ β”œβ”€β”€ databricks-app-apx/ -β”‚ β”‚ β”œβ”€β”€ databricks-app-python/ -β”‚ β”‚ β”œβ”€β”€ databricks-config/ -β”‚ β”‚ β”œβ”€β”€ databricks-docs/ -β”‚ β”‚ β”œβ”€β”€ databricks-genie/ -β”‚ β”‚ β”œβ”€β”€ databricks-jobs/ -β”‚ β”‚ β”œβ”€β”€ databricks-python-sdk/ -β”‚ β”‚ β”œβ”€β”€ databricks-unity-catalog/ -β”‚ β”‚ β”œβ”€β”€ mlflow-evaluation/ -β”‚ β”‚ β”œβ”€β”€ model-serving/ -β”‚ β”‚ β”œβ”€β”€ spark-declarative-pipelines/ -β”‚ β”‚ β”œβ”€β”€ synthetic-data-generation/ -β”‚ β”‚ └── unstructured-pdf-generation/ -β”‚ β”‚ -β”‚ └── plugins/ -β”‚ └── superpowers/ # Full superpowers plugin -β”‚ β”œβ”€β”€ .claude-plugin/ -β”‚ β”‚ └── plugin.json -β”‚ β”œβ”€β”€ skills/ # 14 skills -β”‚ β”œβ”€β”€ commands/ -β”‚ β”œβ”€β”€ hooks/ -β”‚ β”œβ”€β”€ agents/ -β”‚ └── ... -β”œβ”€β”€ setup_claude.py # Modified to register plugin -β”œβ”€β”€ app.py # Modified to start PTY in ~/projects/ -β”œβ”€β”€ CLAUDE.md # Welcome message -└── README.md # Updated documentation -``` - -## Implementation Details - -### 1. Bundle Databricks Skills - -Copy all 16 skills from [ai-dev-kit](https://github.com/databricks-solutions/ai-dev-kit) `databricks-skills/` to `.claude/skills/`: - -| Category | Skills | -|----------|--------| -| AI & Agents | agent-bricks, databricks-genie, mlflow-evaluation, model-serving | -| Analytics | aibi-dashboards, databricks-unity-catalog | -| Data Engineering | spark-declarative-pipelines, databricks-jobs, synthetic-data-generation | -| Development | asset-bundles, databricks-app-apx, databricks-app-python, databricks-python-sdk, databricks-config | -| Reference | databricks-docs, unstructured-pdf-generation | - -### 2. Bundle Superpowers Plugin - -Copy full plugin from [superpowers](https://github.com/obra/superpowers) to `.claude/plugins/superpowers/`: - -- 14 skills (brainstorming, TDD, systematic-debugging, etc.) -- Commands (/commit, etc.) -- Hooks -- Agents - -### 3. Register Plugin in setup_claude.py - -```python -# 6. Register bundled superpowers plugin -plugins_dir = claude_dir / "plugins" -plugins_dir.mkdir(exist_ok=True) - -installed_plugins = { - "version": 2, - "plugins": { - "superpowers@bundled": [ - { - "scope": "user", - "installPath": str(home / ".claude" / "plugins" / "superpowers"), - "version": "4.0.3", - "installedAt": "2025-01-01T00:00:00.000Z", - "lastUpdated": "2025-01-01T00:00:00.000Z" - } - ] - } -} - -plugins_json_path = plugins_dir / "installed_plugins.json" -plugins_json_path.write_text(json.dumps(installed_plugins, indent=2)) -print("Superpowers plugin registered") -``` - -### 4. Start PTY in ~/projects/ - -Modify `app.py` to start shell sessions in the projects directory: - -```python -# In create_session(), when spawning the PTY: -projects_dir = os.path.expanduser("~/projects") -os.makedirs(projects_dir, exist_ok=True) - -pid, fd = pty.fork() -if pid == 0: - os.chdir(projects_dir) # Start in projects/ - os.execvpe('/bin/bash', ['/bin/bash', '-l'], env) -``` - -### 5. Welcome Message (CLAUDE.md) - -Create `CLAUDE.md` at repo root: - -```markdown -# Claude Code on Databricks - -Welcome! This environment comes pre-configured with: - -## Databricks Skills (16) -- **AI & Agents**: agent-bricks, databricks-genie, mlflow-evaluation, model-serving -- **Analytics**: aibi-dashboards, databricks-unity-catalog -- **Data Engineering**: spark-declarative-pipelines, databricks-jobs, synthetic-data-generation -- **Development**: asset-bundles, databricks-app-apx, databricks-app-python, databricks-python-sdk, databricks-config -- **Reference**: databricks-docs, unstructured-pdf-generation - -## Superpowers Plugin -- brainstorming, test-driven-development, systematic-debugging, writing-plans, and more - -## Quick Start -- Projects sync to Databricks Workspace on git commit -- Use `/commit` for guided commits -- Ask "help me create a dashboard" to see skills in action -``` - -### 6. README Update - -Document bundled skills with credits to source repositories: - -- [databricks-solutions/ai-dev-kit](https://github.com/databricks-solutions/ai-dev-kit) - Databricks skills -- [obra/superpowers](https://github.com/obra/superpowers) - Development workflow plugin - -Include update instructions for keeping skills current. - -## Updating Skills - -Since skills are bundled (not downloaded at startup), updates require: - -1. Pull latest from ai-dev-kit repo -2. Copy updated skills to `.claude/skills/` -3. Redeploy the app - -## Trade-offs - -| Approach | Chosen | Reason | -|----------|--------|--------| -| Bundled vs Download at startup | Bundled | Faster startup, no network dependency, predictable | -| All skills vs Subset | All 16 | Comprehensive coverage | -| Skills location | `.claude/skills/` | Standard location, auto-loaded | -| Superpowers full vs skills-only | Full plugin | Get commands, hooks, agents too | -| HOME vs working dir change | Working dir | Keep .claude/ separate, only projects sync | diff --git a/docs/plans/2026-02-02-web-terminal-design.md b/docs/plans/2026-02-02-web-terminal-design.md deleted file mode 100644 index e0ca60c..0000000 --- a/docs/plans/2026-02-02-web-terminal-design.md +++ /dev/null @@ -1,294 +0,0 @@ -# Web Terminal for Databricks Apps with Claude Code - -**Date:** 2026-02-02 -**Status:** Approved - -## Overview - -A web-based terminal emulator deployed as a Databricks App that provides shell access to the container, with Claude Code pre-configured for vibe coding. - -## Architecture - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Databricks App Container β”‚ -β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” WebSocket β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ │◄────────────────────►│ β”‚ β”‚ -β”‚ β”‚ Flask App β”‚ β”‚ PTY Process β”‚ β”‚ -β”‚ β”‚ (Backend) β”‚ β”‚ (bash shell) β”‚ β”‚ -β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ -β”‚ β”‚ β”‚ β”‚Claude Code β”‚ β”‚ β”‚ -β”‚ β”‚ serves β”‚ β”‚ (CLI) β”‚ β”‚ β”‚ -β”‚ β–Ό β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ xterm.js β”‚ β”‚ -β”‚ β”‚ (Frontend) β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β–² - β”‚ HTTPS - β–Ό -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ User Browser β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -## Components - -### Backend (Flask + WebSocket + PTY) - -- **Flask** serves the static frontend -- **flask-socketio** handles WebSocket connections -- **ptyprocess** spawns bash shells -- Each connection gets its own PTY session - -### Frontend (xterm.js) - -- Terminal emulator in the browser -- Loaded from CDN -- Socket.IO client for WebSocket communication -- Auto-resizes to viewport - -### Claude Code Configuration - -Uses Databricks model serving instead of direct Anthropic API: - -| File | Purpose | -|------|---------| -| `~/.claude/settings.json` | Databricks model serving config | -| `~/.claude.json` | Skip onboarding prompt (v2.0.65+ fix) | - -## Project Structure - -``` -xterm-experiment/ -β”œβ”€β”€ app.py # Flask + WebSocket + PTY -β”œβ”€β”€ setup_claude.py # Pre-configures Claude for Databricks -β”œβ”€β”€ requirements.txt -β”œβ”€β”€ app.yaml -└── static/ - └── index.html -``` - -## Files - -### requirements.txt - -``` -flask>=2.0 -flask-socketio>=5.0 -gevent>=21.0 -gevent-websocket>=0.10 -ptyprocess>=0.7 -claude-agent-sdk -``` - -### app.yaml - -```yaml -command: - - bash - - -c - - "python setup_claude.py && python app.py" -env: - - name: DATABRICKS_HOST - value: https://fevm-serverless-9cefok.cloud.databricks.com - - name: DATABRICKS_TOKEN - valueFrom: DATABRICKS_TOKEN -``` - -### app.py - -```python -import os -import pty -import select -import subprocess -from flask import Flask, send_from_directory -from flask_socketio import SocketIO, emit, request - -app = Flask(__name__) -socketio = SocketIO(app, cors_allowed_origins="*", async_mode="gevent") - -# Store PTY file descriptors per session -sessions = {} - -@app.route("/") -def index(): - return send_from_directory("static", "index.html") - -@socketio.on("connect") -def handle_connect(): - """Spawn a new PTY bash shell for this connection.""" - try: - master_fd, slave_fd = pty.openpty() - pid = subprocess.Popen( - ["/bin/bash"], - stdin=slave_fd, - stdout=slave_fd, - stderr=slave_fd, - preexec_fn=os.setsid - ).pid - sessions[request.sid] = {"master_fd": master_fd, "pid": pid} - socketio.start_background_task(read_pty_output, request.sid, master_fd) - except Exception as e: - emit("output", f"\x1b[31mError spawning shell: {e}\x1b[0m\r\n") - -@socketio.on("input") -def handle_input(data): - """Forward user input to the PTY.""" - fd = sessions.get(request.sid, {}).get("master_fd") - if fd: - os.write(fd, data.encode()) - -@socketio.on("disconnect") -def handle_disconnect(): - """Clean up PTY on disconnect.""" - session = sessions.pop(request.sid, None) - if session: - os.close(session["master_fd"]) - -def read_pty_output(sid, fd): - """Read PTY output and send to browser.""" - while sid in sessions: - if select.select([fd], [], [], 0.1)[0]: - try: - output = os.read(fd, 1024).decode(errors="replace") - socketio.emit("output", output, to=sid) - except OSError: - socketio.emit("output", "\r\n\x1b[31mShell disconnected.\x1b[0m\r\n", to=sid) - break - -if __name__ == "__main__": - socketio.run(app, host="0.0.0.0", port=8000) -``` - -### setup_claude.py - -```python -import os -import json -from pathlib import Path - -# Create ~/.claude directory -claude_dir = Path.home() / ".claude" -claude_dir.mkdir(exist_ok=True) - -# 1. Write settings.json for Databricks model serving -settings = { - "env": { - "ANTHROPIC_MODEL": "databricks-claude-sonnet-4-5", - "ANTHROPIC_BASE_URL": f"{os.environ['DATABRICKS_HOST']}/serving-endpoints/anthropic", - "ANTHROPIC_AUTH_TOKEN": os.environ["DATABRICKS_TOKEN"], - "ANTHROPIC_CUSTOM_HEADERS": "x-databricks-use-coding-agent-mode: true" - } -} - -settings_path = claude_dir / "settings.json" -settings_path.write_text(json.dumps(settings, indent=2)) - -# 2. Write ~/.claude.json to skip onboarding (v2.0.65+ fix) -claude_json = { - "hasCompletedOnboarding": True -} - -claude_json_path = Path.home() / ".claude.json" -claude_json_path.write_text(json.dumps(claude_json, indent=2)) - -print(f"Claude configured: {settings_path}") -print(f"Onboarding skipped: {claude_json_path}") -``` - -### static/index.html - -```html - - - - Terminal - - - - -
- - - - - - - -``` - -## Deployment - -1. **Create the app:** - ```bash - databricks apps create xterm-terminal - ``` - -2. **Set the token secret:** - ```bash - databricks secrets create-scope xterm-terminal - databricks secrets put-secret xterm-terminal DATABRICKS_TOKEN - ``` - -3. **Deploy:** - ```bash - databricks apps deploy xterm-terminal --source-code-path . - ``` - -## Known Limitations - -| Limitation | Impact | Workaround | -|------------|--------|------------| -| No persistence | Files lost on redeploy | Mount workspace volume (future) | -| Single user per session | Each tab = new shell | Expected behavior | -| 12hr Databricks session limit | Long sessions timeout | User reconnects | -| No terminal resize signaling | Fixed size initially | Can add SIGWINCH handling | -| Container resources | Limited CPU/memory | Use for coding, not heavy compute | - -## Security Considerations - -- Shell runs as app user (not root) -- Databricks token scoped to model serving -- No network egress restrictions by default (Claude can `curl`, `git clone`, etc.) - -## Future Enhancements - -- Persistent workspace via mounted volumes -- Multi-user authentication -- Terminal resize signaling (SIGWINCH) -- Session recording/playback diff --git a/docs/plans/2026-02-02-web-terminal-implementation.md b/docs/plans/2026-02-02-web-terminal-implementation.md deleted file mode 100644 index ecf4d5f..0000000 --- a/docs/plans/2026-02-02-web-terminal-implementation.md +++ /dev/null @@ -1,398 +0,0 @@ -# Web Terminal Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Build a deployable web terminal for Databricks Apps with Claude Code pre-configured. - -**Architecture:** Flask backend spawns PTY shells per WebSocket connection; xterm.js frontend renders terminal in browser; setup script configures Claude Code for Databricks model serving. - -**Tech Stack:** Flask, flask-socketio, gevent, ptyprocess, xterm.js, Socket.IO - ---- - -## Task 1: Create Project Dependencies - -**Files:** -- Create: `requirements.txt` - -**Step 1: Create requirements.txt** - -``` -flask>=2.0 -flask-socketio>=5.0 -gevent>=21.0 -gevent-websocket>=0.10 -ptyprocess>=0.7 -claude-agent-sdk -``` - -**Step 2: Commit** - -```bash -git add requirements.txt -git commit -m "feat: add Python dependencies for web terminal" -``` - ---- - -## Task 2: Create Claude Configuration Script - -**Files:** -- Create: `setup_claude.py` - -**Step 1: Create setup_claude.py** - -```python -import os -import json -from pathlib import Path - -# Create ~/.claude directory -claude_dir = Path.home() / ".claude" -claude_dir.mkdir(exist_ok=True) - -# 1. Write settings.json for Databricks model serving -settings = { - "env": { - "ANTHROPIC_MODEL": "databricks-claude-sonnet-4-5", - "ANTHROPIC_BASE_URL": f"{os.environ['DATABRICKS_HOST']}/serving-endpoints/anthropic", - "ANTHROPIC_AUTH_TOKEN": os.environ["DATABRICKS_TOKEN"], - "ANTHROPIC_CUSTOM_HEADERS": "x-databricks-use-coding-agent-mode: true" - } -} - -settings_path = claude_dir / "settings.json" -settings_path.write_text(json.dumps(settings, indent=2)) - -# 2. Write ~/.claude.json to skip onboarding (v2.0.65+ fix) -claude_json = { - "hasCompletedOnboarding": True -} - -claude_json_path = Path.home() / ".claude.json" -claude_json_path.write_text(json.dumps(claude_json, indent=2)) - -print(f"Claude configured: {settings_path}") -print(f"Onboarding skipped: {claude_json_path}") -``` - -**Step 2: Test script runs without error (mock env vars)** - -```bash -DATABRICKS_HOST=https://example.databricks.com DATABRICKS_TOKEN=test python setup_claude.py -``` - -Expected: Prints paths, creates files in home directory - -**Step 3: Verify files created** - -```bash -cat ~/.claude/settings.json -cat ~/.claude.json -``` - -Expected: JSON files with correct structure - -**Step 4: Commit** - -```bash -git add setup_claude.py -git commit -m "feat: add Claude Code configuration script for Databricks" -``` - ---- - -## Task 3: Create Frontend HTML - -**Files:** -- Create: `static/index.html` - -**Step 1: Create static directory** - -```bash -mkdir -p static -``` - -**Step 2: Create static/index.html** - -```html - - - - Terminal - - - - -
- - - - - - - -``` - -**Step 3: Commit** - -```bash -git add static/index.html -git commit -m "feat: add xterm.js frontend for web terminal" -``` - ---- - -## Task 4: Create Flask Backend - -**Files:** -- Create: `app.py` - -**Step 1: Create app.py** - -```python -import os -import pty -import select -import subprocess -from flask import Flask, send_from_directory -from flask_socketio import SocketIO, emit, request - -app = Flask(__name__) -socketio = SocketIO(app, cors_allowed_origins="*", async_mode="gevent") - -# Store PTY file descriptors per session -sessions = {} - - -@app.route("/") -def index(): - return send_from_directory("static", "index.html") - - -@socketio.on("connect") -def handle_connect(): - """Spawn a new PTY bash shell for this connection.""" - try: - master_fd, slave_fd = pty.openpty() - pid = subprocess.Popen( - ["/bin/bash"], - stdin=slave_fd, - stdout=slave_fd, - stderr=slave_fd, - preexec_fn=os.setsid - ).pid - sessions[request.sid] = {"master_fd": master_fd, "pid": pid} - socketio.start_background_task(read_pty_output, request.sid, master_fd) - except Exception as e: - emit("output", f"\x1b[31mError spawning shell: {e}\x1b[0m\r\n") - - -@socketio.on("input") -def handle_input(data): - """Forward user input to the PTY.""" - fd = sessions.get(request.sid, {}).get("master_fd") - if fd: - os.write(fd, data.encode()) - - -@socketio.on("disconnect") -def handle_disconnect(): - """Clean up PTY on disconnect.""" - session = sessions.pop(request.sid, None) - if session: - os.close(session["master_fd"]) - - -def read_pty_output(sid, fd): - """Read PTY output and send to browser.""" - while sid in sessions: - if select.select([fd], [], [], 0.1)[0]: - try: - output = os.read(fd, 1024).decode(errors="replace") - socketio.emit("output", output, to=sid) - except OSError: - socketio.emit("output", "\r\n\x1b[31mShell disconnected.\x1b[0m\r\n", to=sid) - break - - -if __name__ == "__main__": - socketio.run(app, host="0.0.0.0", port=8000) -``` - -**Step 2: Commit** - -```bash -git add app.py -git commit -m "feat: add Flask backend with WebSocket PTY handling" -``` - ---- - -## Task 5: Create Databricks App Configuration - -**Files:** -- Create: `app.yaml` - -**Step 1: Create app.yaml** - -```yaml -command: - - bash - - -c - - "python setup_claude.py && python app.py" -env: - - name: DATABRICKS_HOST - value: https://fevm-serverless-9cefok.cloud.databricks.com - - name: DATABRICKS_TOKEN - valueFrom: DATABRICKS_TOKEN -``` - -**Step 2: Commit** - -```bash -git add app.yaml -git commit -m "feat: add Databricks App deployment configuration" -``` - ---- - -## Task 6: Local Testing - -**Step 1: Install dependencies** - -```bash -uv pip install -r requirements.txt -``` - -**Step 2: Run the app locally** - -```bash -python app.py -``` - -Expected: Server starts on http://0.0.0.0:8000 - -**Step 3: Test in browser** - -Open http://localhost:8000 in browser. - -Expected: -- Dark terminal appears -- Green "Connected" message shows -- Can type commands (ls, pwd, etc.) -- Output renders correctly - -**Step 4: Test terminal functionality** - -In the web terminal, run: -```bash -echo "hello world" -ls -la -pwd -``` - -Expected: Commands execute and output displays - -**Step 5: Stop the server** - -Press Ctrl+C in terminal running app.py - ---- - -## Task 7: Final Commit - -**Step 1: Verify all files present** - -```bash -ls -la -ls -la static/ -``` - -Expected structure: -``` -xterm-experiment/ -β”œβ”€β”€ app.py -β”œβ”€β”€ app.yaml -β”œβ”€β”€ requirements.txt -β”œβ”€β”€ setup_claude.py -β”œβ”€β”€ static/ -β”‚ └── index.html -└── docs/ - └── plans/ - └── 2026-02-02-web-terminal-design.md -``` - -**Step 2: Final commit if any uncommitted changes** - -```bash -git status -``` - -If changes exist: -```bash -git add -A -git commit -m "chore: finalize web terminal implementation" -``` - ---- - -## Deployment (Manual - After Local Testing) - -Once local testing passes, deploy to Databricks: - -```bash -# 1. Create the app (if not exists) -databricks apps create xterm-terminal - -# 2. Set up secrets (one-time) -databricks secrets create-scope xterm-terminal -databricks secrets put-secret xterm-terminal DATABRICKS_TOKEN - -# 3. Deploy -databricks apps deploy xterm-terminal --source-code-path . -``` - ---- - -## Summary - -| Task | Description | Files | -|------|-------------|-------| -| 1 | Dependencies | requirements.txt | -| 2 | Claude config | setup_claude.py | -| 3 | Frontend | static/index.html | -| 4 | Backend | app.py | -| 5 | App config | app.yaml | -| 6 | Local test | - | -| 7 | Final commit | - | diff --git a/docs/plans/2026-02-03-session-timeout-design.md b/docs/plans/2026-02-03-session-timeout-design.md deleted file mode 100644 index b4ed7f7..0000000 --- a/docs/plans/2026-02-03-session-timeout-design.md +++ /dev/null @@ -1,113 +0,0 @@ -# Session Timeout Design - -## Problem - -When frontend tabs close unexpectedly (browser crash, network drop, force quit), the backend PTY sessions remain open indefinitely. The `beforeunload` beacon cleanup only works for graceful tab closes. - -## Solution - -Use the existing 100ms polling as an implicit heartbeat. If `/api/output` hasn't been called for 60 seconds, assume the frontend is gone and terminate the session gracefully. - -## Design - -### Configuration Constants - -```python -SESSION_TIMEOUT_SECONDS = 60 # No poll for 60s = dead session -CLEANUP_INTERVAL_SECONDS = 30 # How often to check for stale sessions -GRACEFUL_SHUTDOWN_WAIT = 3 # Seconds to wait after SIGHUP before SIGKILL -``` - -### Data Model Changes - -Add `last_poll_time` to session structure: - -```python -sessions[session_id] = { - "master_fd": master_fd, - "pid": pid, - "output_buffer": deque(maxlen=1000), - "last_poll_time": time.time(), # NEW - "created_at": time.time() # NEW -} -``` - -Update timestamp on every poll in `/api/output`: - -```python -sessions[session_id]["last_poll_time"] = time.time() -``` - -### Cleanup Thread - -Background thread runs every 30 seconds: - -```python -def cleanup_stale_sessions(): - while True: - time.sleep(CLEANUP_INTERVAL_SECONDS) - - now = time.time() - stale_sessions = [] - - with sessions_lock: - for session_id, session in sessions.items(): - if now - session["last_poll_time"] > SESSION_TIMEOUT_SECONDS: - stale_sessions.append((session_id, session["pid"], session["master_fd"])) - - for session_id, pid, master_fd in stale_sessions: - terminate_session(session_id, pid, master_fd) -``` - -### Graceful Termination - -SIGHUP first, wait 3 seconds, then SIGKILL if still alive: - -```python -def terminate_session(session_id, pid, master_fd): - try: - os.kill(pid, signal.SIGHUP) - time.sleep(GRACEFUL_SHUTDOWN_WAIT) - - try: - os.kill(pid, 0) # Check if still alive - os.kill(pid, signal.SIGKILL) - except OSError: - pass # Already dead - - os.close(master_fd) - except OSError: - pass - - with sessions_lock: - sessions.pop(session_id, None) -``` - -### Thread Startup - -Add before `app.run()`: - -```python -cleanup_thread = threading.Thread(target=cleanup_stale_sessions, daemon=True) -cleanup_thread.start() -``` - -## Behavior - -| Scenario | Result | -|----------|--------| -| Browser open, user idle | Polling continues, session stays alive | -| Browser closed gracefully | Beacon fires, immediate cleanup | -| Browser crash / force quit | Polling stops, cleanup after 60s | -| Network disconnect | Polling stops, cleanup after 60s | -| Tab force-closed | Polling stops, cleanup after 60s | - -## Files to Modify - -- `app.py` - All backend changes (data model, cleanup thread, termination logic) - -## Not In Scope - -- Input-based idle timeout (killing sessions where user hasn't typed) -- Maximum session limits -- Session persistence/reconnection diff --git a/docs/plans/2026-02-03-workspace-sync-design.md b/docs/plans/2026-02-03-workspace-sync-design.md deleted file mode 100644 index 56add54..0000000 --- a/docs/plans/2026-02-03-workspace-sync-design.md +++ /dev/null @@ -1,194 +0,0 @@ -# Git-Based Workspace Sync Design - -**Goal:** Auto-sync user projects from the container to Databricks Workspace on git commit. - -**Architecture:** Git post-commit hook triggers `databricks sync` to upload project files to `/Workspace/Users//projects/`. - ---- - -## Overview - -When users create projects in the `~/projects` folder and commit with git, their code automatically syncs to their Databricks Workspace. This ensures work persists even when the container restarts. - -``` -Container Databricks Workspace -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ ~/projects/ β”‚ git commit β”‚ /Workspace/Users// β”‚ -β”‚ my-app/ β”‚ ────────────► β”‚ projects/ β”‚ -β”‚ .git/hooks/ β”‚ post-commit β”‚ my-app/ β”‚ -β”‚ post-commit β”‚ triggers β”‚ (synced files) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - ---- - -## Implementation Plan - -### Files to Create/Modify - -| File | Action | Purpose | -|------|--------|---------| -| `sync_to_workspace.py` | Create | Sync script called by git hook | -| `setup_claude.py` | Modify | Add projects folder + git template setup | -| `requirements.txt` | Modify | Add databricks-sdk | -| `static/index.html` | Modify | Update welcome message | - ---- - -### Task 1: Create sync_to_workspace.py - -```python -#!/usr/bin/env python3 -"""Sync a project directory to Databricks Workspace.""" -import os -import sys -import subprocess -from pathlib import Path - -def get_user_email(): - """Get current user's email from Databricks token.""" - from databricks.sdk import WorkspaceClient - w = WorkspaceClient() - return w.current_user.me().user_name - -def sync_project(project_path: Path): - """Sync project to user's Workspace.""" - try: - user_email = get_user_email() - workspace_dest = f"/Workspace/Users/{user_email}/projects/{project_path.name}" - - result = subprocess.run( - ["databricks", "sync", str(project_path), workspace_dest, "--watch=false"], - capture_output=True, - text=True - ) - - if result.returncode == 0: - print(f"βœ“ Synced to {workspace_dest}") - else: - print(f"⚠ Sync warning: {result.stderr}", file=sys.stderr) - - except Exception as e: - # Log error but don't block the commit - error_log = Path.home() / ".sync-errors.log" - with open(error_log, "a") as f: - f.write(f"{project_path}: {e}\n") - print(f"⚠ Sync failed (logged to ~/.sync-errors.log)", file=sys.stderr) - -if __name__ == "__main__": - if len(sys.argv) > 1: - sync_project(Path(sys.argv[1])) - else: - sync_project(Path.cwd()) -``` - ---- - -### Task 2: Update setup_claude.py - -Add after existing code: - -```python -# 4. Create projects directory -projects_dir = home / "projects" -projects_dir.mkdir(exist_ok=True) -print(f"Projects directory: {projects_dir}") - -# 5. Set up git template with post-commit hook -git_template_hooks = home / ".git-templates" / "hooks" -git_template_hooks.mkdir(parents=True, exist_ok=True) - -post_commit_hook = git_template_hooks / "post-commit" -post_commit_hook.write_text('''#!/bin/bash -# Auto-sync to Databricks Workspace on commit -python3 /app/python/source_code/sync_to_workspace.py "$(pwd)" & -''') -post_commit_hook.chmod(0o755) - -# Configure git to use template for new repos -subprocess.run( - ["git", "config", "--global", "init.templateDir", str(home / ".git-templates")], - capture_output=True -) -print("Git post-commit hook template configured") -``` - ---- - -### Task 3: Update requirements.txt - -Add: -``` -databricks-sdk>=0.20.0 -``` - ---- - -### Task 4: Update welcome message in static/index.html - -Change the welcome message to: -```javascript -term.write('\x1b[32mConnected. Type "claude" to start coding.\x1b[0m\r\n'); -term.write('\x1b[90mProjects in ~/projects auto-sync to Workspace on git commit.\x1b[0m\r\n\r\n'); -``` - ---- - -## User Workflow - -```bash -# 1. User connects to terminal -# 2. Navigate to projects folder -cd ~/projects - -# 3. Create a new project -mkdir my-app && cd my-app - -# 4. Initialize git (post-commit hook auto-installed) -git init - -# 5. Write code with Claude... - -# 6. Commit triggers sync -git add . && git commit -m "initial" -# Output: βœ“ Synced to /Workspace/Users/user@company.com/projects/my-app -``` - ---- - -## Configuration - -- **Sync destination:** `/Workspace/Users//projects/` -- **User email:** Derived from Databricks token via SDK at runtime -- **Trigger:** Git post-commit hook (only on commits) - ---- - -## Error Handling - -- Sync failures are logged to `~/.sync-errors.log` -- Errors don't block git commits -- Failed syncs retry on next commit - ---- - -## Verification - -1. Deploy the updated app -2. Connect to terminal -3. Run: - ```bash - cd ~/projects - mkdir test-sync && cd test-sync - git init - echo "# Test" > README.md - git add . && git commit -m "test" - ``` -4. Check Databricks Workspace: `/Workspace/Users//projects/test-sync/` -5. Verify README.md appears - ---- - -## Dependencies - -- `databricks-sdk>=0.20.0` (add to requirements.txt) diff --git a/docs/plans/2026-03-08-multi-tab-terminals-design.md b/docs/plans/2026-03-08-multi-tab-terminals-design.md deleted file mode 100644 index 0720b38..0000000 --- a/docs/plans/2026-03-08-multi-tab-terminals-design.md +++ /dev/null @@ -1,103 +0,0 @@ -# Multi-Tab Terminal Support - -**Date:** 2026-03-08 -**Status:** Approved - -## Overview - -Add browser-style tabs to the web terminal, where each tab owns its own independent split-pane layout. This lets users run multiple concurrent sessions (e.g., Claude in one tab, logs in another, git in a third) without losing the existing split-pane feature. - -## Architecture - -``` -β”Œβ”€[Shell 1]──[Shell 2]──[+ ]──────────────────────────────────┐ -β”‚ β”‚ -β”‚ Tab 1's pane container (hidden when tab inactive) β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” | β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Pane 1 β”‚ | β”‚ Pane 2 β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ | β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ -β”‚ Tab 2's pane container (display:none when inactive) β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Pane 1 (full width) β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -## Data Model - -```javascript -tabs = [ - { - id: "tab-1", - label: "Shell 1", // double-click to rename - panes: [ // each tab owns 1-2 panes - { id, element, term, fitAddon, searchAddon, sessionId } - ], - activePaneId: "pane-1", - paneContainer:
, // per-tab container element - divider:
| null // per-tab divider (if split) - } -] -activeTabId = "tab-1" -``` - -## Constraints - -- **Max 5 tabs** (up to 2 panes each = max 10 PTY sessions) -- **Default label:** "Shell N" β€” double-click to rename -- **No persistence:** tabs and sessions are lost on page refresh -- **Backend unchanged:** tabs are purely frontend; each pane still calls `/api/session` - -## Tab Bar UI - -- 32px height, positioned above the pane container -- Translucent/blurred background matching existing toolbar aesthetic -- Theme-aware (adapts to light/dark) -- Each tab shows: label + close "x" (visible on hover or when active) -- "+" button at end (hidden when 5 tabs reached) -- Active tab has a subtle bottom border accent - -## Keyboard Shortcuts - -### Tab shortcuts (new) -| Shortcut | Action | -|----------|--------| -| `Ctrl+Shift+T` | New tab | -| `Ctrl+Shift+[` | Previous tab | -| `Ctrl+Shift+]` | Next tab | -| `Ctrl+Shift+1-5` | Jump to tab by number | - -### Pane shortcuts (moved from Ctrl+Shift to Alt+Shift) -| Shortcut | Action | -|----------|--------| -| `Alt+Shift+D` | Split pane within active tab | -| `Alt+Shift+W` | Close pane (closes tab if last pane) | -| `Alt+Shift+[` | Previous pane within tab | -| `Alt+Shift+]` | Next pane within tab | - -## Tab Lifecycle - -1. **Create:** "+" button or `Ctrl+Shift+T`. Creates tab with one pane, spawns PTY, switches to it. -2. **Switch:** Click tab or `Ctrl+Shift+[/]`. Hides current container, shows target, refits panes, focuses active pane. -3. **Rename:** Double-click label, inline edit, Enter to confirm, Escape to cancel. -4. **Close:** Click "x" or close last pane via `Alt+Shift+W`. Terminates all PTY sessions in that tab. If last tab, auto-creates a new "Shell 1". - -## Implementation Scope - -### Modified files -- `static/index.html` β€” tab bar HTML/CSS, JS refactored to wrap panes inside tabs - -### Unchanged files -- `app.py` β€” backend has no tab concept -- `static/poll-worker.js` β€” already supports multiple panes by paneId - -### Estimated changes -- ~40 lines CSS (tab bar styling) -- ~150 lines net JS change (tab management functions, refactored pane logic) - -## Out of Scope (YAGNI) -- Drag-to-reorder tabs -- Tab persistence across page reloads -- Tab-specific themes or settings -- Session reconnection / PTY resumption (separate project) diff --git a/docs/plans/2026-03-08-multi-tab-terminals-implementation.md b/docs/plans/2026-03-08-multi-tab-terminals-implementation.md deleted file mode 100644 index c7517bf..0000000 --- a/docs/plans/2026-03-08-multi-tab-terminals-implementation.md +++ /dev/null @@ -1,946 +0,0 @@ -# Multi-Tab Terminals Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add browser-style tabs to the web terminal where each tab owns its own independent split-pane layout, with renamable labels and a 5-tab cap. - -**Architecture:** Purely frontend change to `static/index.html`. The existing flat `panes[]` array and `activePaneId` get wrapped inside a `tabs[]` array. Each tab owns a dedicated pane container DOM element. Switching tabs toggles CSS `display` on these containers. The backend and poll-worker are unchanged. - -**Tech Stack:** Vanilla JS, xterm.js, existing CSS conventions (translucent/blurred, theme-aware) - ---- - -### Task 1: Add Tab Bar HTML and CSS - -**Files:** -- Modify: `static/index.html:14-17` (pane container CSS) -- Modify: `static/index.html:199-255` (HTML body, before pane-container) - -**Step 1: Add tab bar CSS** - -Insert after line 12 (`#status` rule) and before line 14 (`/* Pane container */`): - -```css - /* Tab bar */ - #tab-bar { - display: flex; align-items: center; height: 32px; width: 100vw; - background: rgba(255,255,255,0.04); - border-bottom: 1px solid rgba(255,255,255,0.08); - backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); - overflow-x: auto; overflow-y: hidden; - user-select: none; flex-shrink: 0; - } - .tab { - display: flex; align-items: center; gap: 6px; - padding: 0 12px; height: 100%; cursor: pointer; - font-size: 12px; white-space: nowrap; - border-right: 1px solid rgba(255,255,255,0.06); - transition: background 0.15s; - position: relative; - } - .tab:hover { background: rgba(255,255,255,0.06); } - .tab.active { - background: rgba(255,255,255,0.08); - border-bottom: 2px solid rgba(100,150,255,0.6); - } - .tab-label { - outline: none; border: none; background: none; - color: inherit; font: inherit; padding: 0; - min-width: 30px; max-width: 120px; - cursor: inherit; - } - .tab-label:focus { - cursor: text; - border-bottom: 1px solid rgba(100,150,255,0.5); - } - .tab-close { - opacity: 0; font-size: 10px; padding: 2px 4px; - border-radius: 3px; border: none; background: none; - color: inherit; cursor: pointer; transition: opacity 0.15s, background 0.15s; - line-height: 1; - } - .tab:hover .tab-close, .tab.active .tab-close { opacity: 0.6; } - .tab-close:hover { opacity: 1 !important; background: rgba(255,255,255,0.1); } - #new-tab-btn { - padding: 0 10px; height: 100%; border: none; background: none; - color: inherit; font-size: 16px; cursor: pointer; - opacity: 0.5; transition: opacity 0.15s; - } - #new-tab-btn:hover { opacity: 1; } - #new-tab-btn:disabled { opacity: 0.2; cursor: default; } -``` - -**Step 2: Update pane container height** - -Change line 15 from: -```css - #pane-container { display: flex; flex-direction: row; height: 100vh; width: 100vw; } -``` -to: -```css - .tab-pane-container { display: flex; flex-direction: row; height: calc(100vh - 33px); width: 100vw; } - .tab-pane-container.hidden { display: none; } -``` - -Note: The old `#pane-container` ID is no longer used. Each tab creates its own `.tab-pane-container` div dynamically. - -**Step 3: Update pane divider CSS** - -Change line 20-27 from `#pane-divider` to class-based: -```css - .pane-divider { - flex: 0 0 4px; cursor: col-resize; - background: rgba(128,128,128,0.15); - transition: background 0.15s; - z-index: 1; - } - .pane-divider:hover, .pane-divider.dragging { - background: rgba(100,150,255,0.5); - } -``` - -**Step 4: Add tab bar HTML** - -Replace line 255 (`
`) with: -```html -
- -
- -``` - -**Step 5: Verify the page loads without errors** - -Open in browser, confirm the tab bar strip renders (empty, with just the "+" button). Terminal won't work yet because the JS still references the old `#pane-container`. - -**Step 6: Commit** - -```bash -git add static/index.html -git commit -m "feat: add tab bar HTML and CSS" -``` - ---- - -### Task 2: Refactor State Model β€” Introduce Tabs Array - -**Files:** -- Modify: `static/index.html:356-383` (State and Pane Object Model sections) - -**Step 1: Replace flat pane state with tabs model** - -Replace the Pane Object Model section (lines 367-383): -```javascript - // ── Pane Object Model ───────────────────────────────────────── - // Each pane: { id, element, term, fitAddon, searchAddon, sessionId } - let panes = []; - let activePaneId = null; - let paneIdCounter = 0; - - function getActivePane() { - return panes.find(p => p.id === activePaneId) || panes[0]; - } - - function focusPane(id) { - activePaneId = id; - panes.forEach(p => { - p.element.classList.toggle('active', p.id === id); - if (p.id === id) p.term.focus(); - }); - } -``` - -With: -```javascript - // ── Tab & Pane Object Model ─────────────────────────────────── - // Tab: { id, label, panes[], activePaneId, paneContainer, divider } - // Pane: { id, element, term, fitAddon, searchAddon, sessionId } - const MAX_TABS = 5; - let tabs = []; - let activeTabId = null; - let tabIdCounter = 0; - let paneIdCounter = 0; - - function getActiveTab() { - return tabs.find(t => t.id === activeTabId) || tabs[0]; - } - - function getActivePane() { - const tab = getActiveTab(); - if (!tab) return null; - return tab.panes.find(p => p.id === tab.activePaneId) || tab.panes[0]; - } - - function getAllPanes() { - return tabs.flatMap(t => t.panes); - } - - function focusPane(id) { - const tab = getActiveTab(); - if (!tab) return; - tab.activePaneId = id; - tab.panes.forEach(p => { - p.element.classList.toggle('active', p.id === id); - if (p.id === id) p.term.focus(); - }); - } -``` - -**Step 2: Verify no syntax errors** - -Page will be broken (functions reference old `panes` global). That's expected β€” we fix the references in the next tasks. - -**Step 3: Commit** - -```bash -git add static/index.html -git commit -m "refactor: introduce tabs array data model" -``` - ---- - -### Task 3: Update Theme, Font, and Refit Functions for Tabs - -**Files:** -- Modify: `static/index.html` β€” `applyTheme`, `setFontSize`, `setFontFamily`, `refitAllPanes` functions - -**Step 1: Update applyTheme** - -Replace line 403 (`panes.forEach(...)`) with: -```javascript - getAllPanes().forEach(p => { p.term.options.theme = preset.theme; }); -``` - -**Step 2: Update setFontSize** - -Replace line 424 (`panes.forEach(...)`) with: -```javascript - getAllPanes().forEach(p => { p.term.options.fontSize = currentFontSize; }); -``` - -**Step 3: Update setFontFamily** - -Replace line 434 (`panes.forEach(...)`) with: -```javascript - getAllPanes().forEach(p => { p.term.options.fontFamily = family; }); -``` - -**Step 4: Update refitAllPanes to only refit the active tab's panes** - -Replace the `refitAllPanes` function: -```javascript - function refitAllPanes() { - const tab = getActiveTab(); - if (!tab) return; - tab.panes.forEach(p => { - p.fitAddon.fit(); - if (p.sessionId) sendResize(p.term.cols, p.term.rows, p.sessionId); - }); - } -``` - -**Step 5: Commit** - -```bash -git add static/index.html -git commit -m "refactor: update theme/font/refit to use tabs model" -``` - ---- - -### Task 4: Rewrite createPane to Accept a Parent Tab - -**Files:** -- Modify: `static/index.html` β€” `createPane` function (lines ~773-838) - -**Step 1: Rewrite createPane** - -Replace the entire `createPane` function with: -```javascript - async function createPane(tab) { - const id = 'pane-' + (++paneIdCounter); - const container = tab.paneContainer; - const element = document.createElement('div'); - element.className = 'pane'; - element.id = id; - - // Add divider before second pane - if (tab.panes.length === 1) { - const divider = document.createElement('div'); - divider.className = 'pane-divider'; - container.appendChild(divider); - tab.divider = divider; - setupDividerDrag(divider, tab); - } - - container.appendChild(element); - - const term = new Terminal({ - cursorBlink: true, - fontSize: currentFontSize, - fontFamily: fontFamilies[currentFontFamily] || 'monospace', - theme: themes[currentThemeName].theme - }); - - const fitAddon = new FitAddon.FitAddon(); - term.loadAddon(fitAddon); - term.loadAddon(new WebLinksAddon.WebLinksAddon()); - - let searchAddon = null; - if (typeof SearchAddon !== 'undefined') { - searchAddon = new SearchAddon.SearchAddon(); - term.loadAddon(searchAddon); - } - - if (typeof ImageAddon !== 'undefined' && ImageAddon.ImageAddon) { - term.loadAddon(new ImageAddon.ImageAddon({ - sixelSupport: true, - sixelScrolling: true, - iipSupport: true, - enableSizeReports: true, - storageLimit: 128 - })); - } - - term.open(element); - fitAddon.fit(); - - const sid = await createSession(); - await sendResize(term.cols, term.rows, sid); - - term.write('\x1b[32mConnected. Type "claude" to start coding.\x1b[0m\r\n'); - term.write('\x1b[90mProjects in ~/projects auto-sync to Workspace on git commit.\x1b[0m\r\n'); - term.write('\x1b[90mCtrl+Shift+T new tab \u2502 Alt+Shift+D split pane \u2502 Alt+Shift+W close pane\x1b[0m\r\n\r\n'); - - const pane = { id, element, term, fitAddon, searchAddon, sessionId: sid }; - term.onData(data => sendInput(data, pane.sessionId)); - pollWorker.postMessage({ type: 'start_poll', paneId: id, sessionId: sid }); - - // Click to focus - element.addEventListener('mousedown', () => focusPane(id)); - - tab.panes.push(pane); - focusPane(id); - - return pane; - } -``` - -**Step 2: Commit** - -```bash -git add static/index.html -git commit -m "refactor: createPane now accepts parent tab" -``` - ---- - -### Task 5: Implement Tab Management Functions (createTab, switchTab, closeTab, renameTab) - -**Files:** -- Modify: `static/index.html` β€” add new functions after createPane - -**Step 1: Add createTab function** - -Insert after the `createPane` function: -```javascript - // ── Tab Management ────────────────────────────────────────────── - async function createTab() { - if (tabs.length >= MAX_TABS) return null; - - const id = 'tab-' + (++tabIdCounter); - const label = 'Shell ' + tabIdCounter; - - // Create per-tab pane container - const paneContainer = document.createElement('div'); - paneContainer.className = 'tab-pane-container'; - paneContainer.id = id + '-panes'; - document.body.appendChild(paneContainer); - - const tab = { - id, - label, - panes: [], - activePaneId: null, - paneContainer, - divider: null - }; - - tabs.push(tab); - - // Render tab in the tab bar - renderTabBar(); - - // Switch to new tab (hides others) - switchTab(id); - - // Create first pane - await createPane(tab); - - updateTabButtons(); - return tab; - } - - function switchTab(id) { - const prevTab = getActiveTab(); - activeTabId = id; - - // Toggle pane container visibility - tabs.forEach(t => { - t.paneContainer.classList.toggle('hidden', t.id !== id); - }); - - // Update tab bar active state - renderTabBar(); - - // Refit panes in the newly visible tab and focus - const tab = getActiveTab(); - if (tab && tab.panes.length > 0) { - requestAnimationFrame(() => { - refitAllPanes(); - const ap = tab.panes.find(p => p.id === tab.activePaneId) || tab.panes[0]; - if (ap) ap.term.focus(); - }); - } - } - - function closeTab(id) { - const tab = tabs.find(t => t.id === id); - if (!tab) return; - - // Cleanup all panes in this tab - tab.panes.forEach(p => { - cleanupPane(p); - p.term.dispose(); - }); - - // Remove DOM - tab.paneContainer.remove(); - - // Remove from array - tabs = tabs.filter(t => t.id !== id); - - // If we closed the active tab, switch to the last tab - if (activeTabId === id) { - if (tabs.length > 0) { - switchTab(tabs[tabs.length - 1].id); - } - } - - // If no tabs left, create a new one - if (tabs.length === 0) { - tabIdCounter = 0; - createTab(); - return; - } - - renderTabBar(); - updateTabButtons(); - } - - function startRenameTab(id) { - const labelEl = document.querySelector(`#tab-bar .tab[data-tab-id="${id}"] .tab-label`); - if (!labelEl) return; - labelEl.contentEditable = 'true'; - labelEl.focus(); - - // Select all text - const range = document.createRange(); - range.selectNodeContents(labelEl); - window.getSelection().removeAllRanges(); - window.getSelection().addRange(range); - - function finishRename() { - labelEl.contentEditable = 'false'; - const newLabel = labelEl.textContent.trim(); - const tab = tabs.find(t => t.id === id); - if (tab && newLabel) { - tab.label = newLabel; - } else if (tab) { - labelEl.textContent = tab.label; // revert empty - } - labelEl.removeEventListener('blur', finishRename); - labelEl.removeEventListener('keydown', handleKey); - // Refocus terminal - const ap = getActivePane(); - if (ap) ap.term.focus(); - } - - function handleKey(e) { - if (e.key === 'Enter') { - e.preventDefault(); - finishRename(); - } - if (e.key === 'Escape') { - e.preventDefault(); - const tab = tabs.find(t => t.id === id); - if (tab) labelEl.textContent = tab.label; - finishRename(); - } - } - - labelEl.addEventListener('blur', finishRename); - labelEl.addEventListener('keydown', handleKey); - } -``` - -**Step 2: Add renderTabBar function** - -```javascript - function renderTabBar() { - const tabBar = document.getElementById('tab-bar'); - const newTabBtn = document.getElementById('new-tab-btn'); - - // Remove old tab elements (keep the + button) - tabBar.querySelectorAll('.tab').forEach(el => el.remove()); - - // Insert tabs before the + button - tabs.forEach((tab, index) => { - const tabEl = document.createElement('div'); - tabEl.className = 'tab' + (tab.id === activeTabId ? ' active' : ''); - tabEl.dataset.tabId = tab.id; - - const label = document.createElement('span'); - label.className = 'tab-label'; - label.textContent = tab.label; - tabEl.appendChild(label); - - const closeBtn = document.createElement('button'); - closeBtn.className = 'tab-close'; - closeBtn.textContent = '\u00D7'; - closeBtn.title = 'Close tab'; - closeBtn.addEventListener('click', (e) => { - e.stopPropagation(); - closeTab(tab.id); - }); - tabEl.appendChild(closeBtn); - - // Click to switch - tabEl.addEventListener('click', () => switchTab(tab.id)); - - // Double-click to rename - tabEl.addEventListener('dblclick', (e) => { - e.preventDefault(); - startRenameTab(tab.id); - }); - - tabBar.insertBefore(tabEl, newTabBtn); - }); - - // Update + button state - newTabBtn.disabled = tabs.length >= MAX_TABS; - } - - function updateTabButtons() { - // Update toolbar pane buttons for active tab - const tab = getActiveTab(); - const multi = tab && tab.panes.length > 1; - document.getElementById('close-pane-btn').style.display = multi ? '' : 'none'; - document.getElementById('next-pane-btn').style.display = multi ? '' : 'none'; - document.getElementById('split-btn').style.display = (tab && tab.panes.length >= 2) ? 'none' : ''; - } -``` - -**Step 3: Commit** - -```bash -git add static/index.html -git commit -m "feat: implement createTab, switchTab, closeTab, renameTab" -``` - ---- - -### Task 6: Rewrite splitPane, closeActivePane, cyclePaneFocus for Tab Context - -**Files:** -- Modify: `static/index.html` β€” replace `splitPane`, `closeActivePane`, `cyclePaneFocus` functions - -**Step 1: Replace splitPane** - -```javascript - async function splitPane() { - const tab = getActiveTab(); - if (!tab || tab.panes.length >= 2) return; - status.textContent = 'Splitting...'; - status.style.display = ''; - try { - await createPane(tab); - // Reset flex for even split - tab.panes.forEach(p => { p.element.style.flex = '1'; }); - refitAllPanes(); - updateTabButtons(); - status.style.display = 'none'; - } catch (e) { - status.textContent = 'Split failed: ' + e.message; - status.style.color = '#ff5555'; - } - } -``` - -**Step 2: Replace closeActivePane** - -```javascript - function closeActivePane() { - const tab = getActiveTab(); - if (!tab) return; - - // If only one pane, close the whole tab - if (tab.panes.length <= 1) { - closeTab(tab.id); - return; - } - - const ap = tab.panes.find(p => p.id === tab.activePaneId) || tab.panes[0]; - if (!ap) return; - - cleanupPane(ap); - ap.term.dispose(); - ap.element.remove(); - - // Remove divider - if (tab.divider) { - tab.divider.remove(); - tab.divider = null; - } - - tab.panes = tab.panes.filter(p => p.id !== ap.id); - - // Reset remaining pane to full width - if (tab.panes.length === 1) { - tab.panes[0].element.style.flex = '1'; - } - - focusPane(tab.panes[0].id); - refitAllPanes(); - updateTabButtons(); - } -``` - -**Step 3: Replace cyclePaneFocus** - -```javascript - function cyclePaneFocus(direction) { - const tab = getActiveTab(); - if (!tab || tab.panes.length <= 1) return; - const idx = tab.panes.findIndex(p => p.id === tab.activePaneId); - const next = direction === 'next' - ? (idx + 1) % tab.panes.length - : (idx - 1 + tab.panes.length) % tab.panes.length; - focusPane(tab.panes[next].id); - } -``` - -**Step 4: Add cycleTabFocus** - -```javascript - function cycleTabFocus(direction) { - if (tabs.length <= 1) return; - const idx = tabs.findIndex(t => t.id === activeTabId); - const next = direction === 'next' - ? (idx + 1) % tabs.length - : (idx - 1 + tabs.length) % tabs.length; - switchTab(tabs[next].id); - } - - function jumpToTab(number) { - // number is 1-indexed - if (number >= 1 && number <= tabs.length) { - switchTab(tabs[number - 1].id); - } - } -``` - -**Step 5: Commit** - -```bash -git add static/index.html -git commit -m "refactor: pane operations now scoped to active tab" -``` - ---- - -### Task 7: Update Divider Drag for Per-Tab Dividers - -**Files:** -- Modify: `static/index.html` β€” `setupDividerDrag` function - -**Step 1: Update setupDividerDrag to accept tab parameter** - -Replace the function: -```javascript - function setupDividerDrag(divider, tab) { - let dragging = false; - - divider.addEventListener('mousedown', e => { - e.preventDefault(); - dragging = true; - divider.classList.add('dragging'); - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - }); - - document.addEventListener('mousemove', e => { - if (!dragging || tab.panes.length < 2) return; - const rect = tab.paneContainer.getBoundingClientRect(); - let pct = ((e.clientX - rect.left) / rect.width) * 100; - pct = Math.max(15, Math.min(85, pct)); - tab.panes[0].element.style.flex = `0 0 ${pct}%`; - tab.panes[1].element.style.flex = '1 1 0'; - refitAllPanes(); - }); - - document.addEventListener('mouseup', () => { - if (dragging) { - dragging = false; - divider.classList.remove('dragging'); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - refitAllPanes(); - } - }); - } -``` - -**Step 2: Commit** - -```bash -git add static/index.html -git commit -m "refactor: divider drag scoped to parent tab" -``` - ---- - -### Task 8: Update Keyboard Shortcuts - -**Files:** -- Modify: `static/index.html` β€” the `document.addEventListener('keydown', ...)` block - -**Step 1: Replace the shortcut block** - -Replace the entire keyboard shortcuts section (lines 633-674) with: -```javascript - // ── Global Keyboard Shortcuts ────────────────────────────────── - document.addEventListener('keydown', e => { - // Ctrl+= : increase font - if (e.ctrlKey && !e.altKey && !e.shiftKey && (e.key === '=' || e.key === '+')) { - e.preventDefault(); setFontSize(currentFontSize + 1); return; - } - // Ctrl+- : decrease font - if (e.ctrlKey && !e.altKey && !e.shiftKey && e.key === '-') { - e.preventDefault(); setFontSize(currentFontSize - 1); return; - } - // Ctrl+0 : reset font - if (e.ctrlKey && !e.altKey && !e.shiftKey && e.key === '0') { - e.preventDefault(); setFontSize(DEFAULT_FONT_SIZE); return; - } - // Ctrl+Shift+F : toggle search - if (e.ctrlKey && e.shiftKey && e.key === 'F') { - e.preventDefault(); toggleSearch(); return; - } - // Alt+V (Option+V) : toggle voice dictation - if (e.altKey && !e.ctrlKey && !e.shiftKey && e.code === 'KeyV') { - e.preventDefault(); - if (dictationActive) closeDictation(); - else startDictation(); - return; - } - - // ── Tab shortcuts (Ctrl+Shift) ── - // Ctrl+Shift+T : new tab - if (e.ctrlKey && e.shiftKey && e.key === 'T') { - e.preventDefault(); createTab(); return; - } - // Ctrl+Shift+W : close active pane (closes tab if last pane) - if (e.ctrlKey && e.shiftKey && e.key === 'W') { - e.preventDefault(); closeActivePane(); return; - } - // Ctrl+Shift+] : next tab - if (e.ctrlKey && e.shiftKey && e.code === 'BracketRight') { - e.preventDefault(); cycleTabFocus('next'); return; - } - // Ctrl+Shift+[ : prev tab - if (e.ctrlKey && e.shiftKey && e.code === 'BracketLeft') { - e.preventDefault(); cycleTabFocus('prev'); return; - } - // Ctrl+Shift+1-5 : jump to tab - if (e.ctrlKey && e.shiftKey && e.code >= 'Digit1' && e.code <= 'Digit5') { - e.preventDefault(); jumpToTab(parseInt(e.code.slice(-1))); return; - } - - // ── Pane shortcuts (Alt+Shift) ── - // Alt+Shift+D : split pane - if (e.altKey && e.shiftKey && e.key === 'D') { - e.preventDefault(); splitPane(); return; - } - // Alt+Shift+W : close pane - if (e.altKey && e.shiftKey && e.key === 'W') { - e.preventDefault(); closeActivePane(); return; - } - // Alt+Shift+] : next pane - if (e.altKey && e.shiftKey && e.code === 'BracketRight') { - e.preventDefault(); cyclePaneFocus('next'); return; - } - // Alt+Shift+[ : prev pane - if (e.altKey && e.shiftKey && e.code === 'BracketLeft') { - e.preventDefault(); cyclePaneFocus('prev'); return; - } - }); -``` - -**Step 2: Update toolbar button tooltips** - -Update line 224 to reflect new shortcut: -```html - - - -``` - -**Step 3: Commit** - -```bash -git add static/index.html -git commit -m "feat: add tab keyboard shortcuts, move pane shortcuts to Alt+Shift" -``` - ---- - -### Task 9: Update Toolbar Button Wiring and Cleanup Functions - -**Files:** -- Modify: `static/index.html` β€” toolbar button listeners, cleanupAllPanes, pagehide, updatePaneButtons - -**Step 1: Wire the new-tab button** - -Add after the existing toolbar button listeners: -```javascript - document.getElementById('new-tab-btn').addEventListener('click', () => createTab()); -``` - -**Step 2: Update cleanupAllPanes to iterate all tabs** - -Replace: -```javascript - function cleanupAllPanes() { - panes.forEach(p => cleanupPane(p)); - } -``` -With: -```javascript - function cleanupAllPanes() { - getAllPanes().forEach(p => cleanupPane(p)); - } -``` - -**Step 3: Update pagehide beacon to iterate all tabs** - -Replace the `pagehide` listener: -```javascript - window.addEventListener('pagehide', () => { - getAllPanes().forEach(p => { - if (p.sessionId) { - navigator.sendBeacon( - '/api/heartbeat', - new Blob([JSON.stringify({ session_id: p.sessionId })], { type: 'application/json' }) - ); - } - }); - }); -``` - -**Step 4: Replace the old updatePaneButtons function** - -The `updatePaneButtons` function was already rewritten in Task 5 as `updateTabButtons`. Remove the old one (lines 930-935) if it still exists. - -**Step 5: Commit** - -```bash -git add static/index.html -git commit -m "feat: wire new-tab button, update cleanup for tabs" -``` - ---- - -### Task 10: Update init() to Create First Tab Instead of First Pane - -**Files:** -- Modify: `static/index.html` β€” `init` function - -**Step 1: Replace init** - -```javascript - async function init() { - try { - status.textContent = 'Initializing terminal...'; - - if (typeof Terminal === 'undefined') throw new Error('xterm.js not loaded'); - if (typeof FitAddon === 'undefined') throw new Error('FitAddon not loaded'); - - await createTab(); - - status.textContent = 'Connected!'; - setTimeout(() => { status.style.display = 'none'; }, 1000); - - window.addEventListener('resize', () => refitAllPanes()); - window.addEventListener('beforeunload', () => cleanupAllPanes()); - - } catch (e) { - status.textContent = 'Error: ' + e.message; - status.style.color = '#ff5555'; - console.error(e); - } - } -``` - -**Step 2: Remove the old `
`** - -This was already replaced in Task 1, but verify it's gone. The `createTab` function now creates per-tab pane containers dynamically. - -**Step 3: Verify end-to-end** - -Open the page in a browser. Verify: -- Tab bar appears at top with "Shell 1" tab and "+" button -- Terminal renders and works below the tab bar -- Click "+" creates "Shell 2" with its own terminal session -- Clicking tabs switches between them -- Double-click a tab label to rename it -- Click "x" on a tab to close it -- `Ctrl+Shift+T` creates a new tab -- `Ctrl+Shift+[/]` cycles tabs -- `Alt+Shift+D` splits the active tab's pane -- `Alt+Shift+W` closes a pane (or tab if last pane) -- Closing the last tab auto-creates a new "Shell 1" -- Max 5 tabs, "+" button disables at cap - -**Step 4: Commit** - -```bash -git add static/index.html -git commit -m "feat: init creates first tab, multi-tab terminals complete" -``` - ---- - -### Task 11: Remove Dead Code and Final Cleanup - -**Files:** -- Modify: `static/index.html` - -**Step 1: Remove any remaining references to the old global `panes` variable** - -Search for `panes.forEach`, `panes.find`, `panes.length`, `panes.filter`, `panes.push`, `panes.pop`, `panes[` in the file. All should now reference `tab.panes` or `getAllPanes()`. Remove any dead code. - -**Step 2: Remove the old `#pane-container` and `#pane-divider` CSS rules if still present** - -They've been replaced by `.tab-pane-container` and `.pane-divider`. - -**Step 3: Verify no console errors** - -Open browser dev tools, check console is clean. - -**Step 4: Commit** - -```bash -git add static/index.html -git commit -m "chore: remove dead pane code, cleanup" -``` diff --git a/docs/plans/2026-03-27-pat-auto-rotation-implementation.md b/docs/plans/2026-03-27-pat-auto-rotation-implementation.md deleted file mode 100644 index df55204..0000000 --- a/docs/plans/2026-03-27-pat-auto-rotation-implementation.md +++ /dev/null @@ -1,510 +0,0 @@ -# PAT Auto-Rotation Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Implement automatic PAT rotation with 2-hour short-lived tokens, rotating every 90 minutes, with persistence to app secrets for restart survival. Fixes #81. - -**Architecture:** New `pat_rotator.py` module with a `PATRotator` class that runs a background daemon thread. Uses current PAT to mint new PAT, persists to Secrets API via SP credentials, writes to `~/.databrickscfg`, revokes old PAT. Integrated into `initialize_app()`. - -**Tech Stack:** Python, Flask, databricks-sdk, requests, threading - ---- - -### Task 1: Create PATRotator module with tests - -**Files:** -- Create: `pat_rotator.py` -- Create: `tests/test_pat_rotator.py` - -**Step 1: Write the failing tests** - -```python -# tests/test_pat_rotator.py -"""Tests for PAT auto-rotation β€” short-lived tokens with background refresh.""" - -import os -import time -import threading -from unittest import mock - -import pytest - - -class TestPATRotation: - """Core rotation logic.""" - - def test_rotate_mints_new_token(self): - from pat_rotator import PATRotator - rotator = PATRotator(host="https://test.databricks.com", rotation_interval=5400, token_lifetime=7200) - rotator._current_token = "old-pat" - rotator._current_token_id = "old-id" - - mock_response_create = mock.MagicMock() - mock_response_create.status_code = 200 - mock_response_create.json.return_value = { - "token_value": "new-pat", - "token_info": {"token_id": "new-id", "expiry_time": int(time.time() + 7200) * 1000} - } - mock_response_delete = mock.MagicMock() - mock_response_delete.status_code = 200 - - with mock.patch("pat_rotator.requests.post") as mock_post: - mock_post.side_effect = [mock_response_create, mock_response_delete] - with mock.patch.object(rotator, "_persist_token"): - result = rotator._rotate_once() - - assert result is True - assert rotator._current_token == "new-pat" - assert rotator._current_token_id == "new-id" - - def test_rotate_revokes_old_token(self): - from pat_rotator import PATRotator - rotator = PATRotator(host="https://test.databricks.com") - rotator._current_token = "old-pat" - rotator._current_token_id = "old-id" - - mock_response_create = mock.MagicMock() - mock_response_create.status_code = 200 - mock_response_create.json.return_value = { - "token_value": "new-pat", - "token_info": {"token_id": "new-id", "expiry_time": int(time.time() + 7200) * 1000} - } - mock_response_delete = mock.MagicMock() - mock_response_delete.status_code = 200 - - with mock.patch("pat_rotator.requests.post") as mock_post: - mock_post.side_effect = [mock_response_create, mock_response_delete] - with mock.patch.object(rotator, "_persist_token"): - rotator._rotate_once() - - # Second call should be the delete with the OLD token id - delete_call = mock_post.call_args_list[1] - assert "token/delete" in delete_call[0][0] - assert delete_call[1]["json"]["token_id"] == "old-id" - - def test_rotate_fails_gracefully_on_create_error(self): - from pat_rotator import PATRotator - rotator = PATRotator(host="https://test.databricks.com") - rotator._current_token = "old-pat" - rotator._current_token_id = "old-id" - - mock_response = mock.MagicMock() - mock_response.status_code = 403 - mock_response.text = "Forbidden" - - with mock.patch("pat_rotator.requests.post", return_value=mock_response): - result = rotator._rotate_once() - - assert result is False - assert rotator._current_token == "old-pat" # Unchanged - - def test_rotate_continues_if_revoke_fails(self): - from pat_rotator import PATRotator - rotator = PATRotator(host="https://test.databricks.com") - rotator._current_token = "old-pat" - rotator._current_token_id = "old-id" - - mock_create = mock.MagicMock() - mock_create.status_code = 200 - mock_create.json.return_value = { - "token_value": "new-pat", - "token_info": {"token_id": "new-id", "expiry_time": int(time.time() + 7200) * 1000} - } - mock_delete = mock.MagicMock() - mock_delete.status_code = 500 - - with mock.patch("pat_rotator.requests.post") as mock_post: - mock_post.side_effect = [mock_create, mock_delete] - with mock.patch.object(rotator, "_persist_token"): - result = rotator._rotate_once() - - # New token should still be active even if old revocation failed - assert result is True - assert rotator._current_token == "new-pat" - - -class TestTokenPersistence: - """Writing token to ~/.databrickscfg.""" - - def test_writes_databrickscfg(self, tmp_path): - from pat_rotator import PATRotator - rotator = PATRotator(host="https://test.databricks.com") - rotator._databrickscfg_path = str(tmp_path / ".databrickscfg") - rotator._write_databrickscfg("test-token") - - content = (tmp_path / ".databrickscfg").read_text() - assert "test-token" in content - assert "https://test.databricks.com" in content - - def test_databrickscfg_permissions(self, tmp_path): - import stat - from pat_rotator import PATRotator - rotator = PATRotator(host="https://test.databricks.com") - rotator._databrickscfg_path = str(tmp_path / ".databrickscfg") - rotator._write_databrickscfg("test-token") - - mode = os.stat(str(tmp_path / ".databrickscfg")).st_mode - assert stat.S_IMODE(mode) == 0o600 - - def test_updates_env_var(self): - from pat_rotator import PATRotator - rotator = PATRotator(host="https://test.databricks.com") - with mock.patch.object(rotator, "_write_databrickscfg"): - with mock.patch.object(rotator, "_persist_to_secret"): - rotator._persist_token("new-token-value") - assert os.environ.get("DATABRICKS_TOKEN") == "new-token-value" - - -class TestSecretPersistence: - """Persisting rotated token to app secret via SP.""" - - def test_persist_to_secret_calls_sdk(self): - from pat_rotator import PATRotator - rotator = PATRotator(host="https://test.databricks.com", - secret_scope="my-scope", secret_key="DATABRICKS_TOKEN") - - with mock.patch("pat_rotator.WorkspaceClient") as mock_ws: - rotator._persist_to_secret("new-token") - mock_ws.return_value.secrets.put_secret.assert_called_once_with( - scope="my-scope", key="DATABRICKS_TOKEN", string_value="new-token" - ) - - def test_persist_skipped_when_no_scope_configured(self): - from pat_rotator import PATRotator - rotator = PATRotator(host="https://test.databricks.com", - secret_scope=None, secret_key=None) - - with mock.patch("pat_rotator.WorkspaceClient") as mock_ws: - rotator._persist_to_secret("new-token") - mock_ws.return_value.secrets.put_secret.assert_not_called() - - -class TestRotatorLifecycle: - """Start/stop the background thread.""" - - def test_start_creates_daemon_thread(self): - from pat_rotator import PATRotator - rotator = PATRotator(host="https://test.databricks.com", rotation_interval=9999) - rotator._current_token = "test-pat" - with mock.patch.object(rotator, "_rotation_loop"): - rotator.start() - assert rotator._thread is not None - assert rotator._thread.daemon is True - rotator.stop() - - def test_no_start_without_token(self): - from pat_rotator import PATRotator - rotator = PATRotator(host="https://test.databricks.com") - rotator._current_token = None - rotator.start() - assert rotator._thread is None -``` - -**Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_pat_rotator.py -v` -Expected: FAIL β€” `ModuleNotFoundError: No module named 'pat_rotator'` - -**Step 3: Write implementation** - -```python -# pat_rotator.py -"""Auto-rotate short-lived PATs in the background. - -Mints a new 2-hour PAT every 90 minutes, persists to app secret -(survives restart), writes to ~/.databrickscfg (immediate CLI/SDK use), -and revokes the old PAT. Fixes #81. -""" - -import os -import time -import threading -import logging - -import requests -from databricks.sdk import WorkspaceClient - -from utils import ensure_https - -logger = logging.getLogger(__name__) - -# Defaults -DEFAULT_TOKEN_LIFETIME = 7200 # 2 hours -DEFAULT_ROTATION_INTERVAL = 5400 # 90 minutes - - -class PATRotator: - """Background PAT rotation with secret persistence.""" - - def __init__(self, host=None, rotation_interval=DEFAULT_ROTATION_INTERVAL, - token_lifetime=DEFAULT_TOKEN_LIFETIME, - secret_scope=None, secret_key=None): - self._host = ensure_https(host or os.environ.get("DATABRICKS_HOST", "")) - self._rotation_interval = rotation_interval - self._token_lifetime = token_lifetime - self._secret_scope = secret_scope - self._secret_key = secret_key - self._current_token = os.environ.get("DATABRICKS_TOKEN", "").strip() or None - self._current_token_id = None - self._lock = threading.Lock() - self._thread = None - self._stop_event = threading.Event() - self._databrickscfg_path = os.path.join( - os.environ.get("HOME", "/app/python/source_code"), - ".databrickscfg" - ) - - @property - def token(self): - with self._lock: - return self._current_token - - def start(self): - """Start the background rotation thread.""" - if not self._current_token: - logger.warning("No PAT configured β€” rotation thread not started") - return - if self._thread and self._thread.is_alive(): - return - self._stop_event.clear() - self._thread = threading.Thread(target=self._rotation_loop, daemon=True, - name="pat-rotation") - self._thread.start() - logger.info(f"PAT rotation started (interval={self._rotation_interval}s, " - f"lifetime={self._token_lifetime}s)") - - def stop(self): - """Signal the rotation thread to stop.""" - self._stop_event.set() - - def _rotation_loop(self): - """Background loop: sleep, rotate, repeat.""" - while not self._stop_event.is_set(): - self._stop_event.wait(timeout=self._rotation_interval) - if self._stop_event.is_set(): - break - try: - self._rotate_once() - except Exception as e: - logger.error(f"PAT rotation failed unexpectedly: {e}") - - def _rotate_once(self): - """Mint new PAT, persist, revoke old. Returns True on success.""" - if not self._current_token: - return False - - # 1. Mint new token - try: - resp = requests.post( - f"{self._host}/api/2.0/token/create", - headers={"Authorization": f"Bearer {self._current_token}"}, - json={ - "lifetime_seconds": self._token_lifetime, - "comment": "coda-auto-rotated" - }, - timeout=30 - ) - except requests.RequestException as e: - logger.error(f"PAT rotation: create request failed: {e}") - return False - - if resp.status_code != 200: - logger.error(f"PAT rotation: create failed ({resp.status_code}): {resp.text}") - return False - - data = resp.json() - new_token = data["token_value"] - new_token_id = data["token_info"]["token_id"] - - old_token_id = self._current_token_id - - # 2. Persist new token (secret + file + env) - with self._lock: - self._current_token = new_token - self._current_token_id = new_token_id - self._persist_token(new_token) - logger.info(f"PAT rotated successfully (new_id={new_token_id})") - - # 3. Revoke old token (best-effort β€” old token expires in 2h anyway) - if old_token_id: - try: - resp = requests.post( - f"{self._host}/api/2.0/token/delete", - headers={"Authorization": f"Bearer {new_token}"}, - json={"token_id": old_token_id}, - timeout=30 - ) - if resp.status_code == 200: - logger.info(f"Old PAT revoked (id={old_token_id})") - else: - logger.warning(f"Old PAT revocation failed ({resp.status_code})") - except requests.RequestException as e: - logger.warning(f"Old PAT revocation request failed: {e}") - - return True - - def _persist_token(self, token): - """Write rotated token to all persistence layers.""" - os.environ["DATABRICKS_TOKEN"] = token - self._write_databrickscfg(token) - self._persist_to_secret(token) - - def _write_databrickscfg(self, token): - """Write token to ~/.databrickscfg for CLI/SDK tools.""" - content = ( - "[DEFAULT]\n" - f"host = {self._host}\n" - f"token = {token}\n" - ) - try: - with open(self._databrickscfg_path, "w") as f: - f.write(content) - os.chmod(self._databrickscfg_path, 0o600) - except OSError as e: - logger.warning(f"Could not write .databrickscfg: {e}") - - def _persist_to_secret(self, token): - """Persist token to Databricks app secret (survives restart).""" - if not self._secret_scope or not self._secret_key: - return - try: - w = WorkspaceClient() - w.secrets.put_secret(scope=self._secret_scope, key=self._secret_key, - string_value=token) - logger.info("Rotated PAT persisted to app secret") - except Exception as e: - logger.warning(f"Could not persist PAT to secret: {e}") -``` - -**Step 4: Run tests** - -Run: `uv run pytest tests/test_pat_rotator.py -v` -Expected: All PASS - -**Step 5: Commit** - -```bash -git add pat_rotator.py tests/test_pat_rotator.py -git -c user.email=datasciencemonkey@gmail.com -c user.name="Sathish Gangichetty" commit -m "feat: add PATRotator for short-lived token auto-rotation (#81)" -``` - ---- - -### Task 2: Integrate PATRotator into app.py - -**Files:** -- Modify: `app.py` (initialize_app, ~line 917) - -**Step 1: Write failing test** - -```python -# tests/test_pat_rotation_integration.py -"""Integration test: PATRotator wired into app.""" - -from unittest import mock - -def test_app_has_pat_rotator(): - with mock.patch("app.initialize_app"): - import app as app_module - assert hasattr(app_module, "pat_rotator") -``` - -**Step 2: Run test β€” should fail** - -Run: `uv run pytest tests/test_pat_rotation_integration.py -v` - -**Step 3: Modify app.py** - -Add import near top (after existing imports): -```python -from pat_rotator import PATRotator -``` - -Add module-level instance: -```python -# PAT auto-rotation (short-lived tokens, background refresh) -pat_rotator = PATRotator( - secret_scope=os.environ.get("PAT_SECRET_SCOPE"), - secret_key=os.environ.get("PAT_SECRET_KEY", "DATABRICKS_TOKEN"), -) -``` - -In `initialize_app()`, after the setup thread start, add: -```python - # Start PAT auto-rotation if a PAT is configured - pat_rotator.start() -``` - -**Step 4: Run all tests** - -Run: `uv run pytest tests/ -v` - -**Step 5: Commit** - -```bash -git add app.py tests/test_pat_rotation_integration.py -git -c user.email=datasciencemonkey@gmail.com -c user.name="Sathish Gangichetty" commit -m "feat: wire PATRotator into app startup (#81)" -``` - ---- - -### Task 3: Update app.yaml with secret resource and rotation env vars - -**Files:** -- Modify: `app.yaml` - -**Step 1: Update app.yaml** - -```yaml -command: - - gunicorn - - app:app -env: - - name: HOME - value: /app/python/source_code - - name: DATABRICKS_TOKEN - valueFrom: DATABRICKS_TOKEN - - name: PAT_SECRET_SCOPE - value: coda-app - - name: PAT_SECRET_KEY - value: DATABRICKS_TOKEN - - name: ANTHROPIC_MODEL - value: databricks-claude-opus-4-6 - - name: GEMINI_MODEL - value: databricks-gemini-3-1-pro - - name: CODEX_MODEL - value: databricks-gpt-5-2 - - name: DATABRICKS_GATEWAY_HOST - valueFrom: DATABRICKS_GATEWAY_HOST - - name: CLAUDE_CODE_DISABLE_AUTO_MEMORY - value: 0 -resources: - - name: pat-token - secret: - scope: coda-app - key: DATABRICKS_TOKEN - permission: WRITE -``` - -**Step 2: Commit** - -```bash -git add app.yaml -git -c user.email=datasciencemonkey@gmail.com -c user.name="Sathish Gangichetty" commit -m "chore: add secret resource with WRITE for PAT rotation (#81)" -``` - ---- - -### Task 4: Run full test suite and commit plan - -**Step 1: Run tests** - -Run: `uv run pytest tests/ -v` -Expected: All PASS - -**Step 2: Commit plan doc** - -```bash -git add docs/plans/2026-03-27-pat-auto-rotation-implementation.md -git -c user.email=datasciencemonkey@gmail.com -c user.name="Sathish Gangichetty" commit -m "docs: PAT auto-rotation implementation plan (#81)" -``` diff --git a/docs/plans/2026-03-28-session-detach-reconnect.md b/docs/plans/2026-03-28-session-detach-reconnect.md deleted file mode 100644 index da22bda..0000000 --- a/docs/plans/2026-03-28-session-detach-reconnect.md +++ /dev/null @@ -1,119 +0,0 @@ -# Session Detach & Reconnect - -**Date:** 2026-03-28 -**Context:** Coding agent sessions (claude, opencode, gemini) should survive tab closure. Only `exit` in the shell kills a session. - ---- - -## Problem - -Closing a browser tab kills the PTY process immediately via `sendBeacon('/api/session/close')`. For a coding agent mid-task, this destroys work in progress. The user didn't intend to kill the session β€” they just closed a tab. - -## Design - -### Principle: Detach, Don't Kill - -- **Tab/pane close = detach.** Frontend disconnects, PTY keeps running. -- **`exit` in shell = the only kill.** PTY EOF detection triggers cleanup. -- **24-hour reaper = safety net.** Orphaned sessions die after 24h with no heartbeat. - -### Changes - -#### 1. Frontend β€” `cleanupPane()` stops killing - -Remove `sendBeacon('/api/session/close')` from `cleanupPane()`. Keep poll stop, WS room leave, and xterm disposal. The `beforeunload` handler still calls `cleanupAllPanes()` but it no longer kills anything. `pagehide` already just sends a heartbeat. - -#### 2. Backend β€” `GET /api/sessions` - -Returns active sessions with process detection: - -```json -[ - { - "session_id": "abc-123", - "created_at": 1743120382.5, - "last_poll_time": 1743120982.5, - "exited": false, - "process": "claude", - "idle_seconds": 342 - } -] -``` - -Process detection: `ps --ppid {pid} -o comm=` to find the child process of the shell. Falls back to "bash" if no child. - -Added to auth skip list alongside `/api/pat-status`. - -#### 3. Backend β€” `POST /api/session/attach` - -Reattach to an existing session: - -- Input: `{ session_id }` -- Validates session exists and not exited -- Resets `last_poll_time` (restarts 24h idle clock) -- Returns output buffer (last ~1000 lines) for replay -- Returns metadata (process name, created_at) - -```json -{ - "session_id": "abc-123", - "output": ["line1\r\n", "line2\r\n"], - "process": "claude", - "created_at": 1743120382.5 -} -``` - -#### 4. Frontend β€” Session picker on return visit - -The picker only appears when PAT is already valid (return visit). First-time PAT flow always creates a new session. - -``` -createPane() - β†’ /api/pat-status - β†’ invalid β†’ PAT prompt β†’ setup β†’ create new session - β†’ valid β†’ GET /api/sessions - β†’ 0 sessions β†’ create new - β†’ 1 session β†’ auto-reattach (replay buffer) - β†’ N sessions β†’ show picker -``` - -**Picker UI** (rendered in xterm with mouse support): - -``` - Existing sessions: - - claude (running, 2h ago) [Attach] [βœ•] - opencode (running, 45m ago) [Attach] [βœ•] - bash (idle, 3h ago) [Attach] [βœ•] - - [+ New session] -``` - -- Click **Attach** or session row β†’ `POST /api/session/attach`, replay buffer, join WS room, start polling -- Click **βœ•** β†’ `POST /api/session/close` for that session, re-render picker -- Click **+ New session** β†’ `POST /api/session` as today -- One session β†’ skip picker, auto-reattach - -#### 5. Exited session cleanup - -When `read_pty_output()` detects EOF (user typed `exit`), call `terminate_session()` immediately to remove from dict. No zombie sessions in the picker. - -Session picker also filters out `exited: true` (defensive, race condition guard). - ---- - -## Files to Modify - -| File | Change | -|------|--------| -| `app.py` | Add `GET /api/sessions`, `POST /api/session/attach`. Update auth skip list. Update `read_pty_output()` to call `terminate_session()` on EOF. Add `_get_session_process(pid)` helper. | -| `static/index.html` | Remove `sendBeacon('/api/session/close')` from `cleanupPane()`. Add session picker flow in `createPane()`. Add mouse click handling for picker UI. | - -## What Doesn't Change - -- `POST /api/session/close` endpoint stays β€” used by EOF cleanup path -- `terminate_session()` stays β€” core kill logic unchanged -- 24-hour timeout stays β€” safety net for orphans -- `pagehide` heartbeat stays β€” already correct -- WebSocket disconnect behavior stays β€” already doesn't kill PTY -- PAT rotation, session awareness β€” unchanged (sessions still count) diff --git a/docs/plans/PLAN-issue-8.md b/docs/plans/PLAN-issue-8.md deleted file mode 100644 index ad4af7a..0000000 --- a/docs/plans/PLAN-issue-8.md +++ /dev/null @@ -1,119 +0,0 @@ -# Issue #8: Frontend Keep-Alive, Reconnection & Web Worker Polling - -## Context - -The frontend polling is fragile. A single `setInterval` at 100ms calls `/api/output` β€” any non-200 response immediately kills the session with no retry. Browsers throttle background tab timers, so switching tabs easily causes polls to stall past the 300s timeout. The current workaround (bumping timeout from 60s to 300s) masks the problem but doesn't fix it. - -**Branch:** Create `feat/frontend-keepalive` off `main` - -## Architecture - -``` -Main Thread (index.html) Web Worker (poll-worker.js) Backend (app.py) -───────────────────────── ────────────────────────── ──────────────── -- xterm.js / DOM - Output polling (100ms fg) - /api/output (existing) -- visibilitychange handler ←──→ - Heartbeat polling (30s bg) ──→ - /api/heartbeat (NEW) -- pagehide sendBeacon - Retry/backoff state - /api/session/close -- Input/resize sending - Per-pane state map -``` - -Web Workers are NOT throttled by browsers in background tabs β€” this is the key benefit. - -## Changes - -### 1. Backend: Add `/api/heartbeat` endpoint - -**File:** `app.py` (insert after `/api/output` at line 530) - -```python -@app.route("/api/heartbeat", methods=["POST"]) -def heartbeat(): - """Lightweight keep-alive β€” resets timeout without draining output buffer.""" - data = request.json - session_id = data.get("session_id") - with sessions_lock: - if session_id not in sessions: - return jsonify({"error": "Session not found"}), 404 - session = sessions[session_id] - session["last_poll_time"] = time.time() - timeout_warning = session.pop("timeout_warning", False) - return jsonify({"status": "ok", "timeout_warning": timeout_warning}) -``` - -Critical: does NOT touch `output_buffer` β€” output is only drained by `/api/output`. - -### 2. New file: `static/poll-worker.js` - -Web Worker handling all HTTP polling and retry logic (~120 lines). - -**Per-pane state:** -```javascript -const panes = new Map(); -// Each: { sessionId, pollTimerId, heartbeatTimerId, retryCount, mode: 'foreground'|'background' } -``` - -**Message protocol (main β†’ worker):** -- `{ type: 'start_poll', paneId, sessionId }` β€” begin polling for a pane -- `{ type: 'stop_poll', paneId }` β€” stop polling on close -- `{ type: 'visibility_change', hidden: bool }` β€” switch fg/bg mode - -**Message protocol (worker β†’ main):** -- `{ type: 'output', paneId, data }` β€” terminal output + flags -- `{ type: 'session_ended', paneId, reason }` β€” 'exited' | 'auth_expired' | 'shutting_down' -- `{ type: 'connection_status', paneId, status, attempt, maxAttempts }` β€” reconnecting/connected -- `{ type: 'session_dead', paneId }` β€” retries exhausted - -**Retry strategy:** Capped exponential backoff with jitter -- Base: 500ms, multiplier: 2x, max delay: 10s, max attempts: 5 -- Schedule: ~500ms β†’ ~1s β†’ ~2s β†’ ~4s β†’ ~8s (~15.5s total) -- 403 (auth) and `exited` flag: no retry (permanent) -- 404, 5xx, network error: full retry with backoff - -**Visibility modes:** -- Foreground: output poll every 100ms, no heartbeat -- Background: no output poll, heartbeat every 30s - -### 3. Modify `static/index.html` - -**Remove:** -- `pollOutput(pane)` function (lines 704-738) -- `setInterval(() => pollOutput(pane), 100)` (line 809) - -**Add:** -- Worker init: `const pollWorker = new Worker('/static/poll-worker.js');` -- `handleWorkerMessage(event)` β€” routes worker messages to xterm writes per pane -- `visibilitychange` listener β†’ sends `visibility_change` to worker -- `pagehide` listener β†’ `navigator.sendBeacon('/api/heartbeat', ...)` for all active panes - -**Modify:** -- `createPane()`: replace `setInterval` with `pollWorker.postMessage({ type: 'start_poll', ... })` -- `cleanupPane(pane)`: replace `clearInterval` with `pollWorker.postMessage({ type: 'stop_poll', ... })` -- Remove `pollInterval` from pane object (no longer needed) - -### 4. New test: `tests/test_heartbeat.py` - -- Heartbeat with valid session returns 200, resets `last_poll_time` -- Heartbeat with unknown session returns 404 -- Heartbeat does NOT drain output buffer (critical invariant) -- Heartbeat returns and clears `timeout_warning` flag - -## Edge Cases Handled - -| Scenario | Behavior | -|----------|----------| -| Background tab | Worker switches to 30s heartbeat; resumes 100ms polling on return | -| Laptop sleep (>5min) | Session expires server-side; on wake, retry exhaustion β†’ "Connection lost" | -| Backend restart/deploy | `shutting_down` flag warns client; retries handle brief downtime | -| Auth expired (403) | No retry, immediate "refresh page" message | -| Network blip | Backoff retries recover transparently | -| Multiple panes | Independent per-pane state in Worker | -| `pagehide` (tab close) | sendBeacon fires heartbeat as safety net before Worker dies | - -## Verification - -1. `uv run --with pytest pytest tests/test_heartbeat.py -v` β€” heartbeat tests pass -2. `uv run --with pytest pytest tests/ -v` β€” all existing tests still pass -3. Manual: open terminal, verify output works at 100ms (Network tab) -4. Manual: background tab 30s β†’ return β†’ session alive, buffered output appears -5. Manual: background tab >5min β†’ return β†’ clean "session expired" message -6. Manual: check Network tab shows `/api/heartbeat` every ~30s when backgrounded diff --git a/pyproject.toml b/pyproject.toml index f9cc5c9..c89bd94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "urllib3>=2.7.0", # GHSA-65pc-fj4g-8rjx β€” idna < 3.15 lets crafted inputs bypass the # CVE-2024-3651 fix in idna.encode(). idna is transitive; pin a floor. - "idna>=3.15", + "idna>=3.17", # Upper bound is forced by our transitive ecosystem: both mlflow-skinny 3.11.x # AND opentelemetry-api 1.41.x cap importlib-metadata<8.8. Dependabot tried # to bump it to 9.0.0 (PR #3) and broke every deploy β€” explicit ceiling so @@ -59,7 +59,7 @@ dev = [ # against the deployed app to verify SSO + setup pipeline + security # fixes end-to-end. Optional: tests skip cleanly when not installed. "playwright>=1.40", - "pytest-playwright>=0.5", + "pytest-playwright>=0.8.0", ] [tool.uv] diff --git a/requirements.txt b/requirements.txt index 75e76d1..167319e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,7 +34,7 @@ charset-normalizer==3.4.7 # via requests claude-agent-sdk==0.1.65 # via coda (pyproject.toml) -click==8.3.3 +click==8.4.1 # via # flask # flask-socketio @@ -78,7 +78,7 @@ httpx==0.28.1 # via mcp httpx-sse==0.4.3 # via mcp -idna==3.16 +idna==3.17 # via # coda (pyproject.toml) # anyio @@ -139,7 +139,7 @@ pydantic==2.13.4 # mcp # mlflow-skinny # pydantic-settings -pydantic-core==2.46.4 +pydantic-core==2.47.0 # via pydantic pydantic-settings==2.14.1 # via mcp diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 908cab6..41d21dd 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -98,8 +98,17 @@ def pytest_collection_modifyitems(config, items): ) if skips: skip_marker = pytest.mark.skip(reason=" | ".join(skips)) + e2e_dir = Path(__file__).parent for item in items: - item.add_marker(skip_marker) + # Only skip items that live in *this* directory. `items` is the + # whole session's collection, so an unguarded loop here skips the + # entire unit-test suite whenever e2e prerequisites are missing. + try: + item_path = Path(str(item.fspath)).resolve() + except Exception: + continue + if item_path.is_relative_to(e2e_dir): + item.add_marker(skip_marker) @pytest.fixture(scope="module") diff --git a/tests/test_app_yaml_overlays.py b/tests/test_app_yaml_overlays.py new file mode 100644 index 0000000..0210c5d --- /dev/null +++ b/tests/test_app_yaml_overlays.py @@ -0,0 +1,106 @@ +"""Guard the Apps-overlay foot-gun. + +`databricks apps deploy` with an overlay (e.g. `make deploy-workshop` swapping +in `app.yaml.workshop`) **replaces** `app.yaml` wholesale β€” it does not merge. +So any env var that exists in the base `app.yaml` but is missing from an overlay +silently disappears from the deployed container and falls back to whatever the +consuming code defaults to. + +For the per-CLI install toggles that default is "install it" (each setup script +reads `os.environ.get("ENABLE_", "true")`), so an omitted toggle is not a +no-op: it turns an intentionally-disabled agent back on. These tests assert every +tracked overlay declares the full set. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +yaml = pytest.importorskip("yaml") + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Every ENABLE_* toggle honoured by a setup script. Keep in sync with the +# `os.environ.get("ENABLE_...")` reads in setup_*.py β€” test_toggles_match_code +# below fails if a new one is added to the code but not listed here. +CLI_TOGGLES = frozenset( + { + "ENABLE_HERMES", + "ENABLE_PI", + "ENABLE_OPENCODE", + "ENABLE_CODEX", + "ENABLE_GEMINI", + } +) + + +def _tracked_app_yamls() -> list[Path]: + """All git-tracked app.yaml files. Untracked local variants are ignored.""" + out = subprocess.run( + ["git", "ls-files", "app.yaml", "app.yaml.*"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + ).stdout.split() + return [REPO_ROOT / name for name in out] + + +def _env_names(path: Path) -> set[str]: + parsed = yaml.safe_load(path.read_text()) + return {entry["name"] for entry in parsed.get("env", [])} + + +def test_finds_the_overlays(): + """Sanity check β€” a silent empty list would make the tests below vacuous.""" + names = {p.name for p in _tracked_app_yamls()} + assert "app.yaml" in names + assert len(names) >= 3, f"expected several overlays, found {sorted(names)}" + + +@pytest.mark.parametrize( + "path", _tracked_app_yamls(), ids=lambda p: p.name +) +def test_overlay_declares_every_cli_toggle(path: Path): + missing = CLI_TOGGLES - _env_names(path) + assert not missing, ( + f"{path.name} omits {sorted(missing)}. Overlays replace app.yaml rather " + f"than merging with it, and each ENABLE_* defaults to true when absent β€” " + f"so an omitted toggle silently re-enables that CLI's install." + ) + + +@pytest.mark.parametrize( + "path", _tracked_app_yamls(), ids=lambda p: p.name +) +def test_toggle_values_are_quoted_booleans(path: Path): + """`value: true` (unquoted) parses as a YAML bool, not the string the setup + scripts call .strip().lower() on. Keep them quoted.""" + parsed = yaml.safe_load(path.read_text()) + for entry in parsed.get("env", []): + if entry["name"] in CLI_TOGGLES: + assert entry["value"] in ("true", "false"), ( + f"{path.name}: {entry['name']} is {entry['value']!r}; expected the " + f'quoted string "true" or "false"' + ) + + +def test_toggles_match_code(): + """Every ENABLE_* the setup scripts read must be listed in CLI_TOGGLES.""" + grep = subprocess.run( + ["git", "grep", "-ho", r"ENABLE_[A-Z]*", "--", "setup_*.py"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + found = set(grep.stdout.split()) + # ENABLE_SP_APIKEYHELPER is an auth switch, not a per-CLI install toggle. + found.discard("ENABLE_SP_APIKEYHELPER") + unlisted = found - CLI_TOGGLES + assert not unlisted, ( + f"setup scripts read {sorted(unlisted)} but the overlay guard doesn't " + f"know about it β€” add it to CLI_TOGGLES and to every app.yaml*." + ) diff --git a/tests/test_auth_enforcement.py b/tests/test_auth_enforcement.py index f4889b6..14831a3 100644 --- a/tests/test_auth_enforcement.py +++ b/tests/test_auth_enforcement.py @@ -328,3 +328,154 @@ def test_get_request_user_lowercases(self): assert result == "user@example.com", ( f"get_request_user() should lowercase, got '{result}'" ) + + +# --------------------------------------------------------------------------- +# 3. Info-disclosure endpoints β€” auth-gated or trimmed +# --------------------------------------------------------------------------- + +class TestInfoDisclosureEndpoints: + """These endpoints were previously reachable without auth and leaked + info about the app's state. They are now either owner-gated (setup-status, + pat-status, app-state) or minimally informative (health). + """ + + def _post_or_get(self, app_module, method, path, headers): + client = _make_client(app_module) + with mock.patch.object(app_module, "_is_databricks_apps", return_value=True): + if method == "GET": + return client.get(path, headers=headers) + return client.post(path, headers=headers) + + # -- /api/setup-status now requires auth -- + + def test_setup_status_denied_for_non_owner(self): + app_module = _get_app_module() + original = app_module.app_owner + try: + app_module.app_owner = "owner@databricks.com" + resp = self._post_or_get(app_module, "GET", "/api/setup-status", + {"X-Forwarded-Email": "intruder@evil.com"}) + assert resp.status_code == 403, ( + f"GET /api/setup-status should 403 for non-owner, got {resp.status_code}" + ) + finally: + app_module.app_owner = original + + def test_setup_status_allowed_for_owner(self): + app_module = _get_app_module() + original = app_module.app_owner + try: + app_module.app_owner = "owner@databricks.com" + resp = self._post_or_get(app_module, "GET", "/api/setup-status", + {"X-Forwarded-Email": "owner@databricks.com"}) + assert resp.status_code == 200, ( + f"Owner should see setup-status, got {resp.status_code}" + ) + finally: + app_module.app_owner = original + + # -- /api/pat-status now requires auth -- + + def test_pat_status_denied_for_non_owner(self): + app_module = _get_app_module() + original = app_module.app_owner + try: + app_module.app_owner = "owner@databricks.com" + resp = self._post_or_get(app_module, "GET", "/api/pat-status", + {"X-Forwarded-Email": "intruder@evil.com"}) + assert resp.status_code == 403, ( + f"GET /api/pat-status should 403 for non-owner, got {resp.status_code}" + ) + finally: + app_module.app_owner = original + + # -- /api/app-state now requires auth -- + + def test_app_state_denied_for_non_owner(self): + app_module = _get_app_module() + original = app_module.app_owner + try: + app_module.app_owner = "owner@databricks.com" + resp = self._post_or_get(app_module, "GET", "/api/app-state", + {"X-Forwarded-Email": "intruder@evil.com"}) + assert resp.status_code == 403, ( + f"GET /api/app-state should 403 for non-owner, got {resp.status_code}" + ) + finally: + app_module.app_owner = original + + # -- /health stays reachable unauth, but only the owner sees detail -- + + def test_health_minimal_response_for_unauthenticated_caller(self): + """/health stays exempt from the SSO gate so the platform can probe it, + but an unauthenticated caller must NOT see version, session counts, + setup state or rotator internals β€” those enable version-targeted + exploit selection and leak the app's auth posture.""" + app_module = _get_app_module() + client = _make_client(app_module) + original = app_module.app_owner + try: + app_module.app_owner = "owner@databricks.com" + # On Apps with no X-Forwarded-Email, check_authorization() fails closed. + with mock.patch.object(app_module, "_is_databricks_apps", return_value=True): + resp = client.get("/health") + finally: + app_module.app_owner = original + + # Still 200 β€” the gate must not turn a liveness probe into a 403. + assert resp.status_code == 200 + body = resp.get_json() + assert set(body) == {"status"}, ( + f"unauth /health should return only status, got keys: {sorted(body)}" + ) + assert body["status"] in ("healthy", "degraded") + # Explicit anti-leak assertions + assert "version" not in body, "/health must not expose version" + assert "setup_status" not in body, "/health must not expose setup_status" + assert "active_sessions" not in body, "/health must not expose session count" + assert "auth" not in body, "/health must not expose rotator internals" + + def test_health_full_payload_for_owner(self): + """The owner keeps the diagnostic payload β€” that's what makes a zombie + app (worker answering while PAT rotation is dead) observable.""" + app_module = _get_app_module() + client = _make_client(app_module) + original = app_module.app_owner + try: + app_module.app_owner = "owner@databricks.com" + with mock.patch.object(app_module, "_is_databricks_apps", return_value=True): + resp = client.get( + "/health", headers={"X-Forwarded-Email": "owner@databricks.com"} + ) + finally: + app_module.app_owner = original + + assert resp.status_code == 200 + body = resp.get_json() + assert body["status"] in ("healthy", "degraded") + for key in ("version", "setup_status", "active_sessions", "auth"): + assert key in body, f"owner /health should include {key}, got {sorted(body)}" + + def test_health_reports_degraded_to_unauthenticated_caller(self): + """`status` is the one field everyone sees β€” a liveness probe that can't + report unhealthiness is useless. Verify a dead rotator still surfaces.""" + app_module = _get_app_module() + client = _make_client(app_module) + original = app_module.app_owner + rotator = app_module.pat_rotator + try: + app_module.app_owner = "owner@databricks.com" + with mock.patch.object(type(rotator), "token", "dapi-fake"), \ + mock.patch.object(type(rotator), "is_alive", False), \ + mock.patch.object(type(rotator), "is_token_expired", True), \ + mock.patch.object(type(rotator), "seconds_since_rotation", 999), \ + mock.patch.object(app_module, "_is_databricks_apps", return_value=True): + resp = client.get("/health") + finally: + app_module.app_owner = original + + body = resp.get_json() + assert body == {"status": "degraded"}, ( + f"expected a bare degraded signal, got {body}" + ) diff --git a/tests/test_cli_token_rotation.py b/tests/test_cli_token_rotation.py index 74d23bc..dbb4d41 100644 --- a/tests/test_cli_token_rotation.py +++ b/tests/test_cli_token_rotation.py @@ -289,3 +289,67 @@ def test_all_five_updated_in_one_call(self, isolated_home): assert "GEMINI_API_KEY=rotated-token" in (gemini_dir / ".env").read_text() hermes_content = (hermes_dir / "config.yaml").read_text() assert hermes_content.count("api_key: rotated-token") == 2 + + +class TestAtomicWrites: + """The rotator rewrites live agent configs every 10 minutes while agents + may be reading them, so every write goes through `_atomic_write_text`.""" + + def test_no_partial_file_and_no_tmp_left_behind(self, isolated_home): + from cli_auth import _atomic_write_text + + path = isolated_home / "config.yaml" + path.write_text("api_key: old\n") + + _atomic_write_text(str(path), "api_key: new\n") + + assert path.read_text() == "api_key: new\n" + assert not (isolated_home / "config.yaml.tmp").exists() + + def test_preserves_restrictive_mode(self, isolated_home): + """os.replace() installs the tmp file's inode β€” and therefore the tmp + file's permissions. Without an explicit chmod, rotating the Hermes + token would widen ~/.hermes/config.yaml from 0600 back to the umask + default, silently undoing setup_hermes.py's hardening.""" + import stat + + path = isolated_home / "config.yaml" + path.write_text("api_key: old\n") + path.chmod(0o600) + + from cli_auth import _atomic_write_text + + _atomic_write_text(str(path), "api_key: new\n") + + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + def test_hermes_rotation_keeps_config_private(self, isolated_home): + """End-to-end via the public entry point: a 0600 Hermes config stays + 0600 across a token rotation.""" + import stat + from cli_auth import update_cli_tokens + + hermes_dir = isolated_home / ".hermes" + hermes_dir.mkdir() + cfg = hermes_dir / "config.yaml" + cfg.write_text("model:\n api_key: old\n") + cfg.chmod(0o600) + + update_cli_tokens("rotated-token") + + assert "api_key: rotated-token" in cfg.read_text() + assert stat.S_IMODE(cfg.stat().st_mode) == 0o600 + + +class TestMissingConfigsAreQuiet: + def test_no_warnings_when_nothing_is_installed(self, isolated_home, caplog): + """A rotation on a box where an agent never ran must not log warnings β€” + the existence guards return early instead of raising OSError.""" + import logging + + from cli_auth import update_cli_tokens + + with caplog.at_level(logging.WARNING, logger="cli_auth"): + update_cli_tokens("some-token") + + assert [r.message for r in caplog.records if r.levelno >= logging.WARNING] == [] diff --git a/tests/test_session_linger.py b/tests/test_session_linger.py index 0b3f2aa..b77274f 100644 --- a/tests/test_session_linger.py +++ b/tests/test_session_linger.py @@ -175,15 +175,16 @@ def test_warning_at_20_hours(self): # --------------------------------------------------------------------------- -# 5. /api/status reports 86400 to the frontend +# 5. Session timeout is configured to 24h # --------------------------------------------------------------------------- -class TestStatusEndpoint: +class TestSessionTimeoutConfig: + """The session-linger contract is that idle sessions get reaped after 24h. + We assert this on the constant directly. /health used to expose this + value to unauthenticated callers but no longer does (it leaked the app's + timeout posture to anyone who could reach the URL); the value is part of + the app's internal lifecycle config, not its public API.""" - def test_health_reports_24h_timeout(self): + def test_session_timeout_is_24h(self): app_module = _get_app() - client = app_module.app.test_client() - with mock.patch.object(app_module, "check_authorization", return_value=(True, "test-user")): - resp = client.get("/health") - body = resp.get_json() - assert body["session_timeout_seconds"] == 86400 + assert app_module.SESSION_TIMEOUT_SECONDS == 86400