diff --git a/.issue-assets/1145-image.png b/.issue-assets/1145-image.png deleted file mode 100644 index a1fd366b2..000000000 Binary files a/.issue-assets/1145-image.png and /dev/null differ diff --git a/ao-pr1483 b/ao-pr1483 deleted file mode 160000 index 2abb1c1e3..000000000 --- a/ao-pr1483 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2abb1c1e3dd804da34baf8262fa44c6766421032 diff --git a/docs/mobile-access-v1.md b/docs/mobile-access-v1.md new file mode 100644 index 000000000..97850efaf --- /dev/null +++ b/docs/mobile-access-v1.md @@ -0,0 +1,84 @@ +# Mobile Access V1 + +Mobile Access V1 lets a user expose the AO dashboard through a temporary Cloudflare quick tunnel and open it from another device with a QR code. This document describes the implementation in this branch, not the more hardened token-session design from the earlier mobile access proposal. + +## Goals + +- Let a user access the dashboard from a phone or another network without manual VPN setup. +- Keep local dashboard access simple on `localhost`. +- Protect the public dashboard URL with credentials. +- Route terminal WebSocket traffic through the same protected public origin. +- Allow the user to enable, disable, and update credentials from the dashboard. + +## User Flow + +1. The user starts AO normally, then clicks the desktop-only Remote button in the dashboard. +2. AO downloads `cloudflared` if needed and starts a Cloudflare quick tunnel to the local dashboard. +3. The dashboard shows the public URL, QR code, username, and password. +4. The user scans the QR code from mobile and signs in with Basic Auth. +5. The user can update credentials or disable remote access from the same modal. + +Remote access is a runtime toggle. `ao start` starts AO normally; the tunnel is created only when the user clicks Remote in the running dashboard. + +## Implementation + +The dashboard path is handled by `/api/remote-info`: + +- `GET` returns current remote access state. +- `POST` enables remote access and starts a tunnel. +- `PATCH` saves remote credentials. +- `DELETE` disables remote access and stops the tunnel. + +Credentials are read from the global config, with process environment variables still available as an override for development and tests: + +```text +~/.agent-orchestrator/config.yaml +``` + +The stored shape is: + +```yaml +remoteAccess: + username: ao + password: example-password +``` + +`cloudflared` is cached under: + +```text +~/.agent-orchestrator/bin/ +``` + +## Security Model + +V1 uses Basic Auth for the public dashboard URL: + +- `localhost`, `127.0.0.1`, and `[::1]` bypass Basic Auth so local use stays frictionless. +- Public remote access requires `AO_REMOTE_AUTH_USER` and `AO_REMOTE_AUTH_PASSWORD`. +- Terminal WebSocket traffic is proxied through the dashboard origin and authenticated with the same credentials. +- Direct terminal WebSocket connections also validate the active remote credentials. + +The V1 credential store is intentionally simple: credentials are persisted in plaintext in the local AO global config. This is acceptable for V1 because the threat model is a user-controlled development machine plus a temporary tunnel, not a multi-user hosted service. + +## What This V1 Does Not Implement + +The earlier mobile access proposal described a stronger mobile-auth system. This branch does not implement those pieces: + +- No URL-fragment token login flow. +- No `/api/mobile/login` endpoint. +- No signed HttpOnly session cookie. +- No token TTL or session TTL. +- No persisted lockout counter. +- No `mobile.json` token/hash/HMAC store. +- No revoke-all-sessions endpoint. +- No explicit WebSocket origin pinning beyond the current proxy/auth checks. + +Those are good candidates for a hardened V2 if remote access becomes a long-lived or broader security surface. + +## Current Scope + +This V1 should be described as: + +> Remote dashboard access through Cloudflare quick tunnel plus Basic Auth. + +It is enough for a pragmatic first release because it solves the main product problem: scan a QR code, open the dashboard from mobile, and keep the public URL credential-protected. diff --git a/handoff/pr-1466/architecture.md b/handoff/pr-1466/architecture.md deleted file mode 100644 index f23b161b1..000000000 --- a/handoff/pr-1466/architecture.md +++ /dev/null @@ -1,209 +0,0 @@ -# PR #1466 — Architecture Changes - -What this PR changes, in the order you should learn it. Companion to [`main.md`](./main.md) and [`review-and-risks.md`](./review-and-risks.md). - ---- - -## 1. Storage layout: V1 → V2 - -### V1 (before — `upstream/main`) - -``` -~/.agent-orchestrator/ - {hash}-{projectId}/ # hash = SHA-256 of config dir - sessions/ # active session metadata (key=value flat files) - worktrees/{sessionId}/ - archive/{sessionId}_{ts}/ # terminated sessions copied here -``` - -- `storageKey` = `{hash}-{projectId}` was the primary key threaded through every session. -- Archive was a *second place* to look for terminated sessions — restore/status had dual lookup paths. - -### V2 (after — `storage-redesign`) - -``` -~/.agent-orchestrator/ - config.yaml # global registry of all projects - running.json # current ao start PID/port - last-stop.json # NEW — sessions killed by ao stop / Ctrl+C - projects/ - {projectId}/ # projectId = {basename}_{hash} - sessions/{sessionId}.json # JSON metadata, terminated sessions stay here - worktrees/{sessionId}/ -``` - -**Key shifts:** -- One canonical directory per project — no more `{hash}-{projectId}` wrapper. -- `projectId` is now a *deterministic, human-readable, collision-safe* identifier: `{basename}_{8-char-hash}`. Duplicate basenames get suffixed automatically. -- Session metadata is JSON files (`.json` extension), not flat key-value blobs. -- `archive/` is gone. Terminated sessions stay in `sessions/` with a terminated lifecycle state. -- `storageKey` is fully removed from the type system. Any reference is dead code. - -### Path helpers (where to read/write) - -`packages/core/src/paths.ts`: -- `getProjectSessionsDir(projectId)` — replaces `getActiveMetadataDir(storageKey)`. -- `getProjectWorktreesDir(projectId)` — replaces worktree path under `{hash}-{projectId}`. -- Archive helpers (`getArchiveDir`, etc.) are **removed**. If you see one, it's dead. - ---- - -## 2. Project identity (hashed) - -`projectId` shape: `{sanitized-basename}_{8-char-sha256-prefix}` - -Example: a config at `/Users/harshit/work/agent-orchestrator/agent-orchestrator.yaml` → `projectId = "agent-orchestrator_a1b2c3d4"`. - -Properties: -- **Deterministic** — same path always yields the same id. -- **Collision-safe** — different paths with the same basename get distinct hashes. -- **Suffix allocation on duplicates** — if you have two checkouts with the same basename in the global config, the loader allocates `name_2`, `name_3`, etc. (see commit `f7118ef1`). -- **Sanitization** — basenames are stripped of dots and unsafe chars before hashing (commit `27666c6e`). - -Where it's used: -- Storage paths (above) -- `agent-orchestrator.yaml` → global config registration -- Web tmux session resolution (commit `eca3001c` handles legacy wrapped keys for backward compat) -- `ao status`, `ao stop `, `ao spawn` — all key off `projectId` - -**Invariant:** one `projectId` ↔ one canonical orchestrator session per project. Enforced in `f674422a`. - ---- - -## 3. Session lifecycle: dual-truth elimination - -### Before - -Sessions persisted **both** a `SessionStatus` enum (`spawning | working | pr_open | merged | killed | …`) **and** a lifecycle `state` + `reason`. They drifted. Restore code had to reconcile them. Tests asserted on whichever was easier. - -### After - -Source of truth is **`lifecycle-state.ts`**: -- `state`: `not_started | working | idle | needs_input | stuck | detecting | done | terminated` -- `reason`: `manually_killed | runtime_lost | agent_process_exited | probe_failure | error_in_process | auto_cleanup | pr_merged` - -Legacy `SessionStatus` is **derived** at read time via `deriveLegacyStatus(state, reason, prState)` — used only for display backward-compat. Never persisted. - -Practical consequences: -- `previousStatus` arguments throughout the codebase are gone (commit `e9d9c762`). -- Restore now resets lifecycle cleanly, including for previously-merged PRs (commits `d22f0c6f`, `b178eb66`, `fc6fd88b`). -- Stale runtime detection: `sm.list()` checks if the tmux/process backing each session is still alive during enrichment. Dead ones get persisted as `runtime_lost` → legacy status `killed`. Without this, sessions whose runtime died silently would show "active" forever (commit `9e2df894`). - ---- - -## 4. Migration: V1 → V2 with rollback - -The single most-reviewed code in the PR. Lives in `packages/core/src/migrate-storage/`. - -### Command - -```bash -ao migrate-storage --dry-run # show planned actions -ao migrate-storage # execute (atomic per-project, with rollback on failure) -``` - -### What it does - -1. **Detect V1 layout** — looks for `{hash}-{projectId}/` directories at the AO root. -2. **Inventory** — enumerates sessions, worktrees, archives, validates each against Zod schemas. -3. **Plan** — computes target paths under `projects/{newProjectId}/`. Detects macOS case-insensitive collisions before writing anything. -4. **Execute, atomically per project:** - - Convert flat-file metadata → JSON. - - Move worktrees, rewriting any embedded paths in git config. - - Flatten `archive/` contents back into `sessions/` with terminated lifecycle. - - Mark the source dir as `.migrated` so re-runs don't re-process it (commit `880930a2` prevents `.migrated.migrated`). -5. **Rollback** if any step fails: restore originals, repair git worktrees (the trickiest part — `worktree` files contain absolute paths that need rewriting). - -### Safety guarantees (commits handling each) - -- File-locked global config updates so two `ao migrate-storage` runs can't race. -- Atomic writes: temp file + rename, never partial overwrites (`bb4c68fc`). -- macOS case-insensitive collision detection (`64caef04`). -- Worktree path rewriting handles recursion and stray paths (`ff61ee97`). -- Active session check is **skipped** during `--dry-run` so users can plan without killing sessions (`c800c89c`). -- Rollback preserves worktrees that were already migrated successfully (`fd4f969f`). -- Graceful errors: `b18cbe22` — failed migration shows actionable message instead of stack trace. -- 21+ named edge cases handled: see `handoff/pr-1466/review-and-risks.md`. - -### Things to NOT touch in `migrate-storage/` - -- The order of operations in the per-project transaction. Each step is checkpointed for rollback. -- The `.migrated` marker scheme. -- Git worktree path rewriting — the regex is intentionally narrow. - ---- - -## 5. Cross-project CLI rework - -### `ao stop` - -**Note:** There is no `stop.ts` — the stop command is defined inside `packages/cli/src/commands/start.ts`. - -| Invocation | Before | After | -|------------|--------|-------| -| `ao stop` | Kills only the most-recently-active orchestrator. Saw only local config (1 project). | Loads global config, kills **all** sessions across **all** registered projects. Stops parent process + dashboard. Writes `last-stop.json` with `{ projectId, sessionIds[], otherProjects: [...] }` for restore. | -| `ao stop ` | Same as above (no scoping). | Surgical: kills only ``'s sessions. **Does not** kill parent process or dashboard (commit `95cf979d` — this was a real bug). Falls back to global config if `` isn't in the local config (`2db2951a`). Calls `removeProjectFromRunning(projectId)` to remove the project from `running.json` so that a subsequent `ao start ` can restart without hitting the "already running" gate. | - -### `ao start` - -- Reads `last-stop.json` on startup. If it has sessions, prompts: **"Restore N sessions from your last shutdown?"** — including cross-project ones (`8b130964`). -- Loads global config for cross-project session-manager access during restore. -- Skips orchestrator restore if `ensureOrchestrator()` already restored it (avoids double-spawn). -- **`projectNeedsRestart` gate:** If `ao start ` is called and the project was removed from `running.json` by a prior `ao stop `, the "already running" menu is bypassed and the orchestrator is re-created for that project. An `isProjectId` guard ensures this only triggers for project ID arguments (not filesystem paths or repo URLs). -- Project picker now offers "Add this project" when launched in an unregistered cwd (`d6a56a8f`, `e1ecc091`). -- Auto-registers a flat local config on first `ao start` if missing (`1972fa30`). - -### `Ctrl+C` (signal handler in `ao start`) - -Mirrors `ao stop` exactly: kills all sessions, writes `last-stop.json`, unregisters `running.json`. Implementation uses an async IIFE inside the sync signal handler (Node.js signal handlers are sync — `process.exit()` must be called explicitly since registering a handler removes the default exit behavior). 10s hard timeout via `setTimeout().unref()` in case cleanup hangs (`b4feda79`). Before this PR, Ctrl+C left tmux orphans. - -### Tab completions - -`packages/cli/completions/` — merge global + local config so tab completion shows every registered project, not just those in the current `agent-orchestrator.yaml` (`39ebb07f`). - ---- - -## 6. Web dashboard changes - -Minimal but important: -- **Sidebar:** always shows all sessions across all projects, regardless of which project is active. Per-project filtering is applied at the kanban level via `projectSessions = sessions.filter(s => s.projectId === projectId)`. (`53e8476f`) -- **Tmux session resolver:** handles the legacy wrapped storage key (`{hash}-{projectName}`) so dashboards open mid-migration don't break (`eca3001c`). -- No new UI component libraries, no inline styles — design system unchanged. - ---- - -## 7. Archive removal — what to grep for - -If you see any of these in the codebase, they're dead and should be deleted (or you're on the wrong branch): - -``` -getArchiveDir -ArchivedSessionMetadata -listArchive -moveToArchive -archive: true -``` - -The cleanup spans 11 commits (group C in `pr-1466.html` → Commit Story tab). Tests and docs were updated in lockstep. - ---- - -## 8. New runtime artifacts - -| File | Lifetime | Written by | Read by | -|------|----------|------------|---------| -| `~/.agent-orchestrator/config.yaml` | Persistent | `ao start` (auto-register), `ao spawn`, manual edits | All CLI commands needing cross-project visibility, tab completions | -| `~/.agent-orchestrator/running.json` | Lives while `ao start` is running | `ao start` (register), `ao stop`/Ctrl+C (unregister), `ao stop ` (removes project via `removeProjectFromRunning`) | `ao status`, `ao spawn`, dashboard, `ao start` (checks for already-running + `projectNeedsRestart` gate) | -| `~/.agent-orchestrator/last-stop.json` | Cleared after restore prompt | `ao stop`, Ctrl+C | `ao start` | - ---- - -## 9. Things that intentionally did NOT change - -- The 8-slot plugin system. No new plugins, no interface breakage. -- SSE 5s polling interval (CLAUDE.md C-14). -- The dashboard component library (no new deps; CLAUDE.md C-01). -- The lifecycle polling loop in `lifecycle-manager.ts` — only its inputs (state model) changed. -- The agent plugin contract (Claude Code, Codex, Aider, OpenCode all unchanged at the interface level). - -If you find yourself touching any of the above to "fix" something, stop and re-read the original review thread on the PR — the answer is almost always "no, work around it." diff --git a/handoff/pr-1466/main.md b/handoff/pr-1466/main.md deleted file mode 100644 index 39ea702cc..000000000 --- a/handoff/pr-1466/main.md +++ /dev/null @@ -1,196 +0,0 @@ -# PR #1466 — Storage Redesign Handoff - -**You are picking up an in-flight PR. Read this file first, then the two siblings.** - -- [`architecture.md`](./architecture.md) — what the PR actually changes (storage layout, identity, lifecycle, CLI semantics) -- [`review-and-risks.md`](./review-and-risks.md) — review state, hot zones, edge cases, gotchas - ---- - -## TL;DR - -PR #1466 ("Storage V2") is the big refactor: replaces `storageKey`-based flat metadata with `projects/{projectId}/` JSON storage, introduces deterministic hashed project IDs (`{basename}_{hash}`), removes the `archive/` directory entirely, eliminates the `SessionStatus` dual-truth, ships a crash-safe `migrate-storage` command with rollback, and reworks `ao stop` / `ao start` / `Ctrl+C` for cross-project awareness with session restore. - -| Metric | Value | -|--------|-------| -| Files changed | 90 | -| Insertions | +6,481 | -| Deletions | -2,421 | -| Net LOC | +4,060 | -| PR-owned commits | ~85+ (between `upstream/main..HEAD`) | -| Upstream commits brought in via merge | ~25 | - ---- - -## PR & branch map - -| Role | Branch | Where it lives | What it represents | -|------|--------|----------------|--------------------| -| **Base** | `main` | `ComposioHQ/agent-orchestrator` | The merge target. PR diffs against this. | -| **Head (PR)** | `storage-redesign` | `harshitsinghbhandari/agent-orchestrator` (fork) | The actual PR head — what GitHub shows on PR #1466. All authored commits live here. | -| **Simulation** | `simulate-pr-1466-merged` | `harshitsinghbhandari/agent-orchestrator` (fork) | What `main` will look like AFTER PR #1466 lands. Used for end-to-end testing of the merged state and for post-merge fixes. Tracks `storage-redesign` via repeated merges + a small number of additional fixes (e.g. `89a51107 fix(cli): add removeProjectFromRunning and targeted stop tests`). | - -All three branches live in **Harshit's fork** (`harshitsinghbhandari/agent-orchestrator`). The upstream repo (`ComposioHQ/agent-orchestrator`) only has `main`. - ---- - -## Checkout recipes - -### If you're Harshit (PR author) - -Your remotes are already set up: -- `origin` / `harshit` → `harshitsinghbhandari/agent-orchestrator` -- `upstream` → `ComposioHQ/agent-orchestrator` - -```bash -git fetch upstream && git fetch origin - -git checkout storage-redesign # PR branch -git pull origin storage-redesign - -git checkout simulate-pr-1466-merged # post-merge simulation -git pull origin simulate-pr-1466-merged -``` - -### If you're anyone else (reviewer / new contributor) - -Clone the upstream repo, then add Harshit's fork as a remote to access the PR branches: - -```bash -# Clone upstream -git clone https://github.com/ComposioHQ/agent-orchestrator.git -cd agent-orchestrator - -# Add the fork that holds the PR branches -git remote add harshit https://github.com/harshitsinghbhandari/agent-orchestrator.git -git fetch harshit - -# Checkout the PR branch -git checkout -b storage-redesign harshit/storage-redesign - -# Checkout the simulation branch (optional — for post-merge testing) -git checkout -b simulate-pr-1466-merged harshit/simulate-pr-1466-merged -``` - -Alternatively, use the GitHub CLI: -```bash -gh repo clone ComposioHQ/agent-orchestrator -cd agent-orchestrator -gh pr checkout 1466 # checks out storage-redesign from the fork automatically -``` - -### Which branch should I work on? - -| Goal | Branch | Why | -|------|--------|-----| -| Fix a review comment / address feedback on PR #1466 | `storage-redesign` | Commits here show up on the PR. Push to the fork. | -| Test "what happens after this lands on main" | `simulate-pr-1466-merged` | Post-merge state with fixes already cherry-picked in. | -| Add a fix that should ride along with the merge but you're unsure about PR scope | `simulate-pr-1466-merged` first, then cherry-pick / merge into `storage-redesign` once validated | The simulate branch is the safe playground. | -| Compare the PR's diff against base | `git diff origin/main...storage-redesign` | Three-dot diff = PR's contribution only. | - -**Do not push directly to `ComposioHQ/agent-orchestrator` main.** PR #1466 will land via the GitHub merge button. - ---- - -## Workflow on `storage-redesign` (the PR head) - -```bash -git checkout storage-redesign -git pull --rebase # avoid stacking review-fixup commits - -# Make changes -pnpm install -pnpm build -pnpm typecheck -pnpm test -pnpm --filter @aoagents/ao-web test - -# Commit conventionally — fix:/refactor:/docs:/test:/feat: -git commit -m "fix(core): address review on X" -git push # pushes to harshitsinghbhandari/agent-orchestrator (the fork) -``` - -**Before pushing, run the full pre-flight:** -```bash -pnpm lint && pnpm format:check && pnpm typecheck && pnpm test -``` - -CI on this branch runs lint, typecheck, tests, and a release dry-run. - ---- - -## Workflow on `simulate-pr-1466-merged` - -This branch exists to validate the *merged* state — useful when: -- A fix only manifests after PR #1466 conflicts have been resolved with `main`. -- You're testing CLI behavior end-to-end (e.g. `ao stop` cross-project flows) with a representative repo state. -- A reviewer asks "but what happens after this merges with PR #X?" - -```bash -git checkout simulate-pr-1466-merged - -# Keep it current with both sides (adjust remote names to your setup): -git fetch origin # upstream: ComposioHQ/agent-orchestrator -git merge origin/main # absorb new main commits (resolve conflicts) - -# If you have the fork as a remote named 'harshit': -git fetch harshit -git merge harshit/storage-redesign # absorb new PR commits -``` - -Fixes that should also ship with the PR get cherry-picked back to `storage-redesign`: -```bash -git checkout storage-redesign -git cherry-pick -git push -``` - ---- - -## Project context (orient quickly) - -- **Repo:** `ComposioHQ/agent-orchestrator` — pnpm workspace (~30 packages). -- **Stack:** TypeScript strict, Node 20+, Next.js 15 (App Router), React 19, Tailwind v4, xterm.js, Zod, Vitest. -- **Read this in the repo root:** `CLAUDE.md` — codebase conventions and working principles. -- **Read this for design rules:** `DESIGN.md` — design system, anti-patterns. -- **Read this for what PR #1466 *behaves like*:** `pr-1466.html` — open in a browser. It has two tabs: "Behavior" (per-feature before/after) and "Commit Story" (themed commit groups). Use this as your visual spec. - ---- - -## Critical files this PR touches - -| File | Why it matters | -|------|----------------| -| `packages/core/src/types.ts` | Plugin interfaces. Minimize changes. | -| `packages/core/src/session-manager.ts` | Session CRUD. Now lifecycle-centric, no archive lookup. | -| `packages/core/src/lifecycle-manager.ts` | State machine. Status is *derived*, not stored. | -| `packages/core/src/lifecycle-state.ts` | Canonical state + reason model — new in this PR. | -| `packages/core/src/paths.ts` | V2 path helpers (`getProjectSessionsDir`, etc.). Archive helpers removed. | -| `packages/core/src/migrate-storage/` | The migration command + rollback. Most-reviewed code in the PR. | -| `packages/cli/src/commands/start.ts` | Cross-project restore prompt, Ctrl+C graceful shutdown, `ao stop` logic (stop is handled inside start.ts, there is no separate stop.ts). | -| `packages/cli/src/lib/running-state.ts` | `running.json` / `last-stop.json` read/write, `removeProjectFromRunning()`, advisory locking. | -| `packages/web/src/components/Dashboard.tsx` | Sidebar always shows all projects' sessions. | -| `~/.agent-orchestrator/last-stop.json` | New runtime artifact. Read by `ao start` to offer restore. | -| `~/.agent-orchestrator/config.yaml` | Global config. Cross-project commands fall back here. | - ---- - -## Quick context dump for an AI agent - -If you're an AI agent picking this up, read in this order and you'll be productive: - -1. This file — branch map + workflow. -2. `handoff/pr-1466/architecture.md` — what changed and why. -3. `handoff/pr-1466/review-and-risks.md` — what reviewers cared about + known edge cases. -4. `pr-1466.html` (open in browser) — visual spec, both tabs. -5. `CLAUDE.md` (repo root) — house rules. -6. `git log --oneline upstream/main..storage-redesign` — the actual commit list. - -After that, `git diff main...storage-redesign -- packages/core/src/migrate-storage/` is where the depth lives (use whatever remote/branch ref matches your setup — e.g. `origin/main` or `upstream/main`). - ---- - -## Contact - -PR author: **Harshit Singh** (`@harshitsinghbhandari`) -PR URL: https://github.com/ComposioHQ/agent-orchestrator/pull/1466 diff --git a/handoff/pr-1466/review-and-risks.md b/handoff/pr-1466/review-and-risks.md deleted file mode 100644 index 27fc7f393..000000000 --- a/handoff/pr-1466/review-and-risks.md +++ /dev/null @@ -1,148 +0,0 @@ -# PR #1466 — Review State, Risks, Edge Cases - -What reviewers cared about, where the bodies are buried, and what to verify before claiming done. Companion to [`main.md`](./main.md) and [`architecture.md`](./architecture.md). - ---- - -## Review state at a glance - -This PR has been through **11+ review rounds** across multiple reviewers (humans + Copilot bot). The commit log reflects this — see group L "Review fixes" in `pr-1466.html` (Commit Story tab). Treat any new feedback as the 12th round, not the 1st. - -**Reviewer focus areas, in descending order of attention:** -1. `migrate-storage/` — the migration + rollback machinery. Most reviewed; most edge cases filed. -2. Cross-project CLI semantics (`ao stop` / `ao start` / Ctrl+C) — behavioral correctness around `last-stop.json`. -3. Status / lifecycle dual-truth elimination — making sure no consumer still reads `previousStatus`. -4. Worktree path safety during migration — git config rewriting. -5. Dashboard sidebar scoping regression. - ---- - -## Edge cases handled (do not regress) - -The migration machinery survived a brutal review pass. The PR has explicit handlers for **at least 21 named edge cases**, codified mostly in commit `64caef04` ("EC-1..EC-8, EC-14, EC-27") and follow-ups. The ones to keep in mind when changing this code: - -| ID / theme | Scenario | Where handled | -|------------|----------|---------------| -| EC-1 | V1 root has both new and legacy directories simultaneously | `migrate-storage/inventory.ts` | -| EC-2 | macOS case-insensitive filesystem collision (`Foo` vs `foo`) | `64caef04` | -| EC-3 | Git worktree files contain absolute paths to old location | `ff61ee97` | -| EC-7 | Re-running migration on a partially-migrated tree | `880930a2` (`.migrated` markers, prevent `.migrated.migrated`) | -| EC-8 | Active sessions exist during migration | Pre-flight check, **bypassed only for `--dry-run`** (`c800c89c`) | -| EC-14 | Corrupt or unparseable session JSON | Whitelisted JSON parse with rejection (`fd4f969f`) | -| EC-27 | Rollback after partial success — preserve already-migrated worktrees | `fd4f969f` | -| — | Stray worktree recursion (worktree containing worktree path) | `ff61ee97` | -| — | Empty / orphaned archive directories | Archive removal commits (group C) | -| — | Stale `running.json` from previous crashed `ao start` | Auto-pruned on next read (pre-existing, kept) | -| — | High-entropy test placeholders triggering gitleaks | `31b20ed2`, `3e23c8db` (targeted regex) | -| — | Two `ao migrate-storage` runs racing | File-locked global config writes (`bb4c68fc`) | -| — | Partial failure in one project — others succeed | Per-project transaction isolation | -| — | `ao start ` after `ao stop ` hits "already running" | `removeProjectFromRunning()` + `projectNeedsRestart` gate | -| — | `projectNeedsRestart` false-triggers on path/URL args | `isProjectId` guard: `!isRepoUrl(arg) && !isLocalPath(arg)` | -| — | Ctrl+C leaves tmux orphans | Signal handler mirrors full `ao stop` cleanup (`b4feda79`) | - -Each of these has at least one test. **If you change `migrate-storage/`, run** `pnpm --filter @aoagents/ao-core test` **and read the failures carefully** — they're load-bearing. - ---- - -## Hot zones — touch with care - -These files have subtle invariants and high blast radius. State which invariants you preserve when modifying them (per `CLAUDE.md`). - -### `packages/core/src/migrate-storage/` -- The order of operations in the per-project transaction is checkpointed for rollback. Reordering = corruption on partial failure. -- The `.migrated` marker scheme — re-runs depend on it. -- Git worktree path rewriting regex — intentionally narrow. Broadening it has caused data loss before (`ff61ee97`). -- Atomic write helpers (temp + rename). Don't substitute direct `fs.writeFile`. - -### `packages/core/src/lifecycle-state.ts` + `lifecycle-manager.ts` -- `state` + `reason` are the source of truth. **Never persist legacy `SessionStatus`.** -- `deriveLegacyStatus` is a *display-only* read function. Don't introduce write paths through it. -- State transitions have implicit dependencies — see CLAUDE.md "Working Principles → Think Before Coding." - -### `packages/core/src/session-manager.ts` -- `sm.list()` reconciles stale runtimes (`runtime_lost`) — this is what keeps the dashboard honest. Don't short-circuit it. -- No more archive lookup paths. If you find yourself wanting one, you're solving the wrong problem. - -### `packages/cli/src/commands/start.ts` (includes stop logic — there is no `stop.ts`) -- `last-stop.json` schema includes `otherProjects` for cross-project restore. Adding fields → bump schema, write a migration test. -- `ao stop ` (with arg) **must not** kill the parent process or the dashboard. There's a regression test for this; it caught a real bug (`95cf979d`). -- `ao stop ` calls `removeProjectFromRunning(projectId)` — removing the project from `running.json` so that `ao start ` can restart. The `projectNeedsRestart` gate in `ao start` depends on this. -- Ctrl+C handler has a **10s hard timeout**. Don't remove — cleanup hangs are real. -- The `projectNeedsRestart` gate uses an `isProjectId` guard (`!isRepoUrl && !isLocalPath`) to avoid false triggers when `projectArg` is a filesystem path or URL. - -### `packages/cli/src/lib/running-state.ts` -- Advisory lockfile system (`O_EXCL` atomic creation) with jittered backoff and dead-owner cleanup. -- `removeProjectFromRunning()` — surgically removes a project from `running.json.projects[]`. 6 dedicated tests cover targeted vs full stop semantics. - -### `packages/web/src/components/Dashboard.tsx` -- Sidebar must show all sessions across all projects, regardless of `projectId`. Per-project filtering happens client-side via `projectSessions` (see commit `53e8476f`). -- SSE 5s interval is hard-coded by constraint C-14. Don't change. - ---- - -## Known risks at handoff - -Things to verify still hold when you pick this up: - -1. **Upstream drift.** This PR has been merged with `upstream/main` multiple times. Run `git fetch upstream && git log --oneline upstream/main ^storage-redesign` — if the result is non-empty, plan a merge before pushing. -2. **Migration on real user data.** All migration tests use synthetic fixtures. Before merge, the author validated against personal `~/.agent-orchestrator/` once. Worth re-verifying on any reviewer's machine. -3. **The `last-stop.json` ↔ `running.json` interaction.** If `ao start` crashes between writing `running.json` and the restore prompt, the next `ao start` will see both files. Current behavior: prompt restore, then proceed. Verify this is still the case. -4. **Dashboard during migration.** If the user has the dashboard open while running `ao migrate-storage`, the SSE stream may briefly 404 on a session whose path moved. Pre-existing tolerance handles it; don't tighten the error UI. -5. **Plugin authors with hardcoded `storageKey`.** External plugins that built against the old type may break. Search consumer plugins for `storageKey` references. Within this monorepo, all plugins are clean. - ---- - -## Verification checklist before claiming "done" - -Before pushing to `storage-redesign` or merging the simulation branch: - -```bash -# Full pre-flight -pnpm install -pnpm build -pnpm lint -pnpm format:check -pnpm typecheck -pnpm test -pnpm --filter @aoagents/ao-web test -pnpm test:integration # only if your change touches CLI / lifecycle / migration -``` - -**Manual smoke tests (do not skip if you touched CLI or migration):** - -1. Fresh `~/.agent-orchestrator/`: - - `ao start` → spawn a session → `ao stop` → `ao start` → confirm restore prompt → accept → session resumes. -2. Cross-project: - - Register two projects in global config. - - Spawn a session in each. - - `ao stop` (no args) — both die. `last-stop.json` has both. - - `ao start` from project A — restore prompt offers both, including project B's. -3. `ao stop ` from inside project A — only project B's sessions die. Dashboard + parent process untouched. -4. Ctrl+C in `ao start` — sessions die, `last-stop.json` written, no tmux orphans (`tmux ls` is empty). -5. Migration on a V1 layout snapshot: - - `ao migrate-storage --dry-run` → review plan. - - `ao migrate-storage` → V2 layout exists, archive content folded into `sessions/`, no orphan files. -6. Dashboard: - - Multi-project sidebar shows all projects' sessions when filter changes. - - Restore button works on a terminated session. - ---- - -## Things reviewers explicitly rejected - -If you're tempted to do any of these, *don't* — they were proposed in earlier rounds and shot down: - -- Adding a configurable archive directory ("for users who want to keep an archive"). Plugin slot, not config. -- Keeping `SessionStatus` as a "compatibility field." It was the source of bugs; it stays derived-only. -- Using a database. AO is flat-file by design. -- Rebasing the PR. The merge commits from upstream are intentional — they preserve review context. -- Splitting the PR. Tried earlier; storage + migration + status + CLI are too entangled. - ---- - -## When in doubt - -1. Re-read `CLAUDE.md` "Working Principles." -2. Read the relevant commit message — they're detailed for this PR. -3. Search the PR conversation on GitHub: https://github.com/ComposioHQ/agent-orchestrator/pull/1466 -4. Bias toward simplification (per `feedback_simplify_backend` memory). The backend is already lean after this PR; keep it that way. diff --git a/libkrunfw b/libkrunfw deleted file mode 160000 index 351d354b4..000000000 --- a/libkrunfw +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 351d354b4b3b3e45f38e29897af8acec9966fd41 diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 1b5841a30..1d8ea3b07 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -10,7 +10,11 @@ */ import { type ChildProcess } from "node:child_process"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { + existsSync, + readFileSync, + writeFileSync, +} from "node:fs"; import { resolve, basename, dirname } from "node:path"; import { cwd } from "node:process"; import chalk from "chalk"; @@ -128,6 +132,10 @@ function writeProjectBehaviorConfig(projectPath: string, config: LocalProjectCon writeLocalProjectConfig(projectPath, config); } +interface StartupResult { + port: number; +} + /** * Register a flat local config (agent-orchestrator.yaml without `projects:`) * into the global config so loadConfig can resolve it. @@ -795,7 +803,12 @@ async function startDashboard( directTerminalPort?: number, devMode?: boolean, ): Promise { - const env = await buildDashboardEnv(port, configPath, terminalPort, directTerminalPort); + const env = await buildDashboardEnv( + port, + configPath, + terminalPort, + directTerminalPort, + ); // Detect monorepo vs npm install: the `server/` source directory only exists // in the monorepo. Published npm packages only have `dist-server/`. @@ -856,8 +869,13 @@ async function runStartup( config: OrchestratorConfig, projectId: string, project: ProjectConfig, - opts?: { dashboard?: boolean; orchestrator?: boolean; rebuild?: boolean; dev?: boolean }, -): Promise { + opts?: { + dashboard?: boolean; + orchestrator?: boolean; + rebuild?: boolean; + dev?: boolean; + }, +): Promise { await runtimePreflight(config); // Ask about the auto-update channel once on first `ao start` after this @@ -907,14 +925,18 @@ async function runStartup( } spinner.start("Starting dashboard"); - dashboardProcess = await startDashboard( - port, - webDir, - config.configPath, - config.terminalPort, - config.directTerminalPort, - opts?.dev, - ); + try { + dashboardProcess = await startDashboard( + port, + webDir, + config.configPath, + config.terminalPort, + config.directTerminalPort, + opts?.dev, + ); + } catch (err) { + throw err; + } spinner.succeed(`Dashboard starting on http://localhost:${port}`); console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n")); } @@ -1096,6 +1118,7 @@ async function runStartup( if (opts?.dashboard !== false) { console.log(chalk.cyan("Dashboard:"), `http://localhost:${port}`); + console.log(chalk.dim(" Use the dashboard Remote button to enable mobile access at runtime.")); } if (shouldStartLifecycle) { @@ -1145,7 +1168,9 @@ async function runStartup( }); } - return port; + return { + port, + }; } /** @@ -1613,7 +1638,8 @@ export function registerStart(program: Command): void { project = config.projects[projectId]; } - const actualPort = await runStartup(config, projectId, project, opts); + const startupResult = await runStartup(config, projectId, project, opts); + const actualPort = startupResult.port; // ── Register in running.json (Step 11) ── // During daemon startup, the project supervisor is the authoritative diff --git a/packages/cli/src/lib/web-dir.ts b/packages/cli/src/lib/web-dir.ts index 6f1aaa51e..bb3fb5193 100644 --- a/packages/cli/src/lib/web-dir.ts +++ b/packages/cli/src/lib/web-dir.ts @@ -4,6 +4,7 @@ */ import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; import { Socket } from "node:net"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; @@ -171,6 +172,7 @@ export async function buildDashboardEnv( env["DIRECT_TERMINAL_PORT"] = String(resolvedDirect); env["NEXT_PUBLIC_TERMINAL_PORT"] = String(resolvedTerminal); env["NEXT_PUBLIC_DIRECT_TERMINAL_PORT"] = String(resolvedDirect); + env["AO_REMOTE_WS_TOKEN_SECRET"] ||= randomBytes(32).toString("base64url"); return env; } diff --git a/packages/core/src/global-config.ts b/packages/core/src/global-config.ts index cda4ca625..bb9811d7b 100644 --- a/packages/core/src/global-config.ts +++ b/packages/core/src/global-config.ts @@ -205,6 +205,13 @@ export const GlobalConfigSchema = z updateChannel: UpdateChannelSchema.optional().catch(undefined), /** Override path-based install detection. Optional. */ installMethod: InstallMethodOverrideSchema.optional().catch(undefined), + /** Remote dashboard access credentials. */ + remoteAccess: z + .object({ + username: z.string().optional(), + password: z.string().optional(), + }) + .optional(), /** Cross-project defaults — projects inherit when fields are omitted. */ defaults: z .object({ @@ -1081,6 +1088,9 @@ export function createDefaultGlobalConfig(): GlobalConfig { return { port: 3000, readyThresholdMs: 300_000, + remoteAccess: { + username: "ao", + }, defaults: { runtime: getDefaultRuntime(), agent: "claude-code", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6cd385068..c3c133025 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -279,6 +279,7 @@ export { killProcessTree, findPidByPort, getEnvDefaults, + getLocalNetworkIPs, } from "./platform.js"; export { normalizeOriginUrl, relativeSubdir, deriveStorageKey } from "./storage-key.js"; diff --git a/packages/core/src/platform.ts b/packages/core/src/platform.ts index 5cde1af87..3e617153d 100644 --- a/packages/core/src/platform.ts +++ b/packages/core/src/platform.ts @@ -1,6 +1,6 @@ import { execFile as execFileCb } from "node:child_process"; import { promisify } from "node:util"; -import { homedir, userInfo } from "node:os"; +import { homedir, userInfo, networkInterfaces } from "node:os"; import { existsSync } from "node:fs"; const execFileAsync = promisify(execFileCb); @@ -239,3 +239,34 @@ export function getEnvDefaults(): EnvDefaults { USER: process.env["USER"] || userInfo().username, }; } + +// -- Network -- + +/** + * Return non-internal IPv4 addresses of this machine on the local network. + * Sorted by priority: en0/en1/Wi-Fi/Ethernet first, then everything else. + * Filters out loopback (127.x.x.x) and link-local (169.254.x.x) addresses. + */ +export function getLocalNetworkIPs(): string[] { + const ifaces = networkInterfaces(); + const ips: { addr: string; prio: number }[] = []; + + for (const [name, addrs] of Object.entries(ifaces)) { + if (!addrs) continue; + const lower = name.toLowerCase(); + // Prioritize common physical interfaces + const prio = + lower.includes("en0") || lower.includes("eth0") ? 0 + : lower.includes("en") || lower.includes("eth") || lower.includes("wi-fi") || lower.includes("wlan") ? 1 + : 2; + + for (const info of addrs) { + if (info.family !== "IPv4" || info.internal) continue; + if (info.address.startsWith("169.254.")) continue; // link-local + ips.push({ addr: info.address, prio }); + } + } + + ips.sort((a, b) => a.prio - b.prio); + return ips.map((x) => x.addr); +} diff --git a/packages/web/package.json b/packages/web/package.json index 83a59ace5..5bff12a2a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -27,7 +27,7 @@ ], "scripts": { "dev": "concurrently \"npm:dev:next\" \"npm:dev:direct-terminal\"", - "dev:next": "next dev -p ${PORT:-3000}", + "dev:next": "tsx server/dev-next-server.ts", "dev:direct-terminal": "node scripts/dev-direct-terminal.mjs", "prebuild": "node scripts/guard-production-artifact-clean.mjs && rimraf .next dist-server", "build": "next build && tsc -p tsconfig.server.json && node scripts/stamp-version.js", diff --git a/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts b/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts index 39f2caaa6..98428b27e 100644 --- a/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts +++ b/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts @@ -7,12 +7,16 @@ * response, mirroring what the browser's MuxProvider does. */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from "vitest"; import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { request, type IncomingMessage } from "node:http"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; import { WebSocket } from "ws"; import { findTmux } from "../tmux-utils.js"; import { createDirectTerminalServer, type DirectTerminalServer } from "../direct-terminal-ws.js"; +import { createRemoteWsToken } from "../remote-auth.js"; const TMUX = findTmux(); const TEST_SESSION = `ao-test-integration-${process.pid}`; @@ -47,9 +51,9 @@ function httpGet(path: string): Promise<{ status: number; body: string }> { } /** Open a raw WebSocket to /mux and wait for the connection to be established. */ -function connectMux(): Promise { +function connectMux(path = "/mux"): Promise { return new Promise((resolve, reject) => { - const ws = new WebSocket(`ws://localhost:${port}/mux`); + const ws = new WebSocket(`ws://localhost:${port}${path}`); ws.on("open", () => resolve(ws)); ws.on("error", reject); setTimeout(() => reject(new Error("WebSocket connect timeout")), 5000); @@ -126,6 +130,10 @@ afterAll(() => { } }); +afterEach(() => { + vi.unstubAllEnvs(); +}); + // ============================================================================= // Health endpoint // ============================================================================= @@ -180,6 +188,81 @@ describeWithTmux("WebSocket upgrade routing", () => { ws.close(); }); + it("requires auth_token on /mux when remote auth is enabled", async () => { + vi.stubEnv("AO_REMOTE_AUTH_USER", "ao"); + vi.stubEnv("AO_REMOTE_AUTH_PASSWORD", "secret"); + vi.stubEnv("AO_REMOTE_WS_TOKEN_SECRET", "test-ws-token-secret"); + + const rejected = await new Promise<{ code: number }>((resolve) => { + const ws = new WebSocket(`ws://localhost:${port}/mux`); + ws.on("close", (code) => resolve({ code })); + ws.on("error", () => resolve({ code: -1 })); + setTimeout(() => resolve({ code: -1 }), 3000); + }); + expect(rejected.code).not.toBe(1000); + + const token = createRemoteWsToken({ username: "ao", password: "secret" }); + if (!token) throw new Error("expected token"); + const ws = await connectMux(`/mux?auth_token=${encodeURIComponent(token)}`); + expect(ws.readyState).toBe(WebSocket.OPEN); + ws.close(); + }); + + it("uses saved remote credentials after they change at runtime", async () => { + if (!TMUX) return; + const tempDir = mkdtempSync(resolve(tmpdir(), "ao-remote-auth-")); + const configPath = resolve(tempDir, "config.yaml"); + writeFileSync(configPath, "remoteAccess:\n username: ao\n password: saved-old\n"); + vi.stubEnv("AO_GLOBAL_CONFIG", configPath); + vi.stubEnv("AO_REMOTE_AUTH_USER", "ao"); + vi.stubEnv("AO_REMOTE_AUTH_PASSWORD", "env-password"); + vi.stubEnv("AO_REMOTE_WS_TOKEN_SECRET", "test-ws-token-secret"); + + const rotatedServer = createDirectTerminalServer(TMUX); + rotatedServer.server.listen(0); + const addr = rotatedServer.server.address(); + const rotatedPort = typeof addr === "object" && addr ? addr.port : 0; + + const connectRotatedMux = (token: string): Promise => + new Promise((resolvePromise, reject) => { + const ws = new WebSocket( + `ws://localhost:${rotatedPort}/mux?auth_token=${encodeURIComponent(token)}`, + ); + ws.on("open", () => resolvePromise(ws)); + ws.on("error", reject); + setTimeout(() => reject(new Error("WebSocket connect timeout")), 5000); + }); + + try { + const envToken = createRemoteWsToken({ username: "ao", password: "env-password" }); + if (!envToken) throw new Error("expected token"); + const initialWs = await connectRotatedMux(envToken); + expect(initialWs.readyState).toBe(WebSocket.OPEN); + initialWs.close(); + + writeFileSync(configPath, "remoteAccess:\n username: ao\n password: saved-new\n"); + + const oldTokenResult = await new Promise<{ code: number }>((resolvePromise) => { + const ws = new WebSocket( + `ws://localhost:${rotatedPort}/mux?auth_token=${encodeURIComponent(envToken)}`, + ); + ws.on("close", (code) => resolvePromise({ code })); + ws.on("error", () => resolvePromise({ code: -1 })); + setTimeout(() => resolvePromise({ code: -1 }), 3000); + }); + expect(oldTokenResult.code).not.toBe(1000); + + const newToken = createRemoteWsToken({ username: "ao", password: "saved-new" }); + if (!newToken) throw new Error("expected token"); + const rotatedWs = await connectRotatedMux(newToken); + expect(rotatedWs.readyState).toBe(WebSocket.OPEN); + rotatedWs.close(); + } finally { + rotatedServer.shutdown(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("destroys connections on unknown paths", async () => { const result = await new Promise<{ code: number }>((resolve) => { const ws = new WebSocket(`ws://localhost:${port}/ws`); diff --git a/packages/web/server/__tests__/mux-websocket.test.ts b/packages/web/server/__tests__/mux-websocket.test.ts index 48f38e96f..b617c3c32 100644 --- a/packages/web/server/__tests__/mux-websocket.test.ts +++ b/packages/web/server/__tests__/mux-websocket.test.ts @@ -48,6 +48,7 @@ describe("SessionBroadcaster", () => { beforeEach(() => { vi.useFakeTimers(); + vi.unstubAllEnvs(); mockFetch.mockReset(); broadcaster = new SessionBroadcaster("3000"); }); @@ -216,6 +217,27 @@ describe("SessionBroadcaster", () => { }); describe("fetchSnapshot", () => { + it("uses remote Basic Auth for internal polling when configured", async () => { + vi.stubEnv("AO_REMOTE_AUTH_USER", "ao"); + vi.stubEnv("AO_REMOTE_AUTH_PASSWORD", "secret"); + broadcaster = new SessionBroadcaster("3000"); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ sessions: [] }), + }); + + broadcaster.subscribe(vi.fn()); + await vi.advanceTimersByTimeAsync(10); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:3000/api/sessions/patches", + expect.objectContaining({ + headers: { Authorization: `Basic ${Buffer.from("ao:secret").toString("base64")}` }, + signal: expect.any(AbortSignal), + }), + ); + }); + it("returns null on fetch failure", async () => { mockFetch.mockRejectedValueOnce(new Error("network error")); diff --git a/packages/web/server/dev-next-server.ts b/packages/web/server/dev-next-server.ts new file mode 100644 index 000000000..e7352d1c9 --- /dev/null +++ b/packages/web/server/dev-next-server.ts @@ -0,0 +1,53 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; +import { ensureRemoteWsTokenSecret } from "./remote-auth.js"; +import { proxyTerminalUpgrade } from "./terminal-proxy.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const require = createRequire(import.meta.url); + +type NextApp = { + prepare: () => Promise; + getRequestHandler: () => (req: IncomingMessage, res: ServerResponse) => Promise | void; +}; + +const next = require("next") as (options: { + dev: boolean; + dir: string; + hostname: string; + port: number; +}) => NextApp; + +const pkgRoot = resolve(__dirname, ".."); +const port = Number.parseInt(process.env.PORT ?? "3000", 10); +const hostname = process.env.HOST || "0.0.0.0"; + +ensureRemoteWsTokenSecret(); + +async function main(): Promise { + const app = next({ dev: true, dir: pkgRoot, hostname, port }); + const handle = app.getRequestHandler(); + await app.prepare(); + + const server = createServer((req, res) => { + void handle(req, res); + }); + + server.on("upgrade", (request, socket, head) => { + if (!proxyTerminalUpgrade(request, socket, head)) { + socket.destroy(); + } + }); + + server.listen(port, hostname, () => { + process.stdout.write(`[next-dev] ready on http://${hostname}:${port}\n`); + }); +} + +main().catch((err: unknown) => { + process.stderr.write(`[next-dev] failed to start: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +}); diff --git a/packages/web/server/direct-terminal-ws.ts b/packages/web/server/direct-terminal-ws.ts index 99f028b1e..e9a04dd21 100644 --- a/packages/web/server/direct-terminal-ws.ts +++ b/packages/web/server/direct-terminal-ws.ts @@ -5,20 +5,35 @@ import { createServer, type Server } from "node:http"; import type { WebSocketServer } from "ws"; +import { isWindows } from "@aoagents/ao-core"; import { findTmux } from "./tmux-utils.js"; import { createMuxWebSocket } from "./mux-websocket.js"; +import { + activeRemoteAuth, + readConfiguredRemoteAuth, + verifyRemoteWsToken, + type RemoteAuthCredentials, +} from "./remote-auth.js"; export interface DirectTerminalServer { server: Server; shutdown: () => void; } +function isRemoteAuthAllowed(url: URL, initialConfiguredAuth: RemoteAuthCredentials): boolean { + const { username, password: expectedPassword } = activeRemoteAuth(initialConfiguredAuth); + if (!expectedPassword) return true; + + return verifyRemoteWsToken(url.searchParams.get("auth_token"), { username, password: expectedPassword }); +} + /** * Create the direct terminal WebSocket server. * Separated from listen() so tests can control lifecycle. */ export function createDirectTerminalServer(tmuxPath?: string | null): DirectTerminalServer { const TMUX = tmuxPath ?? findTmux(); + const initialConfiguredAuth = readConfiguredRemoteAuth(); let muxWss: WebSocketServer | null = null; @@ -62,9 +77,10 @@ export function createDirectTerminalServer(tmuxPath?: string | null): DirectTerm // Manual upgrade routing — ws library doesn't support multiple WebSocketServer // instances with different `path` options on the same HTTP server. server.on("upgrade", (request, socket, head) => { - const pathname = new URL(request.url ?? "/", "ws://localhost").pathname; + const url = new URL(request.url ?? "/", "ws://localhost"); + const pathname = url.pathname; - if (pathname === "/mux" && muxWss) { + if (pathname === "/mux" && muxWss && isRemoteAuthAllowed(url, initialConfiguredAuth)) { muxWss.handleUpgrade(request, socket, head, (ws) => { muxWss!.emit("connection", ws, request); }); @@ -102,7 +118,7 @@ if (isMainModule) { const TMUX = findTmux(); if (TMUX) { console.log(`[DirectTerminal] Using tmux: ${TMUX}`); - } else if (process.platform === "win32") { + } else if (isWindows()) { console.log(`[DirectTerminal] Windows mode — using named pipe relay to PTY hosts`); } else { console.log(`[DirectTerminal] No tmux available — terminal relay may be limited`); diff --git a/packages/web/server/mux-websocket.ts b/packages/web/server/mux-websocket.ts index 5515574df..fb436cd68 100644 --- a/packages/web/server/mux-websocket.ts +++ b/packages/web/server/mux-websocket.ts @@ -64,9 +64,14 @@ export class SessionBroadcaster { private intervalId: ReturnType | null = null; private polling = false; private readonly baseUrl: string; + private readonly authHeader: string | null; constructor(nextPort: string) { this.baseUrl = `http://localhost:${nextPort}`; + const password = process.env.AO_REMOTE_AUTH_PASSWORD; + this.authHeader = password + ? `Basic ${Buffer.from(`${process.env.AO_REMOTE_AUTH_USER || "ao"}:${password}`).toString("base64")}` + : null; } /** @@ -153,6 +158,7 @@ export class SessionBroadcaster { try { const res = await fetch(`${this.baseUrl}/api/sessions/patches`, { signal: controller.signal, + ...(this.authHeader ? { headers: { Authorization: this.authHeader } } : {}), }); clearTimeout(timeoutId); if (!res.ok) { diff --git a/packages/web/server/remote-auth.ts b/packages/web/server/remote-auth.ts new file mode 100644 index 000000000..3501d7c9e --- /dev/null +++ b/packages/web/server/remote-auth.ts @@ -0,0 +1,156 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { + createDefaultGlobalConfig, + getGlobalConfigPath, + loadGlobalConfig, +} from "@aoagents/ao-core"; + +export type RemoteAuthCredentials = { + username: string; + password?: string; +}; + +const TOKEN_VERSION = "v1"; +const TOKEN_TTL_MS = 5 * 60 * 1000; + +function base64UrlEncode(input: string | Buffer): string { + return Buffer.from(input).toString("base64url"); +} + +function base64UrlDecode(input: string): string | null { + try { + return Buffer.from(input, "base64url").toString("utf-8"); + } catch { + return null; + } +} + +function getTokenSecret(): string | undefined { + return ( + process.env.AO_REMOTE_WS_TOKEN_SECRET?.trim() || + process.env.AO_REMOTE_AUTH_PASSWORD?.trim() || + readConfiguredRemoteAuth().password + ); +} + +export function ensureRemoteWsTokenSecret(): string { + const existing = process.env.AO_REMOTE_WS_TOKEN_SECRET?.trim() || undefined; + if (existing) return existing; + const secret = randomBytes(32).toString("base64url"); + process.env.AO_REMOTE_WS_TOKEN_SECRET = secret; + return secret; +} + +export function decodeBasicToken( + input: string | undefined | null, +): { username: string; password: string } | null { + if (!input) return null; + try { + const decoded = Buffer.from(input, "base64").toString("utf-8"); + const separator = decoded.indexOf(":"); + if (separator === -1) return null; + return { username: decoded.slice(0, separator), password: decoded.slice(separator + 1) }; + } catch { + return null; + } +} + +export function readConfiguredRemoteAuth(): RemoteAuthCredentials { + const config = loadGlobalConfig(getGlobalConfigPath()) ?? createDefaultGlobalConfig(); + const remoteAccess = + config.remoteAccess && typeof config.remoteAccess === "object" + ? (config.remoteAccess as { username?: string; password?: string }) + : undefined; + + return { + username: remoteAccess?.username?.trim() || "ao", + password: remoteAccess?.password?.trim() || undefined, + }; +} + +export function activeRemoteAuth(initialConfiguredAuth?: RemoteAuthCredentials): RemoteAuthCredentials { + const configured = readConfiguredRemoteAuth(); + if ( + initialConfiguredAuth && + (configured.username !== initialConfiguredAuth.username || + configured.password !== initialConfiguredAuth.password) + ) { + return configured; + } + + return { + username: process.env.AO_REMOTE_AUTH_USER || initialConfiguredAuth?.username || configured.username, + password: process.env.AO_REMOTE_AUTH_PASSWORD || initialConfiguredAuth?.password || configured.password, + }; +} + +function credentialFingerprint(credentials: RemoteAuthCredentials, secret: string): string { + return createHmac("sha256", secret) + .update(`${credentials.username}:${credentials.password ?? ""}`) + .digest("base64url"); +} + +export function createRemoteWsToken(credentials: RemoteAuthCredentials): string | undefined { + const secret = getTokenSecret(); + if (!secret) return undefined; + + const payload = base64UrlEncode( + JSON.stringify({ + u: credentials.username, + c: credentialFingerprint(credentials, secret), + exp: Date.now() + TOKEN_TTL_MS, + n: randomBytes(12).toString("base64url"), + }), + ); + const signature = createHmac("sha256", secret).update(payload).digest("base64url"); + return `${TOKEN_VERSION}.${payload}.${signature}`; +} + +export function verifyRemoteWsToken( + token: string | undefined | null, + expectedCredentials: RemoteAuthCredentials, +): boolean { + const secret = getTokenSecret(); + if (!secret || !token) return false; + + const parts = token.split("."); + if (parts.length !== 3 || parts[0] !== TOKEN_VERSION) return false; + const [, payload, signature] = parts; + if (!payload || !signature) return false; + + const expectedSignature = createHmac("sha256", secret).update(payload).digest("base64url"); + const provided = Buffer.from(signature); + const expectedSignatureBuffer = Buffer.from(expectedSignature); + if ( + provided.length !== expectedSignatureBuffer.length || + !timingSafeEqual(provided, expectedSignatureBuffer) + ) { + return false; + } + + const rawPayload = base64UrlDecode(payload); + if (!rawPayload) return false; + + try { + const parsed = JSON.parse(rawPayload) as { u?: unknown; c?: unknown; exp?: unknown }; + return ( + parsed.u === expectedCredentials.username && + parsed.c === credentialFingerprint(expectedCredentials, secret) && + typeof parsed.exp === "number" && + parsed.exp >= Date.now() + ); + } catch { + return false; + } +} + +export function isBasicAuthHeaderAllowed( + authorization: string | string[] | undefined, + expected: RemoteAuthCredentials, +): boolean { + if (!expected.password) return true; + const header = Array.isArray(authorization) ? authorization[0] : (authorization ?? ""); + const headerMatch = /^Basic\s+(.+)$/i.exec(header); + const credentials = decodeBasicToken(headerMatch?.[1]); + return credentials?.username === expected.username && credentials.password === expected.password; +} diff --git a/packages/web/server/start-all.ts b/packages/web/server/start-all.ts index d54b11c33..db97b4fb7 100644 --- a/packages/web/server/start-all.ts +++ b/packages/web/server/start-all.ts @@ -5,25 +5,59 @@ */ import { type ChildProcess } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { resolve, dirname } from "node:path"; -import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; import { + createDefaultGlobalConfig, + getGlobalConfigPath, isWindows, killProcessTree, + loadGlobalConfig, markDaemonShutdownHandlerInstalled, spawnManagedDaemonChild, } from "@aoagents/ao-core"; +import { ensureRemoteWsTokenSecret } from "./remote-auth.js"; +import { proxyTerminalUpgrade } from "./terminal-proxy.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +const require = createRequire(import.meta.url); + +type NextApp = { + prepare: () => Promise; + getRequestHandler: () => (req: IncomingMessage, res: ServerResponse) => Promise | void; +}; + +const next = require("next") as (options: { + dev: boolean; + dir: string; + hostname: string; + port: number; +}) => NextApp; // Resolve paths relative to the package root (one level up from dist-server/) const pkgRoot = resolve(__dirname, ".."); const children: ChildProcess[] = []; markDaemonShutdownHandlerInstalled(); +let nextServer: Server | null = null; +let shuttingDown = false; + +type RemoteAccessConfig = { + username?: string; + password?: string; +}; + +const globalConfig = loadGlobalConfig(getGlobalConfigPath()) ?? createDefaultGlobalConfig(); +const remoteAccessConfig: RemoteAccessConfig = + globalConfig.remoteAccess && typeof globalConfig.remoteAccess === "object" ? globalConfig.remoteAccess : {}; +process.env["AO_REMOTE_AUTH_USER"] ||= remoteAccessConfig.username?.trim() || "ao"; +process.env["AO_REMOTE_AUTH_PASSWORD"] ||= + remoteAccessConfig.password?.trim() || randomBytes(18).toString("base64url"); +ensureRemoteWsTokenSecret(); function log(label: string, msg: string): void { process.stdout.write(`[${label}] ${msg}\n`); @@ -82,48 +116,38 @@ function spawnProcess( return launch(); } -/** - * Resolve the `next` CLI binary path. - * Tries the local .bin shim first (fast), then falls back to require.resolve (hoisted deps). - */ -function resolveNextBin(): string { - // On Windows, .bin/next is a POSIX shell shim that spawn() cannot execute. - // Skip it and go straight to the JS entry point. - if (!isWindows()) { - const localBin = resolve(pkgRoot, "node_modules", ".bin", "next"); - if (existsSync(localBin)) return localBin; - } - - // Resolve the actual Next.js CLI JS entry point - const require = createRequire(resolve(pkgRoot, "package.json")); - try { - const nextPkg = require.resolve("next/package.json"); - return resolve(dirname(nextPkg), "dist", "bin", "next"); - } catch { - // Last resort — rely on PATH - return "next"; - } -} - -// Start Next.js production server const port = process.env["PORT"] || "3000"; -const nextBin = resolveNextBin(); - -if (isWindows() && nextBin !== "next") { - // On Windows, run the JS entry point via the current node binary. - // spawn() can't execute .js files directly on Windows. - spawnProcess("next", process.execPath, [nextBin, "start", "-p", port]); -} else { - spawnProcess("next", nextBin, ["start", "-p", port]); -} +const hostname = process.env["HOST"] || "0.0.0.0"; // Start direct terminal WebSocket server (auto-restart on crash) spawnProcess("direct-terminal", "node", [resolve(__dirname, "direct-terminal-ws.js")], { restart: true, }); -// Graceful shutdown — send SIGTERM to children and wait for them to exit -let shuttingDown = false; +async function startNextServer(): Promise { + const app = next({ dev: false, dir: pkgRoot, hostname, port: Number.parseInt(port, 10) }); + const handle = app.getRequestHandler(); + await app.prepare(); + + nextServer = createServer((req: IncomingMessage, res: ServerResponse) => { + void handle(req, res); + }); + + nextServer.on("upgrade", (request, socket, head) => { + if (!proxyTerminalUpgrade(request, socket, head)) { + socket.destroy(); + } + }); + + nextServer.listen(Number.parseInt(port, 10), hostname, () => { + log("next", `ready on http://${hostname}:${port}`); + }); +} + +startNextServer().catch((err: unknown) => { + log("next", `failed to start: ${err instanceof Error ? err.message : String(err)}`); + cleanup(); +}); function cleanup(): void { if (shuttingDown) return; @@ -131,10 +155,13 @@ function cleanup(): void { let alive = children.length; if (alive === 0) { + nextServer?.close(); process.exit(0); return; } + nextServer?.close(); + // Force exit after 5s if children don't exit cleanly const forceTimer = setTimeout(() => { log("start-all", "Children did not exit in time, forcing shutdown"); diff --git a/packages/web/server/terminal-proxy.ts b/packages/web/server/terminal-proxy.ts new file mode 100644 index 000000000..8f82c6b68 --- /dev/null +++ b/packages/web/server/terminal-proxy.ts @@ -0,0 +1,75 @@ +import { type IncomingMessage } from "node:http"; +import { createConnection } from "node:net"; +import { type Duplex } from "node:stream"; +import { + activeRemoteAuth, + isBasicAuthHeaderAllowed, + verifyRemoteWsToken, + type RemoteAuthCredentials, +} from "./remote-auth.js"; + +export function getTerminalProxyTarget(requestUrl: string | undefined): string | null { + const url = new URL(requestUrl ?? "/", "ws://localhost"); + if (url.pathname === "/ao-terminal-mux") { + url.pathname = "/mux"; + return `${url.pathname}${url.search}`; + } + if (url.pathname.startsWith("/ao-terminal/")) { + url.pathname = url.pathname.slice("/ao-terminal".length); + return `${url.pathname}${url.search}`; + } + return null; +} + +export function isRemoteUpgradeAllowed( + request: IncomingMessage, + initialConfiguredAuth?: RemoteAuthCredentials, +): boolean { + const expected = activeRemoteAuth(initialConfiguredAuth); + if (!expected.password) return true; + + const url = new URL(request.url ?? "/", "ws://localhost"); + if (verifyRemoteWsToken(url.searchParams.get("auth_token"), expected)) { + return true; + } + + return isBasicAuthHeaderAllowed(request.headers.authorization, expected); +} + +export function proxyTerminalUpgrade( + request: IncomingMessage, + socket: Duplex, + head: Buffer, + initialConfiguredAuth?: RemoteAuthCredentials, +): boolean { + const targetPath = getTerminalProxyTarget(request.url); + if (!targetPath) return false; + if (!isRemoteUpgradeAllowed(request, initialConfiguredAuth)) { + socket.destroy(); + return true; + } + + const directTerminalPort = Number.parseInt(process.env["DIRECT_TERMINAL_PORT"] ?? "14801", 10); + const upstream = createConnection({ host: "127.0.0.1", port: directTerminalPort }); + + upstream.on("connect", () => { + const headers = Object.entries(request.headers) + .flatMap(([name, value]) => { + if (Array.isArray(value)) return value.map((item) => `${name}: ${item}`); + return value === undefined ? [] : [`${name}: ${value}`]; + }) + .join("\r\n"); + upstream.write(`GET ${targetPath} HTTP/${request.httpVersion}\r\n${headers}\r\n\r\n`); + if (head.length > 0) upstream.write(head); + socket.pipe(upstream).pipe(socket); + }); + + upstream.on("error", () => { + socket.destroy(); + }); + socket.on("error", () => { + upstream.destroy(); + }); + + return true; +} diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 84f769e97..6cfb82631 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -969,6 +969,24 @@ describe("API Routes", () => { }); }); + it("returns an opaque websocket token instead of Basic credentials", async () => { + await withEnv( + { + AO_REMOTE_AUTH_USER: "ao", + AO_REMOTE_AUTH_PASSWORD: "secret", + AO_REMOTE_WS_TOKEN_SECRET: "test-ws-token-secret", + }, + async () => { + const res = await runtimeTerminalGET(); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.remoteWsToken).toMatch(/^v1\./); + expect(data.remoteWsToken).not.toBe(Buffer.from("ao:secret").toString("base64")); + expect(data.remoteAuthToken).toBeUndefined(); + }, + ); + }); + it("sets Cache-Control: no-store header", async () => { const res = await runtimeTerminalGET(); expect(res.headers.get("Cache-Control")).toBe("no-store"); diff --git a/packages/web/src/app/api/remote-info/route.ts b/packages/web/src/app/api/remote-info/route.ts new file mode 100644 index 000000000..ff5a75fb4 --- /dev/null +++ b/packages/web/src/app/api/remote-info/route.ts @@ -0,0 +1,51 @@ +import { + disableRemoteAccess, + enableRemoteAccess, + getRemoteAccessInfo, + saveRemoteAccessCredentials, +} from "@/lib/remote-access-manager"; + +export async function GET() { + try { + return Response.json(getRemoteAccessInfo()); + } catch { + return Response.json({ enabled: false, hosts: [] }); + } +} + +export async function POST() { + try { + return Response.json(await enableRemoteAccess()); + } catch (err) { + return Response.json( + { enabled: false, hosts: [], error: err instanceof Error ? err.message : "Failed to enable remote access" }, + { status: 500 }, + ); + } +} + +export async function PATCH(request: Request) { + try { + const body = (await request.json().catch(() => null)) as Record | null; + if (!body) { + return Response.json({ enabled: false, hosts: [], error: "Invalid JSON body" }, { status: 400 }); + } + return Response.json(saveRemoteAccessCredentials(body)); + } catch (err) { + return Response.json( + { enabled: false, hosts: [], error: err instanceof Error ? err.message : "Failed to save remote access credentials" }, + { status: 400 }, + ); + } +} + +export async function DELETE() { + try { + return Response.json(await disableRemoteAccess()); + } catch (err) { + return Response.json( + { enabled: false, hosts: [], error: err instanceof Error ? err.message : "Failed to disable remote access" }, + { status: 500 }, + ); + } +} diff --git a/packages/web/src/app/api/runtime/terminal/route.ts b/packages/web/src/app/api/runtime/terminal/route.ts index 122d2d87f..2498158cc 100644 --- a/packages/web/src/app/api/runtime/terminal/route.ts +++ b/packages/web/src/app/api/runtime/terminal/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { activeRemoteAuth, createRemoteWsToken } from "../../../../../server/remote-auth"; export const dynamic = "force-dynamic"; @@ -23,9 +24,11 @@ export async function GET() { const proxyWsPath = normalizeProxyPath( process.env.TERMINAL_WS_PATH ?? process.env.NEXT_PUBLIC_TERMINAL_WS_PATH, ); + const remoteAuth = activeRemoteAuth(); + const remoteWsToken = remoteAuth.password ? createRemoteWsToken(remoteAuth) : undefined; return NextResponse.json( - { terminalPort, directTerminalPort, proxyWsPath }, + { terminalPort, directTerminalPort, proxyWsPath, remoteWsToken }, { headers: { "Cache-Control": "no-store" } }, ); } diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 8bb4b4016..5de3264aa 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -7723,3 +7723,215 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { background: color-mix(in srgb, var(--color-text-tertiary) 14%, transparent); color: var(--color-text-tertiary); } + +/* ── Remote Access QR Modal ───────────────────────────────────────── */ +.remote-qr-overlay { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + animation: remoteFadeIn 0.15s ease-out; +} + +@keyframes remoteFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.remote-qr-modal { + background: var(--color-bg-surface); + border: 1px solid var(--color-border-subtle); + border-radius: 12px; + width: 320px; + max-width: calc(100vw - 32px); + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.25); + overflow: hidden; + animation: remoteSlideUp 0.2s ease-out; +} + +@keyframes remoteSlideUp { + from { opacity: 0; transform: translateY(8px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.remote-qr-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--color-border-subtle); +} + +.remote-qr-modal__title { + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); + margin: 0; +} + +.remote-qr-modal__close { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--color-text-tertiary); + cursor: pointer; + transition: background 0.1s; +} + +.remote-qr-modal__close:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.remote-qr-modal__body { + padding: 24px 20px 20px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.remote-qr-modal__qr { + border: 1px solid var(--color-border-subtle); + border-radius: 8px; + padding: 8px; + background: #fff; +} + +.remote-qr-modal__url { + font-family: var(--font-jetbrains-mono); + font-size: 12px; + color: var(--color-text-secondary); + background: var(--color-bg-inset); + padding: 8px 12px; + border-radius: 6px; + word-break: break-all; + text-align: center; + width: 100%; + margin: 0; + user-select: all; +} + +.remote-qr-modal__hosts { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: center; +} + +.remote-qr-modal__host-btn { + padding: 4px 10px; + border: 1px solid var(--color-border-subtle); + border-radius: 14px; + background: transparent; + color: var(--color-text-secondary); + font-size: 11px; + font-family: var(--font-jetbrains-mono); + cursor: pointer; + transition: all 0.1s; +} + +.remote-qr-modal__host-btn:hover { + background: var(--color-bg-hover); +} + +.remote-qr-modal__host-btn--active { + background: color-mix(in srgb, var(--color-accent) 12%, transparent); + color: var(--color-accent); + border-color: color-mix(in srgb, var(--color-accent) 30%, transparent); +} + +.remote-qr-modal__hint { + font-size: 11px; + color: var(--color-text-tertiary); + text-align: center; + margin: 0; +} + +.remote-qr-error { + max-width: 240px; + color: var(--color-status-attention); + font-size: 11px; +} + +.remote-qr-modal__credentials { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--color-border-subtle); + border-radius: 6px; + background: var(--color-bg-inset); + color: var(--color-text-secondary); + font-size: 11px; + display: grid; + gap: 8px; +} + +.remote-qr-modal__credential-field { + display: grid; + gap: 4px; +} + +.remote-qr-modal__credential-field span { + color: var(--color-text-tertiary); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; +} + +.remote-qr-modal__input { + width: 100%; + min-width: 0; + border: 1px solid var(--color-border-subtle); + border-radius: 6px; + background: var(--color-bg-surface); + color: var(--color-text-primary); + font-family: var(--font-jetbrains-mono); + font-size: 11px; + padding: 7px 8px; +} + +.remote-qr-modal__save { + border: 1px solid var(--color-border-subtle); + border-radius: 6px; + background: var(--color-bg-surface); + color: var(--color-text-primary); + font-size: 12px; + font-weight: 600; + padding: 8px 10px; + cursor: pointer; +} + +.remote-qr-modal__save:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--color-accent) 40%, var(--color-border-subtle)); +} + +.remote-qr-modal__save:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.remote-qr-modal__disable { + width: 100%; + border: 1px solid var(--color-border-subtle); + border-radius: 6px; + background: transparent; + color: var(--color-text-secondary); + font-size: 12px; + font-weight: 600; + padding: 8px 12px; + cursor: pointer; +} + +.remote-qr-modal__disable:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); +} diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 3b790b182..ac64f2bef 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -28,6 +28,7 @@ import { CopyDebugBundleButton } from "./CopyDebugBundleButton"; import { SidebarContext } from "./workspace/SidebarContext"; import { projectDashboardPath, projectSessionPath } from "@/lib/routes"; import { BottomSheet } from "./BottomSheet"; +import { RemoteAccessQR } from "./RemoteAccessQR"; interface DashboardProps { initialSessions: DashboardSession[]; @@ -579,6 +580,7 @@ function DashboardInner({ {showDebugBundleButton ? : null}
+ {!allProjectsView && orchestratorHref ? ( (null); + const [open, setOpen] = useState(false); + const [selectedHost, setSelectedHost] = useState(0); + const [busy, setBusy] = useState(false); + const [savingCredentials, setSavingCredentials] = useState(false); + const [username, setUsername] = useState("ao"); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + const req = globalThis.fetch?.("/api/remote-info"); + if (!req || typeof (req as PromiseLike).then !== "function") { + return; + } + req + .then((r) => r.json()) + .then((data: RemoteInfo) => { + if (!cancelled) { + setInfo(data); + setUsername(data.username ?? "ao"); + setPassword(data.password ?? ""); + } + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + + const toggle = useCallback(() => setOpen((v) => !v), []); + + const setRemoteEnabled = useCallback(async (enabled: boolean) => { + setBusy(true); + setError(null); + try { + const res = await fetch("/api/remote-info", { method: enabled ? "POST" : "DELETE" }); + const data = (await res.json()) as RemoteInfo; + if (!res.ok) throw new Error(data.error ?? "Remote access update failed"); + setInfo(data); + setUsername(data.username ?? "ao"); + setPassword(data.password ?? ""); + setSelectedHost(0); + if (enabled) setOpen(true); + else setOpen(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Remote access update failed"); + } finally { + setBusy(false); + } + }, []); + + const saveCredentials = useCallback(async () => { + setSavingCredentials(true); + setError(null); + try { + const res = await fetch("/api/remote-info", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + const data = (await res.json()) as RemoteInfo; + if (!res.ok) throw new Error(data.error ?? "Remote credential update failed"); + setInfo(data); + setUsername(data.username ?? "ao"); + setPassword(data.password ?? ""); + } catch (err) { + setError(err instanceof Error ? err.message : "Remote credential update failed"); + } finally { + setSavingCredentials(false); + } + }, [username, password]); + + const enabled = info?.enabled === true; + const hosts = info?.hosts ?? []; + const host = hosts[selectedHost] ?? hosts[0]; + const hasMultiple = hosts.length > 1; + const qrUrl = host ? qrImageUrl(host.url) : null; + + return ( + <> + + {error ? {error} : null} + + {open && enabled ? ( +
+
e.stopPropagation()}> +
+

Scan to access dashboard

+ +
+ +
+ {qrUrl && host ? ( + {`QR + ) : null} + +

{host?.url}

+ + {hasMultiple ? ( +
+ {hosts.map((h, i) => ( + + ))} +
+ ) : null} + +
+ + + +
+ + +
+
+
+ ) : null} + + ); +} diff --git a/packages/web/src/components/SessionDetailHeader.tsx b/packages/web/src/components/SessionDetailHeader.tsx index f6090efcd..d1ed0f566 100644 --- a/packages/web/src/components/SessionDetailHeader.tsx +++ b/packages/web/src/components/SessionDetailHeader.tsx @@ -12,6 +12,7 @@ import type { ProjectInfo } from "@/lib/project-name"; import { SessionDetailPRCard } from "./SessionDetailPRCard"; import { askAgentToFix } from "./session-detail-agent-actions"; import { buildGitHubBranchUrl } from "./session-detail-utils"; +import { RemoteAccessQR } from "./RemoteAccessQR"; export interface OrchestratorZones { merge: number; @@ -205,6 +206,7 @@ export function SessionDetailHeader({
+ {pr ? (
{ .mocked(fetch) .mock.calls.filter(([input]) => { const url = typeof input === "string" ? input : (input as URL).toString(); - return !url.includes("/api/version"); + return !url.includes("/api/version") && !url.includes("/api/remote-info"); }); expect(otherCalls).toHaveLength(0); }); diff --git a/packages/web/src/lib/remote-access-manager.ts b/packages/web/src/lib/remote-access-manager.ts new file mode 100644 index 000000000..fa1013066 --- /dev/null +++ b/packages/web/src/lib/remote-access-manager.ts @@ -0,0 +1,268 @@ +import "server-only"; + +import { type ChildProcess } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { basename, dirname, resolve } from "node:path"; +import { + isLinux, + isMac, + isWindows, + createDefaultGlobalConfig, + getGlobalConfigPath, + killProcessTree, + loadGlobalConfig, + saveGlobalConfig, + spawnManagedDaemonChild, + type GlobalConfig, +} from "@aoagents/ao-core"; + +interface RemoteHost { + url: string; + ip: string; + port: number; +} + +export interface RemoteAccessInfo { + enabled: boolean; + authRequired: boolean; + username: string; + password?: string; + hosts: RemoteHost[]; +} + +let tunnelProcess: ChildProcess | null = null; + +type RemoteAccessConfig = { + username?: string; + password?: string; +}; + +function loadGlobalConfigOrDefault(): GlobalConfig { + return loadGlobalConfig(getGlobalConfigPath()) ?? createDefaultGlobalConfig(); +} + +function remoteAccessConfig(config: GlobalConfig): RemoteAccessConfig { + return config.remoteAccess && typeof config.remoteAccess === "object" ? config.remoteAccess : {}; +} + +function configuredUsername(config = loadGlobalConfigOrDefault()): string { + return remoteAccessConfig(config).username?.trim() || process.env["AO_REMOTE_AUTH_USER"] || "ao"; +} + +function configuredPassword(config = loadGlobalConfigOrDefault()): string | undefined { + const password = remoteAccessConfig(config).password?.trim() || process.env["AO_REMOTE_AUTH_PASSWORD"]; + return password && password.length > 0 ? password : undefined; +} + +function applyRemoteCredentials(username: string, password: string): void { + process.env["AO_REMOTE_AUTH_USER"] = username; + process.env["AO_REMOTE_AUTH_PASSWORD"] = password; +} + +function generatePassword(): string { + return randomBytes(18).toString("base64url"); +} + +function tryCloudflareUrl(line: string): string | null { + return line.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/)?.[0] ?? null; +} + +function getCloudflaredCachePath(): string { + return resolve(homedir(), ".agent-orchestrator", "bin", isWindows() ? "cloudflared.exe" : "cloudflared"); +} + +function getCloudflaredDownload(): { url: string; archive: boolean } { + const arch = process.arch; + const base = "https://github.com/cloudflare/cloudflared/releases/latest/download"; + + if (isMac()) { + if (arch === "arm64") return { url: `${base}/cloudflared-darwin-arm64.tgz`, archive: true }; + if (arch === "x64") return { url: `${base}/cloudflared-darwin-amd64.tgz`, archive: true }; + } + if (isLinux()) { + if (arch === "arm64") return { url: `${base}/cloudflared-linux-arm64`, archive: false }; + if (arch === "x64") return { url: `${base}/cloudflared-linux-amd64`, archive: false }; + if (arch === "arm") return { url: `${base}/cloudflared-linux-arm`, archive: false }; + if (arch === "ia32") return { url: `${base}/cloudflared-linux-386`, archive: false }; + } + if (isWindows()) { + if (arch === "ia32") return { url: `${base}/cloudflared-windows-386.exe`, archive: false }; + if (arch === "x64" || arch === "arm64") { + return { url: `${base}/cloudflared-windows-amd64.exe`, archive: false }; + } + } + + throw new Error(`Unsupported platform for automatic remote access: ${process.platform}/${arch}`); +} + +async function downloadCloudflaredBinary(targetPath: string): Promise { + const { url, archive } = getCloudflaredDownload(); + const targetDir = dirname(targetPath); + mkdirSync(targetDir, { recursive: true }); + + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to download cloudflared (${response.status})`); + + const tempDir = mkdtempSync(resolve(tmpdir(), "ao-cloudflared-")); + try { + if (archive) { + const archivePath = resolve(tempDir, "cloudflared.tgz"); + writeFileSync(archivePath, Buffer.from(await response.arrayBuffer())); + const { execFileSync } = await import("node:child_process"); + execFileSync("tar", ["-xzf", archivePath, "-C", tempDir]); + const extractedPath = resolve(tempDir, "cloudflared"); + if (!existsSync(extractedPath)) throw new Error("cloudflared archive did not contain a binary"); + chmodSync(extractedPath, 0o755); + renameSync(extractedPath, targetPath); + return; + } + + const tempPath = resolve(tempDir, basename(targetPath)); + writeFileSync(tempPath, Buffer.from(await response.arrayBuffer())); + if (!isWindows()) chmodSync(tempPath, 0o755); + renameSync(tempPath, targetPath); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +async function resolveCloudflaredBinary(): Promise { + const cachedPath = getCloudflaredCachePath(); + if (!existsSync(cachedPath)) await downloadCloudflaredBinary(cachedPath); + return cachedPath; +} + +function hostsFromEnv(): RemoteHost[] { + const publicUrl = process.env["AO_REMOTE_PUBLIC_URL"]; + if (publicUrl) { + return [{ url: publicUrl, ip: new URL(publicUrl).hostname, port: 443 }]; + } + return []; +} + +export function getRemoteAccessInfo(): RemoteAccessInfo { + const hosts = hostsFromEnv(); + const username = configuredUsername(); + const password = configuredPassword(); + return { + enabled: hosts.length > 0, + authRequired: hosts.length > 0 && Boolean(password), + username, + password, + hosts, + }; +} + +export function saveRemoteAccessCredentials(input: { + username?: unknown; + password?: unknown; +}): RemoteAccessInfo { + const username = typeof input.username === "string" ? input.username.trim() : ""; + const password = typeof input.password === "string" ? input.password.trim() : ""; + if (!username) throw new Error("Remote username is required."); + if (!password) throw new Error("Remote password is required."); + + const config = loadGlobalConfigOrDefault(); + const nextConfig: GlobalConfig = { + ...config, + remoteAccess: { + ...remoteAccessConfig(config), + username, + password, + }, + }; + saveGlobalConfig(nextConfig, getGlobalConfigPath()); + applyRemoteCredentials(username, password); + return getRemoteAccessInfo(); +} + +async function startCloudflareTunnel(port: string): Promise<{ publicUrl: string; child: ChildProcess }> { + const cloudflared = await resolveCloudflaredBinary(); + const child = spawnManagedDaemonChild( + "remote-tunnel", + cloudflared, + ["tunnel", "--url", `http://127.0.0.1:${port}`, "--no-autoupdate"], + { stdio: ["ignore", "pipe", "pipe"], detached: !isWindows() }, + ); + + return await new Promise((resolvePromise, reject) => { + let settled = false; + let recentOutput = ""; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + child.kill(); + reject(new Error(`Timed out waiting for cloudflared.${recentOutput ? ` Last output: ${recentOutput.trim()}` : ""}`)); + }, 30_000); + + function settle(publicUrl: string) { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolvePromise({ publicUrl, child }); + } + + function append(data: Buffer) { + const text = data.toString(); + recentOutput = `${recentOutput}${text}`.slice(-1000); + const publicUrl = tryCloudflareUrl(text); + if (publicUrl) settle(publicUrl); + } + + child.stdout?.on("data", append); + child.stderr?.on("data", append); + child.once("error", (err) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + reject(err); + }); + child.once("exit", (code) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + reject(new Error(`cloudflared exited before creating a tunnel${code === null ? "" : ` (${code})`}`)); + }); + }); +} + +export async function enableRemoteAccess(): Promise { + const existing = getRemoteAccessInfo(); + if (existing.enabled) { + return { ...existing, password: process.env["AO_REMOTE_AUTH_PASSWORD"] }; + } + + const config = loadGlobalConfigOrDefault(); + const password = configuredPassword(config) || generatePassword(); + applyRemoteCredentials(configuredUsername(config), password); + + const tunnel = await startCloudflareTunnel(process.env["PORT"] || "3000"); + tunnelProcess = tunnel.child; + process.env["AO_REMOTE_PUBLIC_URL"] = tunnel.publicUrl; + process.env["AO_REMOTE_TUNNEL_PID"] = tunnel.child.pid ? String(tunnel.child.pid) : ""; + + return { ...getRemoteAccessInfo(), password }; +} + +export async function disableRemoteAccess(): Promise { + const pid = Number.parseInt(process.env["AO_REMOTE_TUNNEL_PID"] ?? "", 10); + if (tunnelProcess?.pid) { + await killProcessTree(tunnelProcess.pid, "SIGTERM"); + } else if (Number.isInteger(pid) && pid > 0) { + await killProcessTree(pid, "SIGTERM"); + } + tunnelProcess = null; + delete process.env["AO_REMOTE_PUBLIC_URL"]; + delete process.env["AO_REMOTE_TUNNEL_PID"]; + return getRemoteAccessInfo(); +} diff --git a/packages/web/src/middleware.test.ts b/packages/web/src/middleware.test.ts new file mode 100644 index 000000000..3215c9b04 --- /dev/null +++ b/packages/web/src/middleware.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { middleware } from "./middleware"; + +function request(path: string, authorization?: string): NextRequest { + return new NextRequest(`http://localhost:3000${path}`, { + headers: authorization ? { authorization } : undefined, + }); +} + +function basic(username: string, password: string): string { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; +} + +describe("remote auth middleware", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("does nothing when remote auth is not configured", () => { + vi.stubEnv("AO_REMOTE_AUTH_PASSWORD", ""); + + const response = middleware(request("/api/projects")); + + expect(response.status).toBe(200); + }); + + it("requires basic auth when a remote password is configured", () => { + vi.stubEnv("AO_REMOTE_AUTH_USER", "ao"); + vi.stubEnv("AO_REMOTE_AUTH_PASSWORD", "secret"); + + const response = middleware(request("/api/projects")); + + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toBe('Basic realm="AO Remote"'); + }); + + it("accepts matching basic auth credentials", () => { + vi.stubEnv("AO_REMOTE_AUTH_USER", "ao"); + vi.stubEnv("AO_REMOTE_AUTH_PASSWORD", "secret"); + + const response = middleware(request("/api/projects", basic("ao", "secret"))); + + expect(response.status).toBe(200); + }); + + it("allows public Next assets without credentials", () => { + vi.stubEnv("AO_REMOTE_AUTH_USER", "ao"); + vi.stubEnv("AO_REMOTE_AUTH_PASSWORD", "secret"); + + const response = middleware(request("/_next/static/chunk.js")); + + expect(response.status).toBe(200); + }); +}); diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts new file mode 100644 index 000000000..c752d77ca --- /dev/null +++ b/packages/web/src/middleware.ts @@ -0,0 +1,80 @@ +import { NextResponse, type NextRequest } from "next/server"; + +const REALM = "AO Remote"; + +function remoteAuthPassword(): string | undefined { + const password = process.env["AO_REMOTE_AUTH_PASSWORD"]; + return password && password.length > 0 ? password : undefined; +} + +function remoteAuthUser(): string { + return process.env["AO_REMOTE_AUTH_USER"] || "ao"; +} + +function unauthorized(): NextResponse { + return new NextResponse("Authentication required", { + status: 401, + headers: { + "WWW-Authenticate": `Basic realm="${REALM}"`, + }, + }); +} + +function decodeBasicAuth(header: string | null): { username: string; password: string } | null { + const match = /^Basic\s+(.+)$/i.exec(header ?? ""); + if (!match) return null; + + try { + const decoded = atob(match[1]); + const separator = decoded.indexOf(":"); + if (separator === -1) return null; + return { + username: decoded.slice(0, separator), + password: decoded.slice(separator + 1), + }; + } catch { + return null; + } +} + +function isPublicAsset(pathname: string): boolean { + return ( + pathname.startsWith("/_next/") || + pathname === "/favicon.ico" || + pathname === "/manifest.webmanifest" || + pathname === "/icon" || + pathname === "/apple-icon" || + pathname === "/icon-192" || + pathname === "/icon-512" + ); +} + +function isLocalHost(host: string | null): boolean { + const rawHost = host ?? ""; + const bracketEnd = rawHost.indexOf("]"); + const hostname = (rawHost.startsWith("[") && bracketEnd !== -1 + ? rawHost.slice(0, bracketEnd + 1) + : rawHost.split(":")[0] + )?.toLowerCase(); + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]"; +} + +export function middleware(request: NextRequest) { + const password = remoteAuthPassword(); + if (!password || isPublicAsset(request.nextUrl.pathname) || isLocalHost(request.headers.get("host"))) { + return NextResponse.next(); + } + + const credentials = decodeBasicAuth(request.headers.get("authorization")); + if (!credentials) return unauthorized(); + + if (credentials.username !== remoteAuthUser() || credentials.password !== password) { + return unauthorized(); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/:path*"], +}; diff --git a/packages/web/src/providers/MuxProvider.tsx b/packages/web/src/providers/MuxProvider.tsx index 26e33171e..59fc99a3d 100644 --- a/packages/web/src/providers/MuxProvider.tsx +++ b/packages/web/src/providers/MuxProvider.tsx @@ -37,6 +37,7 @@ export function useMuxOptional(): MuxContextValue | undefined { interface RuntimeTerminalConfig { directTerminalPort?: unknown; proxyWsPath?: unknown; + remoteWsToken?: unknown; } function normalizePortValue(value: unknown): string | undefined { @@ -56,26 +57,30 @@ function normalizePathValue(value: unknown): string | undefined { function buildMuxWsUrl(runtimeConfig: { directTerminalPort?: string; proxyWsPath?: string; + remoteWsToken?: string; }): string { const loc = window.location; const protocol = loc.protocol === "https:" ? "wss:" : "ws:"; + const authQuery = runtimeConfig.remoteWsToken + ? `?auth_token=${encodeURIComponent(runtimeConfig.remoteWsToken)}` + : ""; // Runtime proxy path takes priority (set by `ao start` via TERMINAL_WS_PATH env var) const proxyWsPath = runtimeConfig.proxyWsPath ?? process.env.NEXT_PUBLIC_TERMINAL_WS_PATH; if (proxyWsPath) { const basePath = proxyWsPath.replace(/\/ws\/?$/, ""); - return `${protocol}//${loc.host}${basePath}/mux`; + return `${protocol}//${loc.host}${basePath}/mux${authQuery}`; } // Port-less or standard ports: use path-based routing (reverse proxy expected) if (loc.port === "" || loc.port === "443" || loc.port === "80") { - return `${protocol}//${loc.hostname}/ao-terminal-mux`; + return `${protocol}//${loc.hostname}/ao-terminal-mux${authQuery}`; } // Direct port connection — prefer runtime-configured port, fall back to env/default const port = runtimeConfig.directTerminalPort ?? process.env.NEXT_PUBLIC_DIRECT_TERMINAL_PORT ?? "14801"; - return `${protocol}//${loc.hostname}:${port}/mux`; + return `${protocol}//${loc.hostname}:${port}/mux${authQuery}`; } function terminalKey(id: string, projectId?: string): string { @@ -95,7 +100,11 @@ export function MuxProvider({ children }: { children: ReactNode }) { const [lastError, setLastError] = useState(null); const reconnectAttempt = useRef(0); const reconnectTimer = useRef | null>(null); - const runtimeConfigRef = useRef<{ directTerminalPort?: string; proxyWsPath?: string }>({}); + const runtimeConfigRef = useRef<{ + directTerminalPort?: string; + proxyWsPath?: string; + remoteWsToken?: string; + }>({}); const isDestroyedRef = useRef(false); const connect = useCallback(() => { @@ -107,7 +116,9 @@ export function MuxProvider({ children }: { children: ReactNode }) { try { const url = buildMuxWsUrl(runtimeConfigRef.current); - console.log("[MuxProvider] Connecting to", url); + const safeUrl = new URL(url); + safeUrl.search = ""; + console.log("[MuxProvider] Connecting to", safeUrl.toString()); const ws = new WebSocket(url); // Assign immediately so cleanup can close it even during CONNECTING state wsRef.current = ws; @@ -234,6 +245,8 @@ export function MuxProvider({ children }: { children: ReactNode }) { runtimeConfigRef.current = { directTerminalPort: normalizePortValue(data.directTerminalPort), proxyWsPath: normalizePathValue(data.proxyWsPath), + remoteWsToken: + typeof data.remoteWsToken === "string" ? data.remoteWsToken : undefined, }; } } catch { diff --git a/packages/web/src/providers/__tests__/MuxProvider.test.tsx b/packages/web/src/providers/__tests__/MuxProvider.test.tsx index 878c2aed5..988760222 100644 --- a/packages/web/src/providers/__tests__/MuxProvider.test.tsx +++ b/packages/web/src/providers/__tests__/MuxProvider.test.tsx @@ -751,4 +751,30 @@ describe("buildMuxWsUrl", () => { await flushInit(); expect(MockWebSocket.instances[0].url).toMatch(/\/ao-terminal-mux$/); }); + + it("appends opaque auth token without logging the query string", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ directTerminalPort: 14802, remoteWsToken: "opaque-token" }), + })), + ); + Object.defineProperty(window, "location", { + writable: true, + value: { protocol: "https:", host: "remote.example", hostname: "remote.example", port: "" }, + }); + + renderHook(() => useMux(), { wrapper }); + await flushInit(); + + expect(MockWebSocket.instances[0].url).toBe( + "wss://remote.example/ao-terminal-mux?auth_token=opaque-token", + ); + expect(logSpy).toHaveBeenCalledWith( + "[MuxProvider] Connecting to", + "wss://remote.example/ao-terminal-mux", + ); + }); });