Harden remote terminal auth and dev proxy
This commit is contained in:
parent
3fb23cfb48
commit
e87428cafa
Binary file not shown.
|
Before Width: | Height: | Size: 208 KiB |
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 2abb1c1e3dd804da34baf8262fa44c6766421032
|
||||
|
|
@ -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.
|
||||
|
|
@ -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."
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -279,6 +279,7 @@ export {
|
|||
killProcessTree,
|
||||
findPidByPort,
|
||||
getEnvDefaults,
|
||||
getLocalNetworkIPs,
|
||||
} from "./platform.js";
|
||||
|
||||
export { normalizeOriginUrl, relativeSubdir, deriveStorageKey } from "./storage-key.js";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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" } },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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*"],
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue