Harden remote terminal auth and dev proxy

This commit is contained in:
Ashish Huddar 2026-05-17 16:56:50 +05:30
parent 3fb23cfb48
commit e87428cafa
34 changed files with 1613 additions and 620 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

@ -1 +0,0 @@
Subproject commit 2abb1c1e3dd804da34baf8262fa44c6766421032

84
docs/mobile-access-v1.md Normal file
View File

@ -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.

View File

@ -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 <project>`, `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 <project>` | Same as above (no scoping). | Surgical: kills only `<project>`'s sessions. **Does not** kill parent process or dashboard (commit `95cf979d` — this was a real bug). Falls back to global config if `<project>` isn't in the local config (`2db2951a`). Calls `removeProjectFromRunning(projectId)` to remove the project from `running.json` so that a subsequent `ao start <project>` 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 <project>` is called and the project was removed from `running.json` by a prior `ao stop <project>`, 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 <project>` (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."

View File

@ -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 <commit-from-simulate>
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

View File

@ -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 <project>` after `ao stop <project>` 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 <project>` (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 <project>` calls `removeProjectFromRunning(projectId)` — removing the project from `running.json` so that `ao start <project>` 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 <projectB>` 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.

@ -1 +0,0 @@
Subproject commit 351d354b4b3b3e45f38e29897af8acec9966fd41

View File

@ -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<ChildProcess> {
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<number> {
opts?: {
dashboard?: boolean;
orchestrator?: boolean;
rebuild?: boolean;
dev?: boolean;
},
): Promise<StartupResult> {
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

View File

@ -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;
}

View File

@ -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",

View File

@ -279,6 +279,7 @@ export {
killProcessTree,
findPidByPort,
getEnvDefaults,
getLocalNetworkIPs,
} from "./platform.js";
export { normalizeOriginUrl, relativeSubdir, deriveStorageKey } from "./storage-key.js";

View File

@ -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);
}

View File

@ -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",

View File

@ -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<WebSocket> {
function connectMux(path = "/mux"): Promise<WebSocket> {
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<WebSocket> =>
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`);

View File

@ -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"));

View File

@ -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<void>;
getRequestHandler: () => (req: IncomingMessage, res: ServerResponse) => Promise<void> | 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<void> {
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);
});

View File

@ -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`);

View File

@ -64,9 +64,14 @@ export class SessionBroadcaster {
private intervalId: ReturnType<typeof setInterval> | 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) {

View File

@ -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;
}

View File

@ -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<void>;
getRequestHandler: () => (req: IncomingMessage, res: ServerResponse) => Promise<void> | 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<void> {
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");

View File

@ -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;
}

View File

@ -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");

View File

@ -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<string, unknown> | 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 },
);
}
}

View File

@ -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" } },
);
}

View File

@ -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);
}

View File

@ -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 ? <CopyDebugBundleButton projectId={projectId} /> : null}
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
<RemoteAccessQR />
{!allProjectsView && orchestratorHref ? (
<Link
href={orchestratorHref}

View File

@ -0,0 +1,226 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import Image from "next/image";
interface RemoteHost {
url: string;
ip: string;
port: number;
}
interface RemoteInfo {
enabled: boolean;
authRequired?: boolean;
username?: string;
password?: string;
error?: string;
hosts: RemoteHost[];
}
function qrImageUrl(data: string, size = 200): string {
return `https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${encodeURIComponent(data)}&margin=10`;
}
export function RemoteAccessQR() {
const [info, setInfo] = useState<RemoteInfo | null>(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<string | null>(null);
useEffect(() => {
let cancelled = false;
const req = globalThis.fetch?.("/api/remote-info");
if (!req || typeof (req as PromiseLike<unknown>).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 (
<>
<button
type="button"
className="dashboard-app-btn topbar-desktop-only"
onClick={() => {
if (enabled) toggle();
else void setRemoteEnabled(true);
}}
aria-label={enabled ? "Show QR code for remote access" : "Enable remote access"}
title={enabled ? "Remote access" : "Enable remote access"}
disabled={busy}
>
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="3" height="3" rx="0.5" />
<rect x="18" y="18" width="3" height="3" rx="0.5" />
<rect x="18" y="14" width="3" height="3" rx="0.5" />
<rect x="14" y="18" width="3" height="3" rx="0.5" />
</svg>
<span className="hidden sm:inline">{busy ? "Remote..." : "Remote"}</span>
</button>
{error ? <span className="remote-qr-error">{error}</span> : null}
{open && enabled ? (
<div className="remote-qr-overlay" onClick={toggle}>
<div className="remote-qr-modal" onClick={(e) => e.stopPropagation()}>
<div className="remote-qr-modal__header">
<h2 className="remote-qr-modal__title">Scan to access dashboard</h2>
<button
type="button"
className="remote-qr-modal__close"
onClick={toggle}
aria-label="Close"
>
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className="remote-qr-modal__body">
{qrUrl && host ? (
<Image
src={qrUrl}
alt={`QR code for ${host.url}`}
className="remote-qr-modal__qr"
width={200}
height={200}
unoptimized
/>
) : null}
<p className="remote-qr-modal__url">{host?.url}</p>
{hasMultiple ? (
<div className="remote-qr-modal__hosts">
{hosts.map((h, i) => (
<button
key={h.ip}
type="button"
className={`remote-qr-modal__host-btn${i === selectedHost ? " remote-qr-modal__host-btn--active" : ""}`}
onClick={() => setSelectedHost(i)}
>
{h.ip}
</button>
))}
</div>
) : null}
<div className="remote-qr-modal__credentials">
<label className="remote-qr-modal__credential-field">
<span>Username</span>
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
className="remote-qr-modal__input"
/>
</label>
<label className="remote-qr-modal__credential-field">
<span>Password</span>
<input
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
className="remote-qr-modal__input"
/>
</label>
<button
type="button"
className="remote-qr-modal__save"
onClick={() => void saveCredentials()}
disabled={savingCredentials || !username.trim() || !password.trim()}
>
{savingCredentials ? "Saving..." : "Save credentials"}
</button>
</div>
<button
type="button"
className="remote-qr-modal__disable"
onClick={() => void setRemoteEnabled(false)}
disabled={busy}
>
Disable remote access
</button>
</div>
</div>
</div>
) : null}
</>
);
}

View File

@ -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({
</span>
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
<RemoteAccessQR />
{pr ? (
<div className="topbar-pr-btn-wrap" ref={prPopoverRef}>
<a

View File

@ -109,7 +109,7 @@ describe("Dashboard render cadence", () => {
.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);
});

View File

@ -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<void> {
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<string> {
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<RemoteAccessInfo> {
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<RemoteAccessInfo> {
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();
}

View File

@ -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);
});
});

View File

@ -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*"],
};

View File

@ -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<string | null>(null);
const reconnectAttempt = useRef(0);
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | 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 {

View File

@ -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",
);
});
});