Compare commits
39 Commits
pre-qwen-s
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
e5dd64a1f7 | |
|
|
0882ef8038 | |
|
|
2c627ea095 | |
|
|
df735aa279 | |
|
|
964226847b | |
|
|
a7c7b8f3d7 | |
|
|
0604fd7c03 | |
|
|
8eb8360387 | |
|
|
547df01c21 | |
|
|
2e44a7f286 | |
|
|
4b428df0a4 | |
|
|
8ff635e6f5 | |
|
|
fcfe729d23 | |
|
|
b64aaab40a | |
|
|
38cad794e2 | |
|
|
920f6d8fc3 | |
|
|
571cf8c9ec | |
|
|
1f36636e06 | |
|
|
91b1ad1a44 | |
|
|
911d3f7625 | |
|
|
b94b668df6 | |
|
|
db6be6dcf8 | |
|
|
f89abd1125 | |
|
|
6f106d2411 | |
|
|
17c32e7e93 | |
|
|
bd990a07ab | |
|
|
4f6fa0e83b | |
|
|
d74341af2a | |
|
|
af990f6592 | |
|
|
a7dad86901 | |
|
|
174fa7fb31 | |
|
|
3e813eb90a | |
|
|
568931901d | |
|
|
e396cfcaaa | |
|
|
ba23050eda | |
|
|
460c5fac7a | |
|
|
07522e5974 | |
|
|
c9bdfce890 | |
|
|
7c40c49b1a |
44
README.md
44
README.md
|
|
@ -90,6 +90,50 @@ All planning and coordination lives in [`docs/`](./docs/):
|
|||
|
||||
---
|
||||
|
||||
## Running pi-remote as a sidecar (Phase 1)
|
||||
|
||||
All Phase 1 sidecar modules are implemented. See the full **[Operator Guide](docs/reference/OPERATOR.md)** for details.
|
||||
|
||||
### Quick start
|
||||
|
||||
```bash
|
||||
# Start pi with the sidecar auto-enabled
|
||||
pi -e extensions/remote-control --remote-control
|
||||
# → Remote-control started: http://127.0.0.1:7777/?token=<TOKEN>
|
||||
|
||||
# Create a tmux session
|
||||
curl -s "http://127.0.0.1:7777/sessions?token=<TOKEN>" \
|
||||
-X POST -H 'Content-Type: application/json' -d '{"name":"work"}'
|
||||
|
||||
# Stream output via wscat
|
||||
npm install -g wscat
|
||||
wscat -c "ws://127.0.0.1:7777/sessions/work/stream?token=<TOKEN>"
|
||||
# → send: {"type":"resume","lastSeq":null}
|
||||
|
||||
# Send a keystroke
|
||||
curl -s "http://127.0.0.1:7777/sessions/work/input?token=<TOKEN>" \
|
||||
-X POST -H 'Content-Type: application/json' -d '{"type":"keys","data":"ls"}'
|
||||
|
||||
# Pair the iOS app (generates QR code)
|
||||
node extensions/remote-control/cli/index.js pair
|
||||
```
|
||||
|
||||
### What's implemented
|
||||
|
||||
| Module | Files |
|
||||
|---|---|
|
||||
| Server scaffold | `server/server.ts`, `server/upgrade.ts`, `server/types.ts` |
|
||||
| tmux control-mode | `tmux/manager.ts`, `tmux/control.ts`, `tmux/input.ts`, `tmux/snapshot.ts` |
|
||||
| Disk ring-buffer | `buffer/writer.ts`, `buffer/reader.ts`, `sequence.ts` |
|
||||
| Auth + pairing + TLS | `auth/tokens.ts`, `auth/pairing.ts`, `auth/tls.ts` |
|
||||
| pi adapter | `pi/events.ts`, `pi/commands.ts`, `pi/autoname.ts` |
|
||||
| REST routes | `server/routes/{sessions,commands,health}.ts` |
|
||||
| WS routes | `server/routes/{stream,side}.ts`, `server/upgrade.ts` |
|
||||
| APNs scaffold | `apns/push.ts` |
|
||||
| CLI | `cli/index.ts` (`pair`, `auth list/create/revoke/name`) |
|
||||
|
||||
---
|
||||
|
||||
## Security notes
|
||||
|
||||
- The server only listens on localhost. Remote access depends on your tunnel.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Next Steps — Resume Pointer
|
||||
|
||||
> **Last updated:** 2026-05-15, end of day.
|
||||
> **Where we are:** Phase 0 + 0.5 done. Phase 1 doc updated for Path B.
|
||||
> **Where we go next:** Freeze interface contracts, then T-1.0.
|
||||
> **Last updated:** 2026-05-17.
|
||||
> **Where we are:** Phase 2 mostly done. T-2.0..T-2.6, T-2.8, T-2.9, T-2.10, T-2.11 on main. App runs end-to-end on iPhone 12 mini + simulator. 130 unit tests (8 pre-existing failures), 12/12 UI tests green.
|
||||
> **Where we go next:** T-2.7 PreConnectPool, T-2.12 TestFlight, T-2.13 MVP smoke. Plus: fix 8 pre-existing unit-test failures (4 Keychain entitlements + 4 Pairing http-scheme).
|
||||
|
||||
This document is the "where did I leave off" anchor. Read this first when
|
||||
resuming work. The rest of `docs/` is reference.
|
||||
|
|
@ -14,87 +14,64 @@ resuming work. The rest of `docs/` is reference.
|
|||
| Item | Status |
|
||||
|---|---|
|
||||
| Phase 0 — Stream Spike | ✅ done. Verdict GREEN with caveat (pipe-pane unreliable). Branch `feat/spike-stream` kept. |
|
||||
| Phase 0.5 — Control-Mode Spike | ✅ done. Verdict: **Path B — tmux control mode**. Branch `feat/spike-tmux-cc` kept. |
|
||||
| Phase 1 plan | ✅ updated to Path B. T-1.1 now specifies control mode + `%output` parser. Architecture diagram, risks (R4 + R5) added. |
|
||||
| Interface Contracts (IC-1..IC-4) | ⚠️ **draft**. Need orchestrator sign-off before T-1.5 / fan-out. |
|
||||
| Phase 1 implementation | ⛔ not started. T-1.0 is the next dispatch. |
|
||||
| iOS work | blocked, untouched. |
|
||||
| Phase 0.5 — Control-Mode Spike | ✅ done. Verdict: **Path B — tmux control mode**. |
|
||||
| Phase 1 plan | ✅ updated to Path B. |
|
||||
| Interface Contracts (IC-1..IC-4) | ✅ **frozen** 2026-05-15. |
|
||||
| Phase 1 implementation | ✅ **done** 2026-05-15. All T-1.0..T-1.10 on main. Smoke 12/12. |
|
||||
| Phase 1 — follow-up fixes | ✅ POST /pair smoke test, WS keys/key/paste handler, POST /sessions response shape. |
|
||||
| Phase 2 iOS — T-2.0 scaffold | ✅ done. |
|
||||
| Phase 2 iOS — T-2.1..T-2.5, T-2.9 core | ✅ done. WebSocket, Pairing, Terminal, ModifierBar, SessionConnection, APNs. |
|
||||
| Phase 2 iOS — T-2.6 SessionSwitcher | ✅ **done** 2026-05-16. SessionRegistry + SessionSwitcher + SessionRow. |
|
||||
| Phase 2 iOS — T-2.7 PreConnectPool | ⛔ not started. |
|
||||
| Phase 2 iOS — T-2.8 StatusBar | ✅ **done** 2026-05-16. State indicator, action buttons. |
|
||||
| Phase 2 iOS — T-2.10 Background lifecycle | ✅ **done** 2026-05-17. AppState.lifecycleTransitions publisher, SessionConnection suspend/resume w/ ResumeCursor, stale-frame freeze, post-Face-ID reconnect. 22 lifecycle tests + 6 follow-up. TDD pattern: tests → impl → review → fixup (B-1 + 3 nits + 4 coverage gaps). |
|
||||
| Phase 2 iOS — T-2.11 Face-ID + Settings | ✅ **done** 2026-05-16. SettingsView, FaceIDGate, LockView. |
|
||||
| Phase 2 iOS — T-2.12 TestFlight | ⛔ not started. Needs Apple credentials. |
|
||||
| Phase 2 iOS — T-2.13 MVP smoke | ⛔ not started. |
|
||||
| Phase 2 iOS — XCUITest infrastructure (bonus) | ✅ **done** 2026-05-16. piRemoteUITests target, 8/8 tests, `--uitest` mode, accessibility IDs, idb workflow documented in `docs/SIMULATOR-AUTOMATION.md` (iOS repo). |
|
||||
| Bug Backlog | ⚠️ 8 pre-existing unit-test failures (4 Keychain `-34018` entitlements + 4 Pairing http-scheme tests). Not regressions, undocumented. |
|
||||
|
||||
Branches on remote `git.vpsj.de/jay/pi-remote-control`:
|
||||
- `main` — has all docs, no implementation changes yet.
|
||||
- `main` — all Phase 1 + follow-up fixes (latest: POST /sessions shape).
|
||||
- `feat/spike-stream` — Phase 0 PoC, throwaway.
|
||||
- `feat/spike-tmux-cc` — Phase 0.5 PoC, throwaway. Reference impl for T-1.1.
|
||||
|
||||
Branches on remote `git.vpsj.de/jay/pi-remote-ios`:
|
||||
- `main` — T-2.0..T-2.6, T-2.8, T-2.9, T-2.11 + UI test target + idb docs.
|
||||
- legacy `feat/p2-t2.*` branches kept for history; all already on main via merge commits.
|
||||
|
||||
---
|
||||
|
||||
## Orchestrator todo list (in order)
|
||||
## Open work (in priority order)
|
||||
|
||||
These are things only the orchestrator does — not delegatable to a worker
|
||||
subagent.
|
||||
### Phase 2 remaining
|
||||
|
||||
### 1. Freeze interface contracts
|
||||
- **T-2.7 PreConnectPool.** Hot WS per known session + cached last frame;
|
||||
switching shows the cached frame instantly with a "syncing…" pill.
|
||||
Touches: `Sources/Core/Sessions/PreConnectPool.swift` (new),
|
||||
`Sources/Core/Sessions/SessionConnection.swift`. Now safe to schedule
|
||||
after T-2.10's lifecycle hooks landed.
|
||||
- **T-2.12 TestFlight pipeline.** Build / archive / upload scripts +
|
||||
internal testers list. Verify production APNs path. Needs Apple
|
||||
Developer credentials (paid program). Touches: `docs/DISTRIBUTION.md`,
|
||||
`scripts/` or Fastfile.
|
||||
- **T-2.13 MVP smoke.** Manual on-device checklist: pair → render → input
|
||||
→ backgrounded → push → reopen <1s → session-switch. Documents in
|
||||
`docs/PHASE-2-report.md` (iOS repo).
|
||||
|
||||
Open `PHASE-1-sidecar.md` §Interface Contracts and re-read IC-1..IC-4.
|
||||
For each: does it still look right after the spikes? Anything to tweak?
|
||||
### Bug backlog (not regressions, pre-existing)
|
||||
|
||||
Things to double-check:
|
||||
- **IC-1 ClientToServer**: do we want to add `{ type:"resize"; cols:int; rows:int }` so iPad-Landscape can renegotiate tmux pane size? (Spec said fixed 120×40, but if we ever want elastic, this is the place.) Recommendation: defer, fix at 120×40 for v1, revisit only if it bites.
|
||||
- **IC-1 ServerToClient**: `tree` event — gruppe T is out of iOS scope, so this can be dropped from the contract OR kept as "reserved, server may emit but client ignores". Recommendation: drop to keep contract tight.
|
||||
- **IC-2 REST shape**: `/sessions/:id/thumbnail` is referenced by iOS-D-01c (Phase 3). Should the endpoint return raw text/plain capture, or a structured JSON `{cols,rows,lines:[…]}`? Recommendation: raw text/plain — simpler, smaller, client parses lines itself.
|
||||
- **IC-3 pairing**: `deviceToken` and `environment` are now mandatory in Phase 2. Pre-Phase-2 they're optional. Mark accordingly.
|
||||
- **IC-4 TOML config**: `[apns]` section can have an `environment_default` for testing convenience? Probably no — environment is per-device. Leave as-is.
|
||||
- **Keychain unit tests fail with `-34018` errSecMissingEntitlement** (4 cases).
|
||||
Likely needs Keychain-Access-Group in the test target's entitlements,
|
||||
or running under signed bundle. Investigate or skip with a documented reason.
|
||||
- **Pairing unit tests `testParseQR_{http,https,wrongScheme}_throws` fail**
|
||||
(4 cases). Either the parser silently accepts http/https now (regression
|
||||
hidden by the `fp` optional change) or the tests need updating to match
|
||||
the new lenient behaviour.
|
||||
|
||||
After review:
|
||||
- Edit `SYNC.md` "Frozen Interface Contracts" table: set Status `frozen` and fill `Frozen at` date.
|
||||
- Commit on main.
|
||||
### Phase 3 — not yet in scope
|
||||
|
||||
### 2. Dispatch T-1.0 — Server Refactor
|
||||
|
||||
Single agent, blocking everyone else. Refactor existing
|
||||
`extensions/remote-control/server.ts` (335 lines) and friends into the
|
||||
modular layout from `PHASE-1-sidecar.md` §Architecture Sketch.
|
||||
|
||||
Prompt template (adapt with concrete contract numbers):
|
||||
|
||||
```
|
||||
# Task: Phase 1 T-1.0 — Server Refactor
|
||||
|
||||
Working dir: /Users/jay/.pi/agent/git/git.vpsj.de/jay/pi-remote-control
|
||||
|
||||
Read first:
|
||||
- docs/NEXT-STEPS.md (state of play)
|
||||
- docs/PHASE-1-sidecar.md §Architecture Sketch and §Task Breakdown row T-1.0
|
||||
- docs/SYNC.md (claim the task)
|
||||
- existing code: extensions/remote-control/{index,server,html,messages,auth,config}.ts
|
||||
|
||||
Goal:
|
||||
- Carve existing server.ts into the server/ + server/routes/ + server/upgrade.ts
|
||||
structure from the phase doc.
|
||||
- Keep the LEGACY HTML client (html.ts) working end-to-end. Add comments
|
||||
tagging these legacy paths.
|
||||
- No new features. No new endpoints. Just structural refactor + ensure
|
||||
existing tests / smoke usage still works.
|
||||
- After your refactor lands, the directory layout matches the Phase-1
|
||||
Architecture Sketch.
|
||||
|
||||
Out of scope: anything touching tmux/, buffer/, pi/, auth/{pairing,tls}.ts,
|
||||
apns/, cli/. Those are for later T-1.* tasks.
|
||||
|
||||
Branch: feat/p1-t1-0-server-refactor
|
||||
```
|
||||
|
||||
After T-1.0 merges into main, the parallel fan-out becomes available.
|
||||
|
||||
### 3. Plan the parallel fan-out
|
||||
|
||||
Once T-1.0 is in main:
|
||||
- 5 worker dispatches in parallel: T-1.1, T-1.2, T-1.3, T-1.4, T-1.10.
|
||||
- Each gets a tight prompt referencing their row in `PHASE-1-sidecar.md`
|
||||
and the relevant frozen ICs.
|
||||
- T-1.1 explicitly says: use control mode, reference `spike-cc.ts` on
|
||||
`feat/spike-tmux-cc`, follow R-CC-1..R-CC-5 expectations.
|
||||
|
||||
T-1.5, T-1.6, T-1.7 follow as their deps come in.
|
||||
Slash palette, voice, themes, search, etc. See `docs/PHASE-3-ios-augmentation.md`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -102,26 +79,18 @@ T-1.5, T-1.6, T-1.7 follow as their deps come in.
|
|||
|
||||
1. `docs/NEXT-STEPS.md` (this file)
|
||||
2. `docs/SYNC.md` — current claims, gate, contracts
|
||||
3. `docs/PHASE-1-sidecar.md` — what to build
|
||||
4. `docs/reference/PHASE-0-report.md` and `PHASE-0.5-report.md` — why we're
|
||||
building it this way
|
||||
5. Spec only if you forget the why: `docs/reference/SPEC-ios-app.md`
|
||||
3. `docs/PHASE-2-ios-mvp.md` — what's left to build for iOS
|
||||
4. iOS repo: `docs/SIMULATOR-AUTOMATION.md` — how to drive the sim
|
||||
5. iOS repo: `docs/BUILD.md` — build + install + launch commands
|
||||
6. Spec only if you forget the why: `docs/reference/SPEC-ios-app.md`
|
||||
|
||||
---
|
||||
|
||||
## Open questions for next session
|
||||
## Conventions established
|
||||
|
||||
- **OQ-1.** Drop the `tree` event from IC-1 ServerToClient, or keep reserved? (Recommendation: drop.)
|
||||
- **OQ-2.** Resize message in IC-1? (Recommendation: defer.)
|
||||
- **OQ-3.** Should `tmux/manager.ts` use a single long-lived control-mode connection per **server**, or per **session**? The spike used per-session for simplicity. Per-server scales better but is more code. Decide before T-1.1 starts.
|
||||
- **OQ-4.** Worker for T-1.0: same `anthropic/claude-sonnet-4-5`, or escalate to a higher-context model since the refactor touches multiple files? (Recommendation: sonnet-4-5 with `context: fresh` is fine for ~500 LoC refactor.)
|
||||
|
||||
---
|
||||
|
||||
## Don't do tomorrow
|
||||
|
||||
- Don't merge the spike branches into main. They're reference, not code.
|
||||
- Don't start T-1.1 before T-1.0 is in main.
|
||||
- Don't start any iOS task until Phase 1 is at least feature-complete.
|
||||
- **Async subagent dispatch.** All worker tasks via `subagent({ async: true, context: "fresh" })`. Worktree isolation for parallel-on-same-repo work; same-repo iOS+sidecar splits are inherently isolated.
|
||||
- **Reviewer fan-in.** After parallel implementation, dispatch one reviewer agent with all branch summaries pre-loaded; reviewer writes `review.md` for the orchestrator to apply.
|
||||
- **Sim test infra (`--uitest` mode).** Pre-fetch a fresh `/pair-qr` token per test, launch with `--reset-state --pair-with-url <url>`. `MainTerminalView` in `--uitest` mode skips the WS so XCUITest can reach app-idle within the 120 s window. Pasteboard prompt suppressed via `xcrun simctl privacy grant pasteboard de.vpsj.pi-remote`.
|
||||
- **Sidecar manual restart pattern.** `tmux kill-session -t pi-sidecar; tmux new-session -d -s pi-sidecar -x 220 -y 50 "pi -nt -ne -ns -np -nc --no-session --offline -e extensions/remote-control --remote-control"`. Pre-fill 3 sessions (`main`, `work`, `logs`) for any manual E2E test.
|
||||
- Don't expand scope. Stick to v3 spec; new ideas go into a v4 review
|
||||
round, not into open PRs.
|
||||
|
|
|
|||
|
|
@ -127,7 +127,8 @@ type ClientToServer =
|
|||
|
||||
type ServerToClient =
|
||||
| { type: "state"; value: "thinking" | "tool" | "idle" | "awaiting-input"; tool?: string; ts: number }
|
||||
| { type: "tree"; nodes: TreeNode[]; current: string } // optional, read-only
|
||||
// tree event dropped — out of iOS scope. Revisit if a dashboard wants it.
|
||||
// resize ClientToServer deferred — fixed 120×40 for v1.
|
||||
| { type: "snapshot"; seq: number; data: string } // base64 ANSI snapshot
|
||||
| { type: "session-meta"; name: string; description?: string; createdAt: string }
|
||||
| { type: "error"; code: string; message: string };
|
||||
|
|
@ -159,7 +160,9 @@ QR encodes a `pi-remote://` URL:
|
|||
pi-remote://<host>:<port>?pair=<pairing-token>&fp=<sha256-hex>&name=<sidecar-name>
|
||||
```
|
||||
|
||||
Pairing exchange: client `POST /pair` with `{ pairingToken, deviceToken?, environment?, deviceName? }` → server replies `{ bearerToken, sidecarId }`. Owner: T-1.3.
|
||||
Pairing exchange: client `POST /pair` with `{ pairingToken, deviceToken?, environment?, deviceName? }` → server replies `{ bearerToken, sidecarId }`.
|
||||
|
||||
`deviceToken` and `environment` are **optional** pre-Phase-2, **mandatory** from Phase 2 onward. Owner: T-1.3.
|
||||
|
||||
### IC-4 — Config schema (TOML)
|
||||
|
||||
|
|
|
|||
17
docs/SYNC.md
17
docs/SYNC.md
|
|
@ -38,8 +38,8 @@ The point: no central scheduler is required. A short structured edit on
|
|||
|---|---|---|
|
||||
| Phase 0 — Spike Stream | done | ✅ GREEN LIGHT with caveat: pipe-pane unreliable. See `reference/PHASE-0-report.md`. |
|
||||
| Phase 0.5 — Spike tmux Control Mode | done | ✅ VERDICT: Path B (control mode) recommended. See `reference/PHASE-0.5-report.md`. |
|
||||
| Phase 1 — Sidecar | ready to start | Streaming path decided: tmux control mode (Path B). |
|
||||
| Phase 2 — iOS MVP | blocked on Phase 1 | Sidecar must be reachable and stable. |
|
||||
| Phase 1 — Sidecar | **done** | All T-1.0..T-1.10 implemented. Smoke 22/22 green (incl. POST /pair). Follow-up fixes on main: WS keys handler, POST /sessions shape, control-mode encoding. |
|
||||
| Phase 2 — iOS MVP | **in progress** | T-2.0..T-2.6, T-2.8, T-2.9, T-2.10, T-2.11 on main. Bonus: XCUITest target + idb automation docs. Open: T-2.7, T-2.12, T-2.13. Repo: `pi-remote-ios`. |
|
||||
| Phase 3 — iOS Augmentation | blocked on Phase 2 | Continuous after MVP ships. |
|
||||
|
||||
Update the **Status** column when a phase transitions. Allowed states:
|
||||
|
|
@ -86,6 +86,7 @@ add a row or open a Contract Change Request.
|
|||
| `extensions/remote-control/apns/**` | T-1.10, Phase-2 T-2.9 (when iOS supplies tokens) |
|
||||
| `extensions/remote-control/cli/**` | T-1.3, T-1.7 |
|
||||
| `extensions/remote-control/config.ts` | T-1.7 |
|
||||
| `scripts/smoke/**` | T-1.0a (harness), any T-1.* task (add new test files) |
|
||||
| `docs/SYNC.md` | all (this file) |
|
||||
| `docs/PHASE-*.md` | nobody once a phase has started (frozen plan) — open a CCR to amend |
|
||||
| `docs/reference/**` | nobody during implementation — archival |
|
||||
|
|
@ -99,10 +100,10 @@ Phase 2 kicks off.
|
|||
|
||||
| ID | Defined in | Status | Frozen at | Owner of changes |
|
||||
|---|---|---|---|---|
|
||||
| IC-1 — WebSocket frame protocol | `PHASE-1-sidecar.md` §Interface Contracts | **draft** — needs orchestrator sign-off before T-1.5 starts | — | T-1.5 lead, with sign-off from any active T-2.x owner |
|
||||
| IC-2 — HTTP REST shape | `PHASE-1-sidecar.md` §Interface Contracts | **draft** | — | T-1.5..T-1.7 leads |
|
||||
| IC-3 — Pairing payload | `PHASE-1-sidecar.md` §Interface Contracts | **draft** | — | T-1.3 lead |
|
||||
| IC-4 — Config TOML schema | `PHASE-1-sidecar.md` §Interface Contracts | **draft** | — | T-1.7 lead |
|
||||
| IC-1 — WebSocket frame protocol | `PHASE-1-sidecar.md` §Interface Contracts | **frozen** | 2026-05-15 | T-1.5 lead, with sign-off from any active T-2.x owner |
|
||||
| IC-2 — HTTP REST shape | `PHASE-1-sidecar.md` §Interface Contracts | **frozen** | 2026-05-15 | T-1.5..T-1.7 leads |
|
||||
| IC-3 — Pairing payload | `PHASE-1-sidecar.md` §Interface Contracts | **frozen** | 2026-05-15 | T-1.3 lead |
|
||||
| IC-4 — Config TOML schema | `PHASE-1-sidecar.md` §Interface Contracts | **frozen** | 2026-05-15 | T-1.7 lead |
|
||||
| IC-2.1 — `SessionConnection` Swift surface | `PHASE-2-ios-mvp.md` §Interface Contracts | **draft** — freeze at Phase 2 kickoff | — | T-2.5 lead |
|
||||
|
||||
**Freeze protocol:** when the orchestrator is ready to fan out work that
|
||||
|
|
@ -159,4 +160,8 @@ yyyy-mm-dd @handle T-x.y what was done
|
|||
```
|
||||
2026-05-15 @worker-phase0 T-0.* Phase 0 spike complete. tmux+pipe-pane PoC validated. GREEN LIGHT for Phase 1. Report: reference/PHASE-0-report.md. Branch: feat/spike-stream (kept for reference, not merged).
|
||||
2026-05-15 @worker-phase0.5 T-0.5 Phase 0.5 spike complete. tmux control mode validated. VERDICT: Path B recommended. Report: reference/PHASE-0.5-report.md. Branch: feat/spike-tmux-cc (kept for reference, not merged).
|
||||
2026-05-15 @worker-t1.0 T-1.0 Server refactor scaffold complete. server.ts carved into server/{types,server,upgrade}.ts + server/routes/ stub. LEGACY html path preserved end-to-end. Reviewer APPROVE. Branch: feat/p1-t1-0-server-refactor (merged).
|
||||
2026-05-15 @worker-t1.0a T-1.0a Smoke test harness MVP complete. scripts/smoke/ with helpers.mjs + smoke.mjs. 6 tests: manifest, icon, GET/ auth+unauth, WS upgrade, process-alive. All green in ~1.4s. Key finding: pi requires PTY; workaround via python3 pty.spawn(). Branch: feat/p1-t1-0a-smoke (merged).
|
||||
2026-05-15 @jay T-1.1..T-1.10 Phase 1 complete. All modules implemented sequentially on single branch: tmux control-mode client (fix: -C not -CC), disk ring-buffer, auth/pairing/TLS, pi adapter, stream+input+sessions+commands+side+health routes, APNs scaffold. T-1.8 stream integration smoke 12/12 green. T-1.9 operator guide written. Two production bugs fixed: base-index issue (send-keys target was :0.0, now session-only) and -CC→-C flag in control mode.
|
||||
2026-05-15 @jay T-2.0..T-2.5,T-2.9 Phase 2 started. Repo pi-remote-ios created. T-2.0 Xcode scaffold (de.vpsj.pi-remote, team KNXX8R3648, SwiftTerm+Starscream). T-2.1 WebSocketClient+FrameCodec+ResumeCursor (IC-1). T-2.2 Pairing+Keychain+QRScanner (IC-3). T-2.3 TerminalView+ThemeStore+FontStore. T-2.4 ModifierBar+ModifierState+PasteSheet. T-2.5 SessionConnection (IC-2.1)+ScrollbackCache. T-2.9 APNs NotificationDelegate+DeviceTokenRegistrar. 111 unit tests. App boots on device (iOS 26, wireless, Xcode 16.4 Intel). APNs key: ~/.local/share/pi-remote/apns/AuthKey_285C2X4689.p8.
|
||||
```
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
# Operator Guide — pi-remote-control sidecar
|
||||
|
||||
> **Phase 1 state:** all sidecar modules implemented (T-1.0..T-1.10).
|
||||
> The iOS app (Phase 2) is not yet built; use `wscat` or the legacy
|
||||
> browser HTML UI to verify a running sidecar.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
| Dependency | Minimum | Notes |
|
||||
|---|---|---|
|
||||
| Node.js | 18 | Must be on PATH (pi's runtime) |
|
||||
| tmux | 2.5 | Control mode + `pane-died` events |
|
||||
| openssl | any | Self-signed TLS cert generation |
|
||||
| Python 3 | any | Smoke-test PTY workaround only |
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# 1. Start pi with remote-control and auto-start the sidecar
|
||||
pi -e extensions/remote-control --remote-control
|
||||
|
||||
# The sidecar prints its URL:
|
||||
# Remote-control started: http://127.0.0.1:7777/?token=<TOKEN>
|
||||
```
|
||||
|
||||
The server binds to `127.0.0.1:7777` by default (configurable — see below).
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Create `~/.pi/remote-control/config.json` to override defaults:
|
||||
|
||||
```json
|
||||
{
|
||||
"bindAddress": "0.0.0.0:7777",
|
||||
"publicBaseUrl": "https://pi.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
IC-4 (config.toml, Phase 1.7 wiring) will migrate this to TOML. For now,
|
||||
use the JSON format above.
|
||||
|
||||
**Key fields:**
|
||||
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| `bindAddress` | `127.0.0.1:0` | Host:port to bind (`:0` = random free port) |
|
||||
| `publicBaseUrl` | (none) | URL printed in QR / UI — use your tunnel URL |
|
||||
|
||||
---
|
||||
|
||||
## API reference (IC-2)
|
||||
|
||||
All endpoints require bearer token auth. Pass via:
|
||||
- `?token=<TOKEN>` query param (same token as the startup URL)
|
||||
- `Authorization: Bearer <TOKEN>` header
|
||||
|
||||
### REST endpoints
|
||||
|
||||
```
|
||||
GET /health
|
||||
→ { ok, uptime, sessions, sessionIds, bufferMb, diskFreeGb, warnings }
|
||||
|
||||
POST /sessions
|
||||
body: { name?: string }
|
||||
→ 201 { id, name }
|
||||
|
||||
GET /sessions
|
||||
→ [{ id, name, description, state, lastOutputAt }]
|
||||
|
||||
PATCH /sessions/:id
|
||||
body: { description?: string }
|
||||
→ 200 { id, description }
|
||||
|
||||
DELETE /sessions/:id
|
||||
→ 204 (also deletes session buffer)
|
||||
|
||||
GET /sessions/:id/commands
|
||||
→ [{ name, description, args? }]
|
||||
|
||||
GET /sessions/:id/thumbnail
|
||||
→ text/plain, 40×12 capture-pane snapshot
|
||||
|
||||
POST /sessions/:id/input
|
||||
body: { type: "key"|"keys"|"paste", name?:string, data?:string }
|
||||
→ 204
|
||||
```
|
||||
|
||||
### WebSocket endpoints
|
||||
|
||||
```
|
||||
WS /sessions/:id/stream
|
||||
Client→Server (JSON text frames):
|
||||
{ type:"resume"; lastSeq: number|null } — connect/reconnect
|
||||
{ type:"key"; name: string } — single named key
|
||||
{ type:"keys"; data: string } — literal text
|
||||
{ type:"paste"; data: string } — bracketed-paste
|
||||
{ type:"snapshot-request" } — request a snapshot
|
||||
|
||||
Server→Client (binary frames):
|
||||
[seq: 8 bytes BE uint64][raw ANSI bytes]
|
||||
|
||||
Server→Client (JSON text frames):
|
||||
{ type:"state"; value:"thinking"|"tool"|"idle"|"awaiting-input"; tool?:string; ts:number }
|
||||
{ type:"snapshot"; seq:number; data:string } — base64 ANSI snapshot
|
||||
{ type:"session-meta"; name:string; description?:string; createdAt:string }
|
||||
{ type:"error"; code:string; message:string }
|
||||
|
||||
WS /sessions/:id/side
|
||||
State-only side-channel (no binary output).
|
||||
Server→Client: same JSON frames as /stream (state, session-meta, error).
|
||||
Client→Server: (none expected)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pairing (iOS / CLI)
|
||||
|
||||
```bash
|
||||
# Generate a QR code to pair the iOS app
|
||||
node extensions/remote-control/cli/index.js pair
|
||||
|
||||
# List bearer tokens
|
||||
node extensions/remote-control/cli/index.js auth list
|
||||
|
||||
# Create a named token
|
||||
node extensions/remote-control/cli/index.js auth create "Jay's iPhone"
|
||||
|
||||
# Revoke a token
|
||||
node extensions/remote-control/cli/index.js auth revoke <id>
|
||||
```
|
||||
|
||||
The QR encodes a `pi-remote://` URL (IC-3) containing host, port, pairing
|
||||
token, TLS fingerprint, and sidecar name. The iOS app scans it and calls
|
||||
`POST /pair` to exchange a bearer token.
|
||||
|
||||
---
|
||||
|
||||
## TLS
|
||||
|
||||
Self-signed cert is generated on first run at:
|
||||
```
|
||||
~/.local/share/pi-remote/tls/cert.pem
|
||||
~/.local/share/pi-remote/tls/key.pem
|
||||
```
|
||||
|
||||
The SHA-256 fingerprint is included in the QR code. The iOS app pins to
|
||||
this fingerprint — no CA needed.
|
||||
|
||||
> **Note:** T-1.3 implements cert generation. T-1.5 wiring TLS into the
|
||||
> HTTP server is a Phase 2 task (server currently runs plain HTTP;
|
||||
> terminate TLS at your reverse proxy for now).
|
||||
|
||||
---
|
||||
|
||||
## Disk buffer
|
||||
|
||||
Each session's output is written to:
|
||||
```
|
||||
~/.local/share/pi-remote/buffers/<session-id>.buf
|
||||
```
|
||||
|
||||
Caps (defaults, configurable via IC-4 TOML in T-1.7):
|
||||
- Per-session: 100 MB
|
||||
- Global: 1 GB
|
||||
- Minimum free disk: 1 GB (writes suspended below this)
|
||||
- Idle cleanup: sessions inactive > 30 days are deleted on startup
|
||||
|
||||
---
|
||||
|
||||
## Smoke tests
|
||||
|
||||
```bash
|
||||
# Basic smoke (server start, HTML, manifest, icon, WS)
|
||||
npm run smoke
|
||||
|
||||
# Stream integration (session CRUD, send-keys, reconnect, thumbnail)
|
||||
npm run smoke:stream
|
||||
|
||||
# Both
|
||||
npm run smoke:all
|
||||
```
|
||||
|
||||
Requires `python3` on PATH (PTY workaround for pi's TUI requirement).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Server doesn't start / port conflict**
|
||||
Set `bindAddress` in config.json to a free port.
|
||||
|
||||
**`tmux >= 2.5 required`**
|
||||
Upgrade tmux: `brew upgrade tmux` (macOS) or `apt upgrade tmux`.
|
||||
|
||||
**`can't find window`**
|
||||
Older code bug: was using hardcoded `:0.0` pane targets, which fails if
|
||||
tmux `base-index` is 1. Fixed in T-1.1 — target is now just the session name.
|
||||
|
||||
**Marker not appearing in stream**
|
||||
The ControlClient uses `-C` (not `-CC`) for control mode. If you see no
|
||||
`%output` events, check tmux version and that `-C` works:
|
||||
```bash
|
||||
tmux -C attach -t <session>
|
||||
# Should print: %begin ... %end ... %output ...
|
||||
```
|
||||
|
||||
**APNs push not working**
|
||||
APNs is scaffolded (T-1.10) but device tokens are only available once the
|
||||
iOS app pairs in Phase 2. Check `[apns]` config and that `.p8` key path
|
||||
is correct.
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* APNs scaffold — JWT generation + push primitive.
|
||||
*
|
||||
* Provides a minimal APNs HTTP/2 push implementation using the JWT
|
||||
* provider-auth method (no persistent connection pool — deferred to Phase 2
|
||||
* when iOS delivers real device tokens and call volume is known).
|
||||
*
|
||||
* Config comes from [apns] section in config.toml (T-1.7 wires this).
|
||||
* Until then, an explicit ApnsConfig object is passed in.
|
||||
*
|
||||
* Owner: T-1.10
|
||||
*/
|
||||
|
||||
import { createSign } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
export interface ApnsConfig {
|
||||
teamId: string; // 10-char Apple Team ID
|
||||
keyId: string; // 10-char APNs key ID
|
||||
keyPath: string; // path to .p8 private key file
|
||||
bundleId: string; // app bundle ID, e.g. "com.example.pi-remote"
|
||||
}
|
||||
|
||||
export interface PushPayload {
|
||||
title?: string;
|
||||
body?: string;
|
||||
badge?: number;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** APNs host per environment */
|
||||
const APNS_HOST = {
|
||||
production: "api.push.apple.com",
|
||||
sandbox: "api.development.push.apple.com",
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWT generation (ES256, valid 60 minutes per APNs spec)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface JwtCache {
|
||||
token: string;
|
||||
issuedAt: number;
|
||||
}
|
||||
|
||||
const _jwtCache = new Map<string, JwtCache>();
|
||||
|
||||
/**
|
||||
* Generate (or return cached) APNs provider JWT.
|
||||
* APNs rejects tokens older than 60 min; we refresh at 55 min.
|
||||
*/
|
||||
export async function getProviderJwt(cfg: ApnsConfig): Promise<string> {
|
||||
const cacheKey = `${cfg.teamId}:${cfg.keyId}`;
|
||||
const cached = _jwtCache.get(cacheKey);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (cached && now - cached.issuedAt < 55 * 60) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
const keyPem = await fs.readFile(cfg.keyPath, "utf8");
|
||||
|
||||
const header = base64url(JSON.stringify({ alg: "ES256", kid: cfg.keyId }));
|
||||
const claims = base64url(JSON.stringify({ iss: cfg.teamId, iat: now }));
|
||||
const signingInput = `${header}.${claims}`;
|
||||
|
||||
const sign = createSign("SHA256");
|
||||
sign.update(signingInput);
|
||||
const sig = sign.sign({ key: keyPem, dsaEncoding: "ieee-p1363" });
|
||||
const sigB64 = sig.toString("base64url");
|
||||
|
||||
const token = `${signingInput}.${sigB64}`;
|
||||
_jwtCache.set(cacheKey, { token, issuedAt: now });
|
||||
return token;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Push primitive
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PushResult {
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
apnsId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a single APNs push notification.
|
||||
*
|
||||
* Uses the Node fetch API (available since Node 18).
|
||||
* No connection pooling — Phase 2 can upgrade to http2 if throughput demands.
|
||||
*/
|
||||
export async function sendPush(opts: {
|
||||
cfg: ApnsConfig;
|
||||
deviceToken: string;
|
||||
environment: "production" | "sandbox";
|
||||
payload: PushPayload;
|
||||
collapseId?: string;
|
||||
}): Promise<PushResult> {
|
||||
const { cfg, deviceToken, environment, payload, collapseId } = opts;
|
||||
|
||||
const jwt = await getProviderJwt(cfg);
|
||||
const host = APNS_HOST[environment];
|
||||
const url = `https://${host}/3/device/${deviceToken}`;
|
||||
|
||||
const apsPayload = {
|
||||
aps: {
|
||||
...(payload.title || payload.body
|
||||
? { alert: { title: payload.title, body: payload.body } }
|
||||
: {}),
|
||||
...(payload.badge !== undefined ? { badge: payload.badge } : {}),
|
||||
},
|
||||
...payload.data,
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
authorization: `bearer ${jwt}`,
|
||||
"apns-topic": cfg.bundleId,
|
||||
"content-type": "application/json",
|
||||
};
|
||||
if (collapseId) headers["apns-collapse-id"] = collapseId;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(apsPayload),
|
||||
});
|
||||
|
||||
const apnsId = res.headers.get("apns-id") ?? undefined;
|
||||
|
||||
if (res.ok) {
|
||||
return { ok: true, status: res.status, apnsId };
|
||||
}
|
||||
|
||||
const body = await res.text().catch(() => "");
|
||||
let errorReason = body;
|
||||
try {
|
||||
errorReason = (JSON.parse(body) as { reason?: string }).reason ?? body;
|
||||
} catch {
|
||||
// use raw body
|
||||
}
|
||||
return { ok: false, status: res.status, apnsId, error: errorReason };
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function base64url(input: string): string {
|
||||
return Buffer.from(input).toString("base64url");
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* QR-based pairing.
|
||||
*
|
||||
* Implements IC-3 — the `pi-remote://` URL scheme and `POST /pair` exchange.
|
||||
*
|
||||
* Pairing flow:
|
||||
* 1. Server generates a one-time pairingToken (short-lived, 10 min).
|
||||
* 2. `pi-remote pair` CLI prints the QR code containing the URL.
|
||||
* 3. iOS app scans the QR, extracts host/port/pairingToken/fingerprint.
|
||||
* 4. iOS app calls `POST /pair` with { pairingToken, deviceToken?, environment?, deviceName? }.
|
||||
* 5. Server validates pairingToken, creates a bearer token, returns { bearerToken, sidecarId }.
|
||||
*
|
||||
* Owner: T-1.3
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
export interface PairingToken {
|
||||
token: string;
|
||||
expiresAt: number; // unix ms
|
||||
}
|
||||
|
||||
// In-memory store of active pairing tokens (cleared on server restart)
|
||||
const _pairingTokens = new Map<string, PairingToken>();
|
||||
|
||||
const PAIRING_TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/** Generate a new pairing token. Invalidates any existing one. */
|
||||
export function generatePairingToken(): PairingToken {
|
||||
const token = randomBytes(16).toString("base64url");
|
||||
const entry: PairingToken = {
|
||||
token,
|
||||
expiresAt: Date.now() + PAIRING_TOKEN_TTL_MS,
|
||||
};
|
||||
_pairingTokens.set(token, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Validate and consume a pairing token. Returns true if valid. */
|
||||
export function consumePairingToken(token: string): boolean {
|
||||
const entry = _pairingTokens.get(token);
|
||||
if (!entry) return false;
|
||||
_pairingTokens.delete(token);
|
||||
if (Date.now() > entry.expiresAt) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the IC-3 pairing URL.
|
||||
*
|
||||
* pi-remote://<host>:<port>?pair=<pairingToken>&fp=<sha256-hex>&name=<sidecarName>
|
||||
*/
|
||||
export function buildPairingUrl(opts: {
|
||||
host: string;
|
||||
port: number;
|
||||
pairingToken: string;
|
||||
fingerprint: string;
|
||||
sidecarName?: string;
|
||||
}): string {
|
||||
const {
|
||||
host,
|
||||
port,
|
||||
pairingToken,
|
||||
fingerprint,
|
||||
sidecarName = "pi-remote",
|
||||
} = opts;
|
||||
const params = new URLSearchParams({
|
||||
pair: pairingToken,
|
||||
fp: fingerprint,
|
||||
name: sidecarName,
|
||||
});
|
||||
return `pi-remote://${host}:${port}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the pairing URL as a QR code to the terminal.
|
||||
* Uses the `qrcode` package bundled as a dependency.
|
||||
*/
|
||||
export async function printPairingQr(url: string): Promise<void> {
|
||||
// Dynamic import so this file loads even if qrcode isn't installed
|
||||
const qrcode = await import("qrcode");
|
||||
const qr = await qrcode.toString(url, { type: "terminal", small: true });
|
||||
console.log(qr);
|
||||
console.log(url);
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Self-signed TLS certificate generation + fingerprint.
|
||||
*
|
||||
* Generates a self-signed cert for the sidecar server. The SHA-256 fingerprint
|
||||
* is included in the QR pairing URL (IC-3 `fp` field) so the iOS app can pin.
|
||||
*
|
||||
* Uses openssl CLI (available on macOS/Linux). Falls back to plain HTTP if
|
||||
* openssl is not available.
|
||||
*
|
||||
* Owner: T-1.3
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface TlsCert {
|
||||
certPath: string;
|
||||
keyPath: string;
|
||||
/** SHA-256 hex fingerprint of the DER-encoded cert */
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
export interface TlsCertOptions {
|
||||
stateDir?: string;
|
||||
/** Common name for the cert (default: "pi-remote") */
|
||||
cn?: string;
|
||||
/** Days the cert is valid (default: 3650 — 10 years) */
|
||||
days?: number;
|
||||
}
|
||||
|
||||
function certDir(stateDir?: string): string {
|
||||
return path.join(
|
||||
stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote"),
|
||||
"tls",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing cert or generate a new one.
|
||||
* Returns paths + fingerprint.
|
||||
*/
|
||||
export async function loadOrCreateCert(
|
||||
opts: TlsCertOptions = {},
|
||||
): Promise<TlsCert> {
|
||||
const { stateDir, cn = "pi-remote", days = 3650 } = opts;
|
||||
const dir = certDir(stateDir);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const certPath = path.join(dir, "cert.pem");
|
||||
const keyPath = path.join(dir, "key.pem");
|
||||
|
||||
// Reuse existing cert if present
|
||||
try {
|
||||
await fs.access(certPath);
|
||||
await fs.access(keyPath);
|
||||
const fingerprint = await computeFingerprint(certPath);
|
||||
return { certPath, keyPath, fingerprint };
|
||||
} catch {
|
||||
// generate new
|
||||
}
|
||||
|
||||
// Generate via openssl
|
||||
await execFileAsync("openssl", [
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-keyout",
|
||||
keyPath,
|
||||
"-out",
|
||||
certPath,
|
||||
"-days",
|
||||
String(days),
|
||||
"-nodes",
|
||||
"-subj",
|
||||
`/CN=${cn}`,
|
||||
]);
|
||||
await fs.chmod(keyPath, 0o600);
|
||||
|
||||
const fingerprint = await computeFingerprint(certPath);
|
||||
return { certPath, keyPath, fingerprint };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-256 fingerprint of a PEM cert.
|
||||
* Returns hex string (no colons), e.g. "a1b2c3...".
|
||||
*/
|
||||
export async function computeFingerprint(certPath: string): Promise<string> {
|
||||
// Use openssl to get DER bytes, then hash
|
||||
const { stdout } = await execFileAsync("openssl", [
|
||||
"x509",
|
||||
"-in",
|
||||
certPath,
|
||||
"-outform",
|
||||
"DER",
|
||||
]);
|
||||
return createHash("sha256").update(stdout).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if openssl is available on PATH.
|
||||
*/
|
||||
export async function isOpensslAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync("openssl", ["version"]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* Bearer-token CRUD.
|
||||
*
|
||||
* Extends the minimal token support in auth.ts (legacy single-token) with
|
||||
* named multi-token management. Each token entry has:
|
||||
* - id: short random identifier
|
||||
* - token: the bearer secret (base64url, 32 bytes)
|
||||
* - name: human label (e.g. "Jay's iPhone")
|
||||
* - createdAt: ISO timestamp
|
||||
* - deviceToken?: APNs device token (set when device pairs in Phase 2)
|
||||
* - environment?: "production" | "sandbox"
|
||||
*
|
||||
* Stored as JSON in $state_dir/auth/tokens.json (mode 0o600).
|
||||
*
|
||||
* Owner: T-1.3
|
||||
*/
|
||||
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory cache for sync validation (WS upgrade can't await)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _tokenCache = new Set<string>();
|
||||
|
||||
function cacheToken(token: string): void {
|
||||
_tokenCache.add(token);
|
||||
}
|
||||
|
||||
/** Sync bearer validation — checks in-memory cache populated at runtime. */
|
||||
export function validateBearerSync(bearer: string): boolean {
|
||||
return _tokenCache.has(bearer);
|
||||
}
|
||||
|
||||
/** Warm the cache from disk on startup. */
|
||||
export async function warmTokenCache(stateDir?: string): Promise<void> {
|
||||
const entries = await loadTokens(stateDir);
|
||||
for (const e of entries) _tokenCache.add(e.token);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TokenEntry {
|
||||
id: string;
|
||||
token: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
deviceToken?: string; // APNs — optional pre-Phase-2, mandatory Phase-2+
|
||||
environment?: "production" | "sandbox";
|
||||
}
|
||||
|
||||
function tokensPath(stateDir?: string): string {
|
||||
const base =
|
||||
stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote");
|
||||
return path.join(base, "auth", "tokens.json");
|
||||
}
|
||||
|
||||
async function loadTokens(stateDir?: string): Promise<TokenEntry[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(tokensPath(stateDir), "utf8");
|
||||
return JSON.parse(raw) as TokenEntry[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTokens(
|
||||
entries: TokenEntry[],
|
||||
stateDir?: string,
|
||||
): Promise<void> {
|
||||
const fp = tokensPath(stateDir);
|
||||
await fs.mkdir(path.dirname(fp), { recursive: true });
|
||||
await fs.writeFile(fp, JSON.stringify(entries, null, 2), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a new named bearer token. Returns the new entry (token visible once). */
|
||||
export async function createToken(
|
||||
name: string,
|
||||
stateDir?: string,
|
||||
): Promise<TokenEntry> {
|
||||
const entries = await loadTokens(stateDir);
|
||||
const entry: TokenEntry = {
|
||||
id: randomBytes(6).toString("base64url"),
|
||||
token: randomBytes(32).toString("base64url"),
|
||||
name,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
entries.push(entry);
|
||||
await saveTokens(entries, stateDir);
|
||||
cacheToken(entry.token);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** List all tokens (token field is included — protect at the API layer). */
|
||||
export async function listTokens(stateDir?: string): Promise<TokenEntry[]> {
|
||||
return loadTokens(stateDir);
|
||||
}
|
||||
|
||||
/** Revoke a token by id. Returns true if found and removed. */
|
||||
export async function revokeToken(
|
||||
id: string,
|
||||
stateDir?: string,
|
||||
): Promise<boolean> {
|
||||
const entries = await loadTokens(stateDir);
|
||||
const before = entries.length;
|
||||
const filtered = entries.filter((e) => e.id !== id);
|
||||
if (filtered.length === before) return false;
|
||||
await saveTokens(filtered, stateDir);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Rename a token. Returns true if found. */
|
||||
export async function renameToken(
|
||||
id: string,
|
||||
newName: string,
|
||||
stateDir?: string,
|
||||
): Promise<boolean> {
|
||||
const entries = await loadTokens(stateDir);
|
||||
const entry = entries.find((e) => e.id === id);
|
||||
if (!entry) return false;
|
||||
entry.name = newName;
|
||||
await saveTokens(entries, stateDir);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Update device token (called during Phase-2 pairing). */
|
||||
export async function setDeviceToken(
|
||||
id: string,
|
||||
deviceToken: string,
|
||||
environment: "production" | "sandbox",
|
||||
stateDir?: string,
|
||||
): Promise<boolean> {
|
||||
const entries = await loadTokens(stateDir);
|
||||
const entry = entries.find((e) => e.id === id);
|
||||
if (!entry) return false;
|
||||
entry.deviceToken = deviceToken;
|
||||
entry.environment = environment;
|
||||
await saveTokens(entries, stateDir);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a bearer token string against the store.
|
||||
* Returns the matching entry or null.
|
||||
*/
|
||||
export async function validateBearer(
|
||||
bearer: string,
|
||||
stateDir?: string,
|
||||
): Promise<TokenEntry | null> {
|
||||
const entries = await loadTokens(stateDir);
|
||||
const b = Buffer.from(bearer);
|
||||
for (const entry of entries) {
|
||||
const a = Buffer.from(entry.token);
|
||||
if (a.length === b.length && timingSafeEqual(a, b)) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Disk ring-buffer reader.
|
||||
*
|
||||
* Reads chunks from a session buffer file, optionally starting from a
|
||||
* given seq number. Used by the stream route for reconnect replay (T-1.5).
|
||||
*
|
||||
* File format (mirrors writer.ts):
|
||||
* Each record: [seq: 8 bytes BE uint64] [length: 4 bytes BE uint32] [data: N bytes]
|
||||
*
|
||||
* Owner: T-1.2
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export interface BufferChunk {
|
||||
seq: number;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
export interface ReaderConfig {
|
||||
stateDir?: string;
|
||||
}
|
||||
|
||||
function stateDir(cfg?: ReaderConfig): string {
|
||||
return (
|
||||
cfg?.stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote")
|
||||
);
|
||||
}
|
||||
|
||||
function bufferPath(session: string, cfg?: ReaderConfig): string {
|
||||
return path.join(stateDir(cfg), "buffers", `${session}.buf`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all chunks from a session buffer, optionally starting after `afterSeq`.
|
||||
*
|
||||
* Returns chunks in seq order. If the file doesn't exist, returns [].
|
||||
* Stops at the first parse error (truncated file at end is tolerated).
|
||||
*/
|
||||
export function readChunks(
|
||||
session: string,
|
||||
opts: { afterSeq?: number; cfg?: ReaderConfig } = {},
|
||||
): BufferChunk[] {
|
||||
const { afterSeq = 0, cfg } = opts;
|
||||
const fp = bufferPath(session, cfg);
|
||||
|
||||
let buf: Buffer;
|
||||
try {
|
||||
buf = fs.readFileSync(fp);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chunks: BufferChunk[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset + 12 <= buf.length) {
|
||||
const seqBig = buf.readBigUInt64BE(offset);
|
||||
const seq = Number(seqBig);
|
||||
const length = buf.readUInt32BE(offset + 8);
|
||||
offset += 12;
|
||||
|
||||
if (offset + length > buf.length) break; // truncated record at end
|
||||
|
||||
if (seq > afterSeq) {
|
||||
chunks.push({ seq, data: buf.slice(offset, offset + length) });
|
||||
}
|
||||
offset += length;
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read chunks as an async generator (streaming, for large buffers).
|
||||
* Yields one chunk at a time after `afterSeq`.
|
||||
*/
|
||||
export async function* streamChunks(
|
||||
session: string,
|
||||
opts: { afterSeq?: number; cfg?: ReaderConfig } = {},
|
||||
): AsyncGenerator<BufferChunk> {
|
||||
// Simple implementation: read all and yield. For large files T-1.5 can
|
||||
// switch to a streaming file read if needed.
|
||||
const { afterSeq = 0, cfg } = opts;
|
||||
const chunks = readChunks(session, { afterSeq, cfg });
|
||||
for (const chunk of chunks) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* Disk ring-buffer writer.
|
||||
*
|
||||
* Appends chunks to a per-session file, enforcing:
|
||||
* - Per-session cap: 100 MB (configurable)
|
||||
* - Global cap: 1 GB across all sessions (configurable)
|
||||
* - Free-space watchdog: refuse writes if free disk < 1 GB
|
||||
* - Idle cleanup: sessions inactive for > 30 days are deleted
|
||||
*
|
||||
* File format (binary, append-only):
|
||||
* Each record: [seq: 8 bytes BE uint64] [length: 4 bytes BE uint32] [data: N bytes]
|
||||
*
|
||||
* Risk R1 mitigation: all writes serialised through a per-session async queue.
|
||||
* Global cap protected by a module-level mutex (simple flag since JS is single-threaded).
|
||||
*
|
||||
* Owner: T-1.2
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import type { Dirent } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { SeqNum } from "../sequence.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config defaults (can be overridden; T-1.7 will wire these from config.toml)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BufferConfig {
|
||||
stateDir: string;
|
||||
perSessionMb: number; // default 100
|
||||
globalGb: number; // default 1
|
||||
freeMinGb: number; // default 1
|
||||
idleDays: number; // default 30
|
||||
}
|
||||
|
||||
function defaultConfig(): BufferConfig {
|
||||
return {
|
||||
stateDir: path.join(os.homedir(), ".local", "share", "pi-remote"),
|
||||
perSessionMb: 100,
|
||||
globalGb: 1,
|
||||
freeMinGb: 1,
|
||||
idleDays: 30,
|
||||
};
|
||||
}
|
||||
|
||||
let _config: BufferConfig = defaultConfig();
|
||||
|
||||
export function configureBuffer(cfg: Partial<BufferConfig>): void {
|
||||
_config = { ..._config, ...cfg };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global cap mutex (JS single-threaded so a flag suffices)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _globalBusy = false;
|
||||
let _globalBytes = 0; // tracked in-memory; recalculated on startup
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-session writer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class BufferWriter {
|
||||
readonly session: string;
|
||||
private filePath: string;
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
private sessionBytes = 0;
|
||||
private lastWriteAt = Date.now();
|
||||
|
||||
constructor(session: string) {
|
||||
this.session = session;
|
||||
this.filePath = path.join(_config.stateDir, "buffers", `${session}.buf`);
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
// Load existing size for cap tracking
|
||||
try {
|
||||
const stat = await fs.stat(this.filePath);
|
||||
this.sessionBytes = stat.size;
|
||||
_globalBytes += stat.size;
|
||||
} catch {
|
||||
this.sessionBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a chunk write. Writes are serialised per session.
|
||||
*/
|
||||
write(seq: SeqNum, data: Buffer): void {
|
||||
this.queue = this.queue.then(() => this._write(seq, data));
|
||||
}
|
||||
|
||||
private async _write(seq: SeqNum, data: Buffer): Promise<void> {
|
||||
const perSessionCap = _config.perSessionMb * 1024 * 1024;
|
||||
const globalCap = _config.globalGb * 1024 * 1024 * 1024;
|
||||
|
||||
// Free-space watchdog
|
||||
try {
|
||||
const { available } = await checkFreeSpace(path.dirname(this.filePath));
|
||||
const freeMin = _config.freeMinGb * 1024 * 1024 * 1024;
|
||||
if (available < freeMin) return; // silently drop; could emit a warning
|
||||
} catch {
|
||||
// If we can't check, don't block writes
|
||||
}
|
||||
|
||||
// Cap enforcement
|
||||
const recordSize = 8 + 4 + data.length;
|
||||
if (
|
||||
_globalBusy ||
|
||||
this.sessionBytes + recordSize > perSessionCap ||
|
||||
_globalBytes + recordSize > globalCap
|
||||
) {
|
||||
return; // drop oldest strategy: just don't write (ring via truncation not implemented yet)
|
||||
}
|
||||
|
||||
_globalBusy = true;
|
||||
try {
|
||||
const header = Buffer.allocUnsafe(12);
|
||||
header.writeBigUInt64BE(BigInt(seq), 0);
|
||||
header.writeUInt32BE(data.length, 8);
|
||||
|
||||
await fs.appendFile(this.filePath, Buffer.concat([header, data]));
|
||||
this.sessionBytes += recordSize;
|
||||
_globalBytes += recordSize;
|
||||
this.lastWriteAt = Date.now();
|
||||
} finally {
|
||||
_globalBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.queue; // drain
|
||||
}
|
||||
|
||||
/** Delete the buffer file and reclaim global tracking bytes. */
|
||||
async delete(): Promise<void> {
|
||||
await this.queue;
|
||||
try {
|
||||
await fs.unlink(this.filePath);
|
||||
_globalBytes = Math.max(0, _globalBytes - this.sessionBytes);
|
||||
this.sessionBytes = 0;
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
get idleMs(): number {
|
||||
return Date.now() - this.lastWriteAt;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Idle cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Delete buffer files for sessions idle longer than idleDays.
|
||||
* Safe to call periodically (e.g. on startup or daily timer).
|
||||
*/
|
||||
export async function cleanupIdleBuffers(
|
||||
cfg: BufferConfig = _config,
|
||||
): Promise<string[]> {
|
||||
const dir = path.join(cfg.stateDir, "buffers");
|
||||
const maxIdleMs = cfg.idleDays * 24 * 60 * 60 * 1000;
|
||||
const deleted: string[] = [];
|
||||
|
||||
let entries: Dirent[] = [];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.name.endsWith(".buf")) continue;
|
||||
const fp = path.join(dir, entry.name);
|
||||
try {
|
||||
const stat = await fs.stat(fp);
|
||||
if (Date.now() - stat.mtimeMs > maxIdleMs) {
|
||||
await fs.unlink(fp);
|
||||
deleted.push(entry.name.replace(/\.buf$/, ""));
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Approximate free disk space on the filesystem containing `dir`. */
|
||||
async function checkFreeSpace(dir: string): Promise<{ available: number }> {
|
||||
// Node doesn't expose statvfs directly; use df -k as a fallback.
|
||||
// If it fails, caller ignores the error.
|
||||
const { execFile } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const exec = promisify(execFile);
|
||||
const { stdout } = await exec("df", ["-k", dir]);
|
||||
const lines = stdout.trim().split("\n");
|
||||
const last = lines[lines.length - 1];
|
||||
const parts = last.split(/\s+/);
|
||||
// df -k columns: Filesystem 1K-blocks Used Available Use% Mounted
|
||||
const availKb = parseInt(parts[3], 10);
|
||||
return { available: availKb * 1024 };
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* pi-remote CLI entrypoints.
|
||||
*
|
||||
* Subcommands:
|
||||
* pi-remote pair — generate QR code for device pairing
|
||||
* pi-remote auth list — list bearer tokens
|
||||
* pi-remote auth revoke <id> — revoke a token
|
||||
* pi-remote auth name <id> <name> — rename a token
|
||||
*
|
||||
* Invoked by the extension via pi's flag registration or as a standalone
|
||||
* script: `node cli/index.js <args>`
|
||||
*
|
||||
* Owner: T-1.3
|
||||
*/
|
||||
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildPairingUrl,
|
||||
generatePairingToken,
|
||||
printPairingQr,
|
||||
} from "../auth/pairing.js";
|
||||
import { loadOrCreateCert } from "../auth/tls.js";
|
||||
import {
|
||||
createToken,
|
||||
listTokens,
|
||||
renameToken,
|
||||
revokeToken,
|
||||
} from "../auth/tokens.js";
|
||||
import { readRemoteControlConfig } from "../config.js";
|
||||
|
||||
const DEFAULT_STATE_DIR = path.join(
|
||||
os.homedir(),
|
||||
".local",
|
||||
"share",
|
||||
"pi-remote",
|
||||
);
|
||||
|
||||
export async function runCli(
|
||||
argv: string[] = process.argv.slice(2),
|
||||
): Promise<void> {
|
||||
const [cmd, sub, ...rest] = argv;
|
||||
|
||||
switch (cmd) {
|
||||
case "pair":
|
||||
await cmdPair();
|
||||
break;
|
||||
|
||||
case "auth":
|
||||
await cmdAuth(sub, rest);
|
||||
break;
|
||||
|
||||
case "help":
|
||||
case "--help":
|
||||
case "-h":
|
||||
printHelp();
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unknown command: ${cmd ?? "(none)"}`);
|
||||
printHelp();
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pair
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function cmdPair(): Promise<void> {
|
||||
const config = await readRemoteControlConfig();
|
||||
const stateDir = DEFAULT_STATE_DIR;
|
||||
|
||||
// Try to get TLS fingerprint; fall back to empty string if openssl unavailable
|
||||
let fingerprint = "";
|
||||
try {
|
||||
const cert = await loadOrCreateCert({ stateDir });
|
||||
fingerprint = cert.fingerprint;
|
||||
} catch {
|
||||
console.warn(
|
||||
"[pi-remote] Warning: openssl not available; fingerprint will be empty.",
|
||||
);
|
||||
}
|
||||
|
||||
const pairingTokenEntry = generatePairingToken();
|
||||
|
||||
// Determine host for QR — use advertised URL or fall back to hostname
|
||||
const bindAddress = config.bindAddress ?? "0.0.0.0:7777";
|
||||
const portMatch = bindAddress.match(/:(\d+)$/);
|
||||
const port = portMatch ? parseInt(portMatch[1], 10) : 7777;
|
||||
const host =
|
||||
(config.publicBaseUrl ?? config.advertisedBaseUrl)?.replace(
|
||||
/^https?:\/\//,
|
||||
"",
|
||||
) ?? os.hostname();
|
||||
|
||||
const url = buildPairingUrl({
|
||||
host,
|
||||
port,
|
||||
pairingToken: pairingTokenEntry.token,
|
||||
fingerprint,
|
||||
sidecarName: "pi-remote",
|
||||
});
|
||||
|
||||
console.log("\nScan this QR code with the pi-remote iOS app:\n");
|
||||
await printPairingQr(url);
|
||||
console.log(
|
||||
`\nPairing token expires in 10 minutes. Run "pi-remote pair" again to refresh.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function cmdAuth(sub: string | undefined, args: string[]): Promise<void> {
|
||||
const stateDir = DEFAULT_STATE_DIR;
|
||||
|
||||
switch (sub) {
|
||||
case "list": {
|
||||
const tokens = await listTokens(stateDir);
|
||||
if (tokens.length === 0) {
|
||||
console.log(
|
||||
"No tokens. Use `pi-remote auth create <name>` to create one.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log("ID NAME CREATED");
|
||||
for (const t of tokens) {
|
||||
const created = new Date(t.createdAt).toLocaleDateString();
|
||||
console.log(
|
||||
`${t.id.padEnd(12)}${t.name.padEnd(22)}${created}${t.deviceToken ? " [device paired]" : ""}`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "create": {
|
||||
const name = args[0] ?? "unnamed";
|
||||
const entry = await createToken(name, stateDir);
|
||||
console.log(`Created token "${entry.name}" (id: ${entry.id})`);
|
||||
console.log(`Bearer token: ${entry.token}`);
|
||||
console.log("Save this token — it won't be shown again.");
|
||||
break;
|
||||
}
|
||||
|
||||
case "revoke": {
|
||||
const id = args[0];
|
||||
if (!id) {
|
||||
console.error("Usage: pi-remote auth revoke <id>");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const ok = await revokeToken(id, stateDir);
|
||||
if (ok) {
|
||||
console.log(`Revoked token ${id}.`);
|
||||
} else {
|
||||
console.error(`Token ${id} not found.`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "name": {
|
||||
const [id, ...nameParts] = args;
|
||||
const newName = nameParts.join(" ");
|
||||
if (!id || !newName) {
|
||||
console.error("Usage: pi-remote auth name <id> <new-name>");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const ok = await renameToken(id, newName, stateDir);
|
||||
if (ok) {
|
||||
console.log(`Renamed token ${id} to "${newName}".`);
|
||||
} else {
|
||||
console.error(`Token ${id} not found.`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(`Unknown auth subcommand: ${sub ?? "(none)"}`);
|
||||
console.error("Available: list, create, revoke, name");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// help
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`
|
||||
pi-remote — CLI for the pi-remote-control sidecar
|
||||
|
||||
Commands:
|
||||
pair Generate a QR code to pair the iOS app
|
||||
auth list List all bearer tokens
|
||||
auth create <name> Create a new named bearer token
|
||||
auth revoke <id> Revoke a token by id
|
||||
auth name <id> <name> Rename a token
|
||||
|
||||
Options:
|
||||
--help, -h Show this help
|
||||
`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standalone entrypoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Run when invoked as: node cli/index.js <args>
|
||||
if (
|
||||
process.argv[1] &&
|
||||
(process.argv[1].endsWith("cli/index.js") ||
|
||||
process.argv[1].endsWith("cli/index.ts"))
|
||||
) {
|
||||
runCli().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
|
@ -93,7 +93,9 @@ export default function remoteControl(pi: ExtensionAPI) {
|
|||
updateStatus(ctx);
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (_event, ctx) => {
|
||||
// session_switch is not in the ExtensionAPI — use session_start instead
|
||||
// to sync state when a session becomes active.
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
scheduleSync(ctx);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -58,8 +58,8 @@ export function serializeMessage(
|
|||
const toolCalls = (msg.content as RawContent[])
|
||||
.filter((c) => c.type === "toolCall")
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
id: c.id ?? "",
|
||||
name: c.name ?? "",
|
||||
args: JSON.stringify(c.arguments, null, 2),
|
||||
}));
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Auto-naming via `pi -p` (S-09a).
|
||||
*
|
||||
* After a configurable number of user messages, spawn a cheap `pi -p` call
|
||||
* to generate a short session name from the conversation context.
|
||||
* The result is stored as the tmux session's @description.
|
||||
*
|
||||
* Gated by [autoname] enabled in config.toml (T-1.7 wires the config;
|
||||
* until then defaults are used).
|
||||
*
|
||||
* Owner: T-1.4
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { setDescription } from "../tmux/manager.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface AutonameConfig {
|
||||
enabled: boolean;
|
||||
triggerAfter: number; // number of user messages before naming
|
||||
model: string; // e.g. "claude-haiku-4-5"
|
||||
}
|
||||
|
||||
export const DEFAULT_AUTONAME_CONFIG: AutonameConfig = {
|
||||
enabled: true,
|
||||
triggerAfter: 3,
|
||||
model: "claude-haiku-4-5",
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempt to auto-name a session using `pi -p`.
|
||||
* If pi is not on PATH or the call fails, silently no-ops.
|
||||
*
|
||||
* @param sessionId tmux session name to set @description on
|
||||
* @param context recent conversation context (short excerpt)
|
||||
* @param cfg autoname configuration
|
||||
*/
|
||||
export async function autoname(
|
||||
sessionId: string,
|
||||
context: string,
|
||||
cfg: AutonameConfig = DEFAULT_AUTONAME_CONFIG,
|
||||
): Promise<void> {
|
||||
if (!cfg.enabled) return;
|
||||
|
||||
const prompt = `Give a 2-4 word title for this conversation. Reply with only the title, no punctuation.\n\n${context.slice(0, 800)}`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"pi",
|
||||
[
|
||||
"-p",
|
||||
"--model",
|
||||
cfg.model,
|
||||
"--no-session",
|
||||
"--no-tools",
|
||||
"--no-extensions",
|
||||
"--no-skills",
|
||||
"--offline",
|
||||
prompt,
|
||||
],
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
const name = stdout.trim().slice(0, 60); // cap at 60 chars
|
||||
if (name) {
|
||||
await setDescription(sessionId, name);
|
||||
}
|
||||
} catch {
|
||||
// Autoname failures are non-fatal
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* pi.getCommands() wrapper.
|
||||
*
|
||||
* Fetches available slash commands from the pi ExtensionAPI and normalises
|
||||
* them into the shape used by the /sessions/:id/commands REST endpoint (T-1.6).
|
||||
*
|
||||
* Owner: T-1.4
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
args?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of registered slash commands from pi.
|
||||
* Returns an empty array if the API doesn't support getCommands.
|
||||
*/
|
||||
export async function getCommands(pi: ExtensionAPI): Promise<SlashCommand[]> {
|
||||
try {
|
||||
// getCommands may not exist in all pi versions
|
||||
if (
|
||||
typeof (pi as unknown as { getCommands?: unknown }).getCommands !==
|
||||
"function"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const raw = await (
|
||||
pi as unknown as { getCommands: () => Promise<unknown[]> }
|
||||
).getCommands();
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
return raw
|
||||
.filter(
|
||||
(c): c is { name: string; description?: string; args?: string } =>
|
||||
c !== null &&
|
||||
typeof c === "object" &&
|
||||
typeof (c as { name?: unknown }).name === "string",
|
||||
)
|
||||
.map((c) => ({
|
||||
name: c.name,
|
||||
description: c.description ?? "",
|
||||
args: c.args,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* pi ExtensionAPI event subscriptions.
|
||||
*
|
||||
* Bridges pi's lifecycle events into the sidecar's state model.
|
||||
* Emits structured state updates that the WebSocket broadcaster (T-1.5)
|
||||
* can forward as IC-1 `{ type: "state"; value: ... }` frames.
|
||||
*
|
||||
* Subscribes to:
|
||||
* - agent_start / agent_end → "thinking" / "idle"
|
||||
* - tool_start / tool_end → "tool" (with tool name)
|
||||
* - awaiting_input → "awaiting-input"
|
||||
*
|
||||
* Owner: T-1.4
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
/** IC-1 state values */
|
||||
export type AgentState = "thinking" | "tool" | "idle" | "awaiting-input";
|
||||
|
||||
export interface StateEvent {
|
||||
value: AgentState;
|
||||
tool?: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export type StateCallback = (event: StateEvent) => void;
|
||||
|
||||
/**
|
||||
* Subscribe to pi agent lifecycle events.
|
||||
*
|
||||
* Note: `pi.on()` returns void and has no unsubscribe mechanism — event
|
||||
* handlers are scoped to the extension lifetime, not to individual calls.
|
||||
* The returned function is a no-op kept for API compatibility.
|
||||
*/
|
||||
export function subscribeAgentEvents(
|
||||
pi: ExtensionAPI,
|
||||
onState: StateCallback,
|
||||
): () => void {
|
||||
// agent_start → thinking
|
||||
pi.on("agent_start", () => {
|
||||
onState({ value: "thinking", ts: Date.now() });
|
||||
});
|
||||
|
||||
// agent_end → idle
|
||||
pi.on("agent_end", () => {
|
||||
onState({ value: "idle", ts: Date.now() });
|
||||
});
|
||||
|
||||
// tool_execution_start → tool (carries toolName directly on the event)
|
||||
pi.on("tool_execution_start", (event) => {
|
||||
onState({ value: "tool", tool: event.toolName, ts: Date.now() });
|
||||
});
|
||||
|
||||
// tool_execution_end → thinking (agent loop continues after tool completes)
|
||||
pi.on("tool_execution_end", () => {
|
||||
onState({ value: "thinking", ts: Date.now() });
|
||||
});
|
||||
|
||||
// input → awaiting-input (fired when pi pauses to wait for user input)
|
||||
pi.on("input", () => {
|
||||
onState({ value: "awaiting-input", ts: Date.now() });
|
||||
});
|
||||
|
||||
return () => {
|
||||
// No-op: pi event subscriptions cannot be cancelled.
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Monotonic sequence number generator — shared by stream + buffer.
|
||||
*
|
||||
* Each chunk of output gets a unique, monotonically increasing seq number.
|
||||
* This lets clients resume a stream from a known position (IC-1 `lastSeq`).
|
||||
*
|
||||
* Owner: T-1.2
|
||||
*/
|
||||
|
||||
export type SeqNum = number; // safe JS integer, starts at 1
|
||||
|
||||
/**
|
||||
* Per-session sequence counter.
|
||||
* Create one instance per session; share between the buffer writer and the
|
||||
* WebSocket broadcaster.
|
||||
*/
|
||||
export class SequenceCounter {
|
||||
private current: SeqNum = 0;
|
||||
|
||||
/** Increment and return the next seq number. */
|
||||
next(): SeqNum {
|
||||
this.current += 1;
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/** Current value without incrementing. */
|
||||
peek(): SeqNum {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/** Reset (e.g. after session restart). */
|
||||
reset(): void {
|
||||
this.current = 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,335 +1,18 @@
|
|||
/**
|
||||
* HTTP + WebSocket server for remote-control.
|
||||
* LEGACY: re-export shim.
|
||||
*
|
||||
* Handles authentication, serves the web UI, and manages WebSocket connections
|
||||
* for real-time message streaming between the pi session and browser clients.
|
||||
* The server implementation has been moved to server/ sub-modules:
|
||||
* server/server.ts — HTTP bootstrap, TLS, middleware, LEGACY HTML routes
|
||||
* server/upgrade.ts — WebSocket upgrade routing per session/topic
|
||||
* server/types.ts — shared WS + RemoteServer type definitions
|
||||
* server/routes/ — route modules (populated by T-1.5/T-1.6/T-1.7)
|
||||
*
|
||||
* This shim is kept so that the existing import in index.ts
|
||||
* (`import { startServer } from "./server.js"`) continues to resolve
|
||||
* without modification. It will be removed once all consumers have been
|
||||
* updated to import directly from server/ sub-modules.
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { createServer } from "node:http";
|
||||
import { createRequire } from "node:module";
|
||||
import type { AddressInfo, Socket } from "node:net";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
generateSessionId,
|
||||
loadOrCreateToken,
|
||||
parseCookies,
|
||||
SESSION_COOKIE,
|
||||
validateToken,
|
||||
} from "./auth.js";
|
||||
import { parseBindAddress, readRemoteControlConfig } from "./config.js";
|
||||
import { buildHTML } from "./html.js";
|
||||
import { buildSyncMessage } from "./messages.js";
|
||||
|
||||
interface WsClient {
|
||||
readyState: number;
|
||||
send(data: string): void;
|
||||
terminate(): void;
|
||||
on(event: "message", listener: (data: Buffer) => void): void;
|
||||
on(event: "close" | "error", listener: () => void): void;
|
||||
}
|
||||
|
||||
interface WsServer {
|
||||
on(event: "connection", listener: (ws: WsClient) => void): void;
|
||||
on(event: "error", listener: (err: Error) => void): void;
|
||||
handleUpgrade(
|
||||
request: IncomingMessage,
|
||||
socket: Socket,
|
||||
head: Buffer,
|
||||
cb: (ws: WsClient) => void,
|
||||
): void;
|
||||
emit(event: string, ...args: unknown[]): void;
|
||||
close(cb?: () => void): void;
|
||||
}
|
||||
|
||||
// Load ws (bundled with pi) without needing @types/ws installed locally
|
||||
const _require = createRequire(import.meta.url);
|
||||
const wsModule = _require("ws") as {
|
||||
WebSocketServer: new (opts: { noServer: boolean }) => WsServer;
|
||||
OPEN: number;
|
||||
};
|
||||
const { WebSocketServer, OPEN } = wsModule;
|
||||
|
||||
export interface RemoteServer {
|
||||
broadcast: (msg: object) => void;
|
||||
sync: (ctx: ExtensionContext) => void;
|
||||
stop: () => Promise<void>;
|
||||
clientCount: () => number;
|
||||
onClientChange: (cb: () => void) => void;
|
||||
port: number;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export async function startServer(
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionContext,
|
||||
): Promise<RemoteServer> {
|
||||
const config = await readRemoteControlConfig();
|
||||
const bindAddr = config.bindAddress ?? "";
|
||||
const { host: bindHost, port: bindPort } = bindAddr
|
||||
? parseBindAddress(bindAddr)
|
||||
: { host: "127.0.0.1", port: 0 };
|
||||
const clientChangeListeners: Array<() => void> = [];
|
||||
const clients = new Set<WsClient>();
|
||||
const token = await loadOrCreateToken();
|
||||
// Map of valid session IDs → expiry timestamp (ms since epoch)
|
||||
const SESSION_TTL_MS = 86_400_000; // 24 h — matches cookie Max-Age
|
||||
const validSessions = new Map<string, number>();
|
||||
const pruneExpiredSessions = (): void => {
|
||||
const now = Date.now();
|
||||
for (const [id, expiresAt] of validSessions) {
|
||||
if (expiresAt <= now) validSessions.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
/** Check if a request is authenticated (valid token query param OR valid session cookie) */
|
||||
function isAuthenticated(req: IncomingMessage): boolean {
|
||||
// Check session cookie first
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
const sessionId = cookies[SESSION_COOKIE];
|
||||
const sessionExpiry = sessionId ? validSessions.get(sessionId) : undefined;
|
||||
if (sessionExpiry !== undefined && sessionExpiry > Date.now()) return true;
|
||||
|
||||
// Check token query param
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const providedToken = url.searchParams.get("token");
|
||||
if (providedToken && validateToken(providedToken, token)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function broadcast(msg: object): void {
|
||||
const data = JSON.stringify(msg);
|
||||
for (const client of clients) {
|
||||
if (client.readyState === OPEN) {
|
||||
try {
|
||||
client.send(data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sync(currentCtx: ExtensionContext): void {
|
||||
broadcast(buildSyncMessage(currentCtx));
|
||||
}
|
||||
|
||||
const httpServer = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname === "/manifest.json") {
|
||||
res.writeHead(200, { "Content-Type": "application/manifest+json; charset=utf-8" });
|
||||
res.end(JSON.stringify({
|
||||
name: "Pi Remote",
|
||||
short_name: "Pi",
|
||||
description: "Remote control for Pi sessions",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#0d1117",
|
||||
theme_color: "#0d1117",
|
||||
icons: [{ src: "/icon.svg", sizes: "any", type: "image/svg+xml" }],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/icon.svg") {
|
||||
res.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=86400" });
|
||||
res.end(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180"><rect width="180" height="180" rx="40" fill="#0d1117"/><text x="90" y="133" font-family="-apple-system,Helvetica,Arial,sans-serif" font-size="110" text-anchor="middle" fill="#3fb950">π</text></svg>`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/" || pathname === "/index.html") {
|
||||
// Check authentication
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
const sc = cookies[SESSION_COOKIE];
|
||||
const hasValidSession =
|
||||
sc !== undefined && (validSessions.get(sc) ?? 0) > Date.now();
|
||||
const providedToken = url.searchParams.get("token");
|
||||
const hasValidToken =
|
||||
providedToken && validateToken(providedToken, token);
|
||||
|
||||
if (!hasValidSession && !hasValidToken) {
|
||||
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end(
|
||||
"Forbidden — valid token required. Use the URL shown in the pi terminal.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If authenticated via token (first visit), issue a session cookie and redirect to clean URL
|
||||
if (!hasValidSession && hasValidToken) {
|
||||
pruneExpiredSessions();
|
||||
const sessionId = generateSessionId();
|
||||
validSessions.set(sessionId, Date.now() + SESSION_TTL_MS);
|
||||
res.writeHead(302, {
|
||||
"Set-Cookie": `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
|
||||
Location: "/",
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Valid session cookie — serve the page
|
||||
const nonce = randomBytes(16).toString("base64");
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; manifest-src 'self'; base-uri 'none'`,
|
||||
});
|
||||
res.end(buildHTML(nonce));
|
||||
} else {
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("Not found");
|
||||
}
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
httpServer.on("error", (err: Error) => {
|
||||
console.error("[remote-control] httpServer error:", err.message);
|
||||
});
|
||||
|
||||
wss.on("error", (err: Error) => {
|
||||
console.error("[remote-control] wss error:", err.message);
|
||||
});
|
||||
|
||||
httpServer.on(
|
||||
"upgrade",
|
||||
(request: IncomingMessage, socket: Socket, head: Buffer) => {
|
||||
const url = new URL(request.url, "http://localhost");
|
||||
if (url.pathname === "/ws") {
|
||||
// Validate auth: session cookie or token query param
|
||||
if (!isAuthenticated(request)) {
|
||||
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(request, socket, head, (ws: WsClient) => {
|
||||
wss.emit("connection", ws, request);
|
||||
});
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
wss.on("connection", (ws: WsClient) => {
|
||||
clients.add(ws);
|
||||
for (const cb of clientChangeListeners) cb();
|
||||
|
||||
// Send full state snapshot to the new client
|
||||
try {
|
||||
ws.send(JSON.stringify(buildSyncMessage(ctx)));
|
||||
} catch {
|
||||
/* client disconnected before first send */
|
||||
}
|
||||
|
||||
// Per-connection rate limiting: max 30 prompts per 60 seconds
|
||||
const RATE_WINDOW_MS = 60_000;
|
||||
const RATE_MAX = 30;
|
||||
const MAX_MSG_BYTES = 64 * 1024;
|
||||
const recentPrompts: number[] = [];
|
||||
|
||||
ws.on("message", (data: Buffer) => {
|
||||
if (data.length > MAX_MSG_BYTES) return;
|
||||
let msg: { type?: string; text?: string };
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(data.toString());
|
||||
if (typeof parsed !== "object" || parsed === null) return;
|
||||
msg = parsed as { type?: string; text?: string };
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.type === "stop") {
|
||||
if (!ctx.isIdle()) {
|
||||
ctx.abort();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
msg.type === "prompt" &&
|
||||
typeof msg.text === "string" &&
|
||||
msg.text.trim()
|
||||
) {
|
||||
const text = msg.text.trim();
|
||||
// Sliding-window rate limit
|
||||
const now = Date.now();
|
||||
const cutoff = now - RATE_WINDOW_MS;
|
||||
while (recentPrompts.length > 0 && recentPrompts[0] < cutoff)
|
||||
recentPrompts.shift();
|
||||
if (recentPrompts.length >= RATE_MAX) return;
|
||||
recentPrompts.push(now);
|
||||
if (ctx.isIdle()) {
|
||||
pi.sendUserMessage(text);
|
||||
} else {
|
||||
pi.sendUserMessage(text, { deliverAs: "followUp" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
clients.delete(ws);
|
||||
broadcast({ type: "status", clientCount: clients.size });
|
||||
for (const cb of clientChangeListeners) cb();
|
||||
};
|
||||
ws.on("close", onClose);
|
||||
ws.on("error", onClose);
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
httpServer.listen(bindPort, bindHost, () => {
|
||||
resolve({
|
||||
broadcast,
|
||||
sync,
|
||||
stop: () =>
|
||||
new Promise<void>((res) => {
|
||||
// Forcefully kill all WebSocket clients — terminate() sends no
|
||||
// close frame and doesn't wait for the remote end to acknowledge,
|
||||
// so it can't hang on an unresponsive client.
|
||||
for (const client of clients) {
|
||||
try {
|
||||
client.terminate();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
clients.clear();
|
||||
|
||||
// Safety timeout — if wss/http shutdown callbacks never fire
|
||||
// (e.g. lingering keep-alive sockets), resolve anyway so the
|
||||
// session_shutdown handler doesn't block pi from exiting.
|
||||
const timeout = setTimeout(() => {
|
||||
httpServer.close(() => {});
|
||||
httpServer.closeAllConnections?.();
|
||||
res();
|
||||
}, 2000);
|
||||
|
||||
wss.close(() =>
|
||||
httpServer.close(() => {
|
||||
clearTimeout(timeout);
|
||||
res();
|
||||
}),
|
||||
);
|
||||
}),
|
||||
clientCount: () => clients.size,
|
||||
onClientChange: (cb: () => void) => {
|
||||
clientChangeListeners.push(cb);
|
||||
},
|
||||
get port() {
|
||||
return (httpServer.address() as AddressInfo | null)?.port ?? 0;
|
||||
},
|
||||
get token() {
|
||||
return token;
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
export { startServer } from "./server/server.js";
|
||||
// LEGACY: re-exports for backward-compatibility
|
||||
export type { RemoteServer } from "./server/types.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* S-08 — slash-command registry route.
|
||||
*
|
||||
* GET /sessions/:id/commands → [{ name, description, args }]
|
||||
*
|
||||
* Returns the list of slash commands available in the current pi session.
|
||||
* Delegates to pi/commands.ts (T-1.4).
|
||||
*
|
||||
* Owner: T-1.6
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { getCommands } from "../../pi/commands.js";
|
||||
import { getSession } from "../../tmux/manager.js";
|
||||
import { sendJson } from "../util.js";
|
||||
|
||||
export async function handleCommands(
|
||||
_req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
sessionId: string,
|
||||
pi: ExtensionAPI,
|
||||
): Promise<void> {
|
||||
const session = await getSession(sessionId).catch(() => null);
|
||||
if (!session) {
|
||||
sendJson(res, 404, { error: "session_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const commands = await getCommands(pi);
|
||||
sendJson(res, 200, commands);
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { error: "internal_error", message: String(err) });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* S-12 — health endpoint.
|
||||
*
|
||||
* GET /health → { ok, sessions, bufferBytes, uptime, version }
|
||||
*
|
||||
* Also integrates the disk watchdog: on each health call we check free space
|
||||
* and total buffer size, returning a warning if caps are near.
|
||||
*
|
||||
* Owner: T-1.7
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { listSessions } from "../../tmux/manager.js";
|
||||
import { sendJson } from "../util.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const _startedAt = Date.now();
|
||||
|
||||
export interface HealthOptions {
|
||||
stateDir?: string;
|
||||
}
|
||||
|
||||
export async function handleHealth(
|
||||
_req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
opts: HealthOptions = {},
|
||||
): Promise<void> {
|
||||
const stateDir =
|
||||
opts.stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote");
|
||||
|
||||
const [sessions, bufferBytes, freeBytes] = await Promise.all([
|
||||
listSessions().catch(() => []),
|
||||
getTotalBufferBytes(stateDir),
|
||||
getFreeBytes(stateDir),
|
||||
]);
|
||||
|
||||
const freeGb = freeBytes / (1024 * 1024 * 1024);
|
||||
const bufferMb = bufferBytes / (1024 * 1024);
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
uptime: Math.floor((Date.now() - _startedAt) / 1000),
|
||||
sessions: sessions.length,
|
||||
sessionIds: sessions.map((s) => s.id),
|
||||
bufferMb: Math.round(bufferMb * 10) / 10,
|
||||
diskFreeGb: Math.round(freeGb * 10) / 10,
|
||||
warnings: buildWarnings(freeGb, bufferMb),
|
||||
});
|
||||
}
|
||||
|
||||
function buildWarnings(freeGb: number, bufferMb: number): string[] {
|
||||
const warnings: string[] = [];
|
||||
if (freeGb < 1) warnings.push(`Low disk space: ${freeGb.toFixed(1)} GB free`);
|
||||
if (bufferMb > 900)
|
||||
warnings.push(`Buffer near cap: ${bufferMb.toFixed(0)} MB used`);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
async function getTotalBufferBytes(stateDir: string): Promise<number> {
|
||||
const bufDir = path.join(stateDir, "buffers");
|
||||
try {
|
||||
const entries = await fs.readdir(bufDir, { withFileTypes: true });
|
||||
let total = 0;
|
||||
for (const e of entries) {
|
||||
if (!e.name.endsWith(".buf")) continue;
|
||||
try {
|
||||
const stat = await fs.stat(path.join(bufDir, e.name));
|
||||
total += stat.size;
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return total;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function getFreeBytes(dir: string): Promise<number> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("df", ["-k", dir]);
|
||||
const lines = stdout.trim().split("\n");
|
||||
const last = lines[lines.length - 1];
|
||||
const parts = last.split(/\s+/);
|
||||
const availKb = parseInt(parts[3], 10);
|
||||
return availKb * 1024;
|
||||
} catch {
|
||||
return Number.POSITIVE_INFINITY; // unknown — don't warn
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* S-03 — send-keys input route.
|
||||
*
|
||||
* POST /sessions/:id/input
|
||||
*
|
||||
* Body (IC-1 ClientToServer subset, but as HTTP POST for non-WS clients):
|
||||
* { type: "key"; name: string }
|
||||
* { type: "keys"; data: string }
|
||||
* { type: "paste"; data: string }
|
||||
*
|
||||
* Response: 204 No Content on success, 400 on bad input, 404 if session missing.
|
||||
*
|
||||
* Note: the primary path for send-keys is via WS (T-1.5 stream route handles
|
||||
* key/keys/paste messages inline). This HTTP endpoint is for clients that
|
||||
* don't have an open stream (e.g. one-shot CLI tools).
|
||||
*
|
||||
* Owner: T-1.5
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { sendKey, sendKeys, sendPaste } from "../../tmux/input.js";
|
||||
import { getSession } from "../../tmux/manager.js";
|
||||
import { readBody, sendJson } from "../util.js";
|
||||
|
||||
export async function handleInput(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const session = await getSession(sessionId).catch(() => null);
|
||||
if (!session) {
|
||||
sendJson(res, 404, {
|
||||
error: "session_not_found",
|
||||
message: `Session "${sessionId}" not found`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(await readBody(req));
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "bad_request", message: "Invalid JSON body" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!body || typeof body !== "object") {
|
||||
sendJson(res, 400, {
|
||||
error: "bad_request",
|
||||
message: "Body must be a JSON object",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const m = body as Record<string, unknown>;
|
||||
|
||||
try {
|
||||
switch (m.type) {
|
||||
case "key": {
|
||||
if (typeof m.name !== "string") {
|
||||
sendJson(res, 400, {
|
||||
error: "bad_request",
|
||||
message: "key.name must be a string",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await sendKey(sessionId, m.name);
|
||||
break;
|
||||
}
|
||||
case "keys": {
|
||||
if (typeof m.data !== "string") {
|
||||
sendJson(res, 400, {
|
||||
error: "bad_request",
|
||||
message: "keys.data must be a string",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await sendKeys(sessionId, m.data);
|
||||
break;
|
||||
}
|
||||
case "paste": {
|
||||
if (typeof m.data !== "string") {
|
||||
sendJson(res, 400, {
|
||||
error: "bad_request",
|
||||
message: "paste.data must be a string",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await sendPaste(sessionId, m.data);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
sendJson(res, 400, {
|
||||
error: "bad_request",
|
||||
message: `Unknown type: ${String(m.type)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { error: "internal_error", message: String(err) });
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(204).end();
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* S-09 — multi-session CRUD routes.
|
||||
*
|
||||
* POST /sessions → { id, name, state, lastOutputAt }
|
||||
* GET /sessions → [{ id, name, description, state, lastOutputAt }]
|
||||
* PATCH /sessions/:id → updates @description
|
||||
* DELETE /sessions/:id → kills tmux session, optionally clears buffer
|
||||
* GET /sessions/:id/thumbnail → text/plain capture-pane (40×12)
|
||||
*
|
||||
* Owner: T-1.6
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { BufferWriter } from "../../buffer/writer.js";
|
||||
import {
|
||||
getSession,
|
||||
killSession,
|
||||
listSessions,
|
||||
setDescription,
|
||||
spawnSession,
|
||||
} from "../../tmux/manager.js";
|
||||
import { captureThumbnail } from "../../tmux/snapshot.js";
|
||||
import { readBody, sendJson } from "../util.js";
|
||||
|
||||
export async function handleSessions(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
sessionId?: string,
|
||||
sub?: string,
|
||||
): Promise<void> {
|
||||
const method = req.method?.toUpperCase();
|
||||
|
||||
// GET /sessions/:id/thumbnail
|
||||
if (sessionId && sub === "thumbnail" && method === "GET") {
|
||||
const session = await getSession(sessionId).catch(() => null);
|
||||
if (!session) {
|
||||
sendJson(res, 404, { error: "session_not_found" });
|
||||
return;
|
||||
}
|
||||
const text = await captureThumbnail(sessionId).catch(() => "");
|
||||
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end(text);
|
||||
return;
|
||||
}
|
||||
|
||||
// /sessions/:id — PATCH / DELETE
|
||||
if (sessionId && !sub) {
|
||||
if (method === "PATCH") {
|
||||
await handlePatch(req, res, sessionId);
|
||||
return;
|
||||
}
|
||||
if (method === "DELETE") {
|
||||
await handleDelete(req, res, sessionId);
|
||||
return;
|
||||
}
|
||||
sendJson(res, 405, { error: "method_not_allowed" });
|
||||
return;
|
||||
}
|
||||
|
||||
// /sessions — GET / POST
|
||||
if (!sessionId) {
|
||||
if (method === "GET") {
|
||||
await handleList(res);
|
||||
return;
|
||||
}
|
||||
if (method === "POST") {
|
||||
await handleCreate(req, res);
|
||||
return;
|
||||
}
|
||||
sendJson(res, 405, { error: "method_not_allowed" });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 404, { error: "not_found" });
|
||||
}
|
||||
|
||||
async function handleList(res: ServerResponse): Promise<void> {
|
||||
try {
|
||||
const sessions = await listSessions();
|
||||
const payload = sessions.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
state: "idle", // T-1.4 events will feed real state in Phase 2
|
||||
lastOutputAt: s.lastActivityAt,
|
||||
}));
|
||||
sendJson(res, 200, payload);
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { error: "internal_error", message: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<void> {
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
const raw = await readBody(req);
|
||||
if (raw.trim()) body = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "bad_request", message: "Invalid JSON" });
|
||||
return;
|
||||
}
|
||||
|
||||
const name =
|
||||
typeof body.name === "string" && body.name.trim()
|
||||
? body.name.trim()
|
||||
: `session-${Date.now()}`;
|
||||
|
||||
try {
|
||||
const id = await spawnSession({ name });
|
||||
// Include state + lastOutputAt to match the GET /sessions response shape
|
||||
// so iOS clients can decode the response with the same type.
|
||||
sendJson(res, 201, { id, name, state: "idle", lastOutputAt: "" });
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { error: "internal_error", message: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePatch(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const session = await getSession(sessionId).catch(() => null);
|
||||
if (!session) {
|
||||
sendJson(res, 404, { error: "session_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
const raw = await readBody(req);
|
||||
if (raw.trim()) body = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "bad_request", message: "Invalid JSON" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof body.description === "string") {
|
||||
await setDescription(sessionId, body.description);
|
||||
}
|
||||
|
||||
sendJson(res, 200, { id: sessionId, description: body.description });
|
||||
}
|
||||
|
||||
async function handleDelete(
|
||||
_req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const session = await getSession(sessionId).catch(() => null);
|
||||
if (!session) {
|
||||
sendJson(res, 404, { error: "session_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await killSession(sessionId);
|
||||
// Optionally clear buffer
|
||||
const writer = new BufferWriter(sessionId);
|
||||
await writer.delete().catch(() => {}); // best-effort
|
||||
res.writeHead(204).end();
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { error: "internal_error", message: String(err) });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* S-07 — state side-channel route.
|
||||
*
|
||||
* WS endpoint: GET /sessions/:id/side
|
||||
*
|
||||
* Pushes IC-1 ServerToClient JSON frames (state, session-meta, error).
|
||||
* Does NOT carry binary output (that's /stream).
|
||||
* Lightweight channel for UI state updates without full output stream.
|
||||
*
|
||||
* Owner: T-1.6
|
||||
*/
|
||||
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { Socket } from "node:net";
|
||||
import type { StateEvent } from "../../pi/events.js";
|
||||
import { getSession } from "../../tmux/manager.js";
|
||||
import type { WsClient, WsServer } from "../types.js";
|
||||
|
||||
export interface SideRouteOptions {
|
||||
wss: WsServer;
|
||||
isAuthenticated: (req: IncomingMessage) => boolean;
|
||||
getCurrentState?: () => StateEvent | null;
|
||||
}
|
||||
|
||||
// Subscribers per session: sessionId → set of ws clients
|
||||
const _subscribers = new Map<string, Set<WsClient>>();
|
||||
|
||||
/**
|
||||
* Broadcast a state event to all side-channel subscribers of a session.
|
||||
*/
|
||||
export function broadcastState(sessionId: string, event: StateEvent): void {
|
||||
const subs = _subscribers.get(sessionId);
|
||||
if (!subs) return;
|
||||
const msg = JSON.stringify({
|
||||
type: "state",
|
||||
value: event.value,
|
||||
tool: event.tool,
|
||||
ts: event.ts,
|
||||
});
|
||||
for (const ws of subs) {
|
||||
if (ws.readyState === 1 /* OPEN */) {
|
||||
ws.send(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a WebSocket upgrade for /sessions/:id/side.
|
||||
*/
|
||||
export function handleSideUpgrade(
|
||||
sessionId: string,
|
||||
request: IncomingMessage,
|
||||
socket: Socket,
|
||||
head: Buffer,
|
||||
opts: SideRouteOptions,
|
||||
): void {
|
||||
opts.wss.handleUpgrade(request, socket, head, (ws: WsClient) => {
|
||||
handleSideConnection(sessionId, ws, opts);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSideConnection(
|
||||
sessionId: string,
|
||||
ws: WsClient,
|
||||
opts: SideRouteOptions,
|
||||
): Promise<void> {
|
||||
const session = await getSession(sessionId).catch(() => null);
|
||||
if (!session) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
code: "session_not_found",
|
||||
message: `Session "${sessionId}" not found`,
|
||||
}),
|
||||
);
|
||||
ws.terminate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Push session-meta on connect
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session-meta",
|
||||
name: session.name,
|
||||
description: session.description,
|
||||
createdAt: session.createdAt,
|
||||
}),
|
||||
);
|
||||
|
||||
// Push current state
|
||||
const currentState = opts.getCurrentState?.();
|
||||
if (currentState) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "state",
|
||||
value: currentState.value,
|
||||
tool: currentState.tool,
|
||||
ts: currentState.ts,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Register subscriber
|
||||
let subs = _subscribers.get(sessionId);
|
||||
if (!subs) {
|
||||
subs = new Set();
|
||||
_subscribers.set(sessionId, subs);
|
||||
}
|
||||
subs.add(ws);
|
||||
|
||||
ws.on("close", () => {
|
||||
subs?.delete(ws);
|
||||
if (subs?.size === 0) _subscribers.delete(sessionId);
|
||||
});
|
||||
|
||||
ws.on("error", () => {
|
||||
subs?.delete(ws);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
/**
|
||||
* S-02 binary stream + S-04 sequence cursor resume + S-05 snapshot route.
|
||||
*
|
||||
* WS endpoint: GET /sessions/:id/stream
|
||||
*
|
||||
* Protocol (IC-1):
|
||||
* - On connect: client sends { type: "resume"; lastSeq: number | null }
|
||||
* - Server replays buffer chunks after lastSeq (binary frames with 8-byte seq header)
|
||||
* - Then live output arrives as binary frames
|
||||
* - Client may send { type: "snapshot-request" } → server sends { type: "snapshot"; ... }
|
||||
* - State events pushed unsolicited: { type: "state"; ... }
|
||||
* - Session meta pushed on connect: { type: "session-meta"; ... }
|
||||
*
|
||||
* Binary frame format: [seq: 8 bytes BE uint64][data: N bytes]
|
||||
*
|
||||
* Owner: T-1.5
|
||||
*/
|
||||
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { Socket } from "node:net";
|
||||
import { readChunks } from "../../buffer/reader.js";
|
||||
import type { StateEvent } from "../../pi/events.js";
|
||||
import { SequenceCounter } from "../../sequence.js";
|
||||
import { ControlClient } from "../../tmux/control.js";
|
||||
import { sendKey, sendKeys, sendPaste } from "../../tmux/input.js";
|
||||
import { resizeSession } from "../../tmux/manager.js";
|
||||
import { capturePane } from "../../tmux/snapshot.js";
|
||||
import type { WsClient, WsServer } from "../types.js";
|
||||
|
||||
export interface StreamRouteOptions {
|
||||
wss: WsServer;
|
||||
isAuthenticated: (req: IncomingMessage) => boolean;
|
||||
/** Called to get the current agent state for new connections */
|
||||
getCurrentState?: () => StateEvent | null;
|
||||
stateDir?: string;
|
||||
}
|
||||
|
||||
// Per-session registry of active ControlClients and sequence counters
|
||||
const _clients = new Map<
|
||||
string,
|
||||
{ control: ControlClient; seq: SequenceCounter }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Get or create a ControlClient + SequenceCounter for a session.
|
||||
*/
|
||||
function getOrCreateSession(sessionId: string): {
|
||||
control: ControlClient;
|
||||
seq: SequenceCounter;
|
||||
} {
|
||||
const existing = _clients.get(sessionId);
|
||||
if (existing) return existing;
|
||||
|
||||
const seq = new SequenceCounter();
|
||||
const control = new ControlClient({
|
||||
session: sessionId,
|
||||
onClose: (reason) => {
|
||||
console.error(
|
||||
`[stream] control client closed for ${sessionId}: ${reason}`,
|
||||
);
|
||||
_clients.delete(sessionId);
|
||||
},
|
||||
});
|
||||
control.start();
|
||||
|
||||
const entry = { control, seq };
|
||||
_clients.set(sessionId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop and remove a session's ControlClient (call on session kill).
|
||||
*/
|
||||
export function stopSession(sessionId: string): void {
|
||||
const entry = _clients.get(sessionId);
|
||||
if (entry) {
|
||||
entry.control.stop();
|
||||
_clients.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a WebSocket upgrade for /sessions/:id/stream.
|
||||
*/
|
||||
export function handleStreamUpgrade(
|
||||
sessionId: string,
|
||||
request: IncomingMessage,
|
||||
socket: Socket,
|
||||
head: Buffer,
|
||||
opts: StreamRouteOptions,
|
||||
): void {
|
||||
opts.wss.handleUpgrade(request, socket, head, (ws: WsClient) => {
|
||||
handleStreamConnection(sessionId, ws, opts);
|
||||
});
|
||||
}
|
||||
|
||||
function handleStreamConnection(
|
||||
sessionId: string,
|
||||
ws: WsClient,
|
||||
opts: StreamRouteOptions,
|
||||
): void {
|
||||
const { control, seq } = getOrCreateSession(sessionId);
|
||||
let resumed = false;
|
||||
|
||||
// Push session-meta immediately
|
||||
sendJson(ws, {
|
||||
type: "session-meta",
|
||||
name: sessionId,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Push current state if available
|
||||
const currentState = opts.getCurrentState?.();
|
||||
if (currentState) {
|
||||
sendJson(ws, {
|
||||
type: "state",
|
||||
value: currentState.value,
|
||||
tool: currentState.tool,
|
||||
ts: currentState.ts,
|
||||
});
|
||||
}
|
||||
|
||||
// Subscribe to live output
|
||||
const unsubscribe = control.subscribe((chunk: Buffer) => {
|
||||
if (ws.readyState !== 1 /* OPEN */) return;
|
||||
const seqNum = seq.next();
|
||||
const frame = buildBinaryFrame(seqNum, chunk);
|
||||
sendBinary(ws, frame);
|
||||
});
|
||||
|
||||
// Handle client messages
|
||||
ws.on("message", (data: Buffer) => {
|
||||
let msg: unknown;
|
||||
try {
|
||||
msg = JSON.parse(data.toString());
|
||||
} catch {
|
||||
sendJson(ws, {
|
||||
type: "error",
|
||||
code: "bad_message",
|
||||
message: "Invalid JSON",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!msg || typeof msg !== "object") return;
|
||||
const m = msg as Record<string, unknown>;
|
||||
|
||||
if (m.type === "resume" && !resumed) {
|
||||
resumed = true;
|
||||
const lastSeq = typeof m.lastSeq === "number" ? m.lastSeq : 0;
|
||||
// Replay buffered chunks after lastSeq
|
||||
const chunks = readChunks(sessionId, {
|
||||
afterSeq: lastSeq,
|
||||
cfg: opts.stateDir ? { stateDir: opts.stateDir } : undefined,
|
||||
});
|
||||
for (const chunk of chunks) {
|
||||
if (ws.readyState !== 1) break;
|
||||
sendBinary(ws, buildBinaryFrame(chunk.seq, chunk.data));
|
||||
}
|
||||
} else if (m.type === "resize") {
|
||||
// IC-1 extension: client reports its actual terminal dimensions.
|
||||
// Resize the tmux window so line-wrapping matches what the client sees.
|
||||
const cols = typeof m.cols === "number" ? m.cols : 80;
|
||||
const rows = typeof m.rows === "number" ? m.rows : 24;
|
||||
resizeSession(sessionId, cols, rows).catch(() => {});
|
||||
} else if (m.type === "keys") {
|
||||
if (typeof m.data !== "string") {
|
||||
sendJson(ws, {
|
||||
type: "error",
|
||||
code: "bad_input",
|
||||
message: "keys.data must be a string",
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendKeys(sessionId, m.data).catch((err) => {
|
||||
sendJson(ws, {
|
||||
type: "error",
|
||||
code: "bad_input",
|
||||
message: `Failed to send keys: ${String(err)}`,
|
||||
});
|
||||
});
|
||||
} else if (m.type === "key") {
|
||||
if (typeof m.name !== "string") {
|
||||
sendJson(ws, {
|
||||
type: "error",
|
||||
code: "bad_input",
|
||||
message: "key.name must be a string",
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendKey(sessionId, m.name).catch((err) => {
|
||||
sendJson(ws, {
|
||||
type: "error",
|
||||
code: "bad_input",
|
||||
message: `Failed to send key: ${String(err)}`,
|
||||
});
|
||||
});
|
||||
} else if (m.type === "paste") {
|
||||
if (typeof m.data !== "string") {
|
||||
sendJson(ws, {
|
||||
type: "error",
|
||||
code: "bad_input",
|
||||
message: "paste.data must be a string",
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendPaste(sessionId, m.data).catch((err) => {
|
||||
sendJson(ws, {
|
||||
type: "error",
|
||||
code: "bad_input",
|
||||
message: `Failed to send paste: ${String(err)}`,
|
||||
});
|
||||
});
|
||||
} else if (m.type === "snapshot-request") {
|
||||
capturePane({ session: sessionId, escapes: true })
|
||||
.then((text) => {
|
||||
const data = Buffer.from(text).toString("base64");
|
||||
const s = seq.next();
|
||||
sendJson(ws, { type: "snapshot", seq: s, data });
|
||||
})
|
||||
.catch(() => {
|
||||
sendJson(ws, {
|
||||
type: "error",
|
||||
code: "snapshot_failed",
|
||||
message: "Failed to capture pane",
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
ws.on("error", () => {
|
||||
unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build a binary frame: [seq: 8 bytes BE][data] */
|
||||
function buildBinaryFrame(seqNum: number, data: Buffer): Buffer {
|
||||
const header = Buffer.allocUnsafe(8);
|
||||
header.writeBigUInt64BE(BigInt(seqNum), 0);
|
||||
return Buffer.concat([header, data]);
|
||||
}
|
||||
|
||||
function sendJson(ws: WsClient, msg: object): void {
|
||||
if (ws.readyState === 1 /* OPEN */) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
function sendBinary(ws: WsClient, data: Buffer): void {
|
||||
if (ws.readyState === 1 /* OPEN */) {
|
||||
(ws as unknown as { send(data: Buffer): void }).send(data);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,500 @@
|
|||
/**
|
||||
* HTTP + WebSocket server bootstrap for remote-control.
|
||||
*
|
||||
* Responsible for:
|
||||
* - Creating and configuring the HTTP server (middleware, TLS in future T-1.3)
|
||||
* - Wiring up the WebSocket upgrade handler (see upgrade.ts)
|
||||
* - Serving LEGACY routes for the browser HTML client (html.ts)
|
||||
* - Managing the connected-client set and the broadcast helper
|
||||
* - Returning a RemoteServer handle to the extension entry point (index.ts)
|
||||
*
|
||||
* LEGACY routes (kept until Phase 2 ships and html.ts is retired):
|
||||
* GET / — serves the browser HTML UI (buildHTML from html.ts)
|
||||
* GET /index.html — same as /
|
||||
* GET /manifest.json — PWA manifest
|
||||
* GET /icon.svg — PWA icon
|
||||
* WS /ws — WebSocket endpoint for the browser client
|
||||
*
|
||||
* Future route modules live under routes/ and are wired here once they
|
||||
* land in T-1.5, T-1.6, T-1.7.
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { createServer } from "node:http";
|
||||
import { createRequire } from "node:module";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
buildPairingUrl,
|
||||
consumePairingToken,
|
||||
generatePairingToken,
|
||||
printPairingQr,
|
||||
} from "../auth/pairing.js";
|
||||
import {
|
||||
createToken,
|
||||
validateBearer,
|
||||
validateBearerSync,
|
||||
warmTokenCache,
|
||||
} from "../auth/tokens.js";
|
||||
import {
|
||||
generateSessionId,
|
||||
loadOrCreateToken,
|
||||
parseCookies,
|
||||
SESSION_COOKIE,
|
||||
validateToken,
|
||||
} from "../auth.js";
|
||||
import { parseBindAddress, readRemoteControlConfig } from "../config.js";
|
||||
import { buildHTML } from "../html.js"; // LEGACY: browser HTML client
|
||||
import { buildSyncMessage } from "../messages.js";
|
||||
import { handleCommands } from "./routes/commands.js";
|
||||
import { handleHealth } from "./routes/health.js";
|
||||
import { handleInput } from "./routes/input.js";
|
||||
import { handleSessions } from "./routes/sessions.js";
|
||||
import type { RemoteServer, WsClient, WsServer } from "./types.js";
|
||||
import { createUpgradeHandler } from "./upgrade.js";
|
||||
import { extractBearer, readBody, sendJson } from "./util.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load ws (bundled with pi) without needing @types/ws installed locally
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const wsModule = _require("ws") as {
|
||||
WebSocketServer: new (opts: { noServer: boolean }) => WsServer;
|
||||
OPEN: number;
|
||||
};
|
||||
const { WebSocketServer, OPEN } = wsModule;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// startServer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function startServer(
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionContext,
|
||||
): Promise<RemoteServer> {
|
||||
const config = await readRemoteControlConfig();
|
||||
const bindAddr = config.bindAddress ?? "";
|
||||
const { host: bindHost, port: bindPort } = bindAddr
|
||||
? parseBindAddress(bindAddr)
|
||||
: { host: "127.0.0.1", port: 0 };
|
||||
|
||||
const clientChangeListeners: Array<() => void> = [];
|
||||
const clients = new Set<WsClient>();
|
||||
const token = await loadOrCreateToken();
|
||||
await warmTokenCache(); // pre-load multi-tokens into sync cache
|
||||
|
||||
// Map of valid session IDs → expiry timestamp (ms since epoch)
|
||||
const SESSION_TTL_MS = 86_400_000; // 24 h — matches cookie Max-Age
|
||||
const validSessions = new Map<string, number>();
|
||||
|
||||
const pruneExpiredSessions = (): void => {
|
||||
const now = Date.now();
|
||||
for (const [id, expiresAt] of validSessions) {
|
||||
if (expiresAt <= now) validSessions.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
/** Check if a request is authenticated.
|
||||
* Accepts: session cookie | legacy ?token= | Authorization: Bearer <multi-token>
|
||||
*/
|
||||
function isAuthenticated(req: IncomingMessage): boolean {
|
||||
// 1. Session cookie (legacy browser HTML client)
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
const sessionId = cookies[SESSION_COOKIE];
|
||||
const sessionExpiry = sessionId ? validSessions.get(sessionId) : undefined;
|
||||
if (sessionExpiry !== undefined && sessionExpiry > Date.now()) return true;
|
||||
|
||||
// 2. Legacy single token via query param (smoke tests, CLI)
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const providedToken = url.searchParams.get("token");
|
||||
if (providedToken && validateToken(providedToken, token)) return true;
|
||||
|
||||
// 3. Bearer token via Authorization header or ?token= (iOS app, multi-token)
|
||||
const bearer = extractBearer(req);
|
||||
if (bearer) {
|
||||
if (validateToken(bearer, token)) return true;
|
||||
if (validateBearerSync(bearer)) return true; // multi-token sync cache
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Async auth check — also validates multi-token bearer from auth/tokens store */
|
||||
async function isAuthenticatedAsync(req: IncomingMessage): Promise<boolean> {
|
||||
if (isAuthenticated(req)) return true;
|
||||
const bearer = extractBearer(req);
|
||||
if (bearer) {
|
||||
const entry = await validateBearer(bearer).catch(() => null);
|
||||
if (entry) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function broadcast(msg: object): void {
|
||||
const data = JSON.stringify(msg);
|
||||
for (const client of clients) {
|
||||
if (client.readyState === OPEN) {
|
||||
try {
|
||||
client.send(data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sync(currentCtx: ExtensionContext): void {
|
||||
broadcast(buildSyncMessage(currentCtx));
|
||||
}
|
||||
|
||||
// ── HTTP server ─────────────────────────────────────────────────────────
|
||||
|
||||
const httpServer = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const pathname = url.pathname;
|
||||
|
||||
// LEGACY: PWA manifest — served for the browser HTML client
|
||||
if (pathname === "/manifest.json") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/manifest+json; charset=utf-8",
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
name: "Pi Remote",
|
||||
short_name: "Pi",
|
||||
description: "Remote control for Pi sessions",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#0d1117",
|
||||
theme_color: "#0d1117",
|
||||
icons: [{ src: "/icon.svg", sizes: "any", type: "image/svg+xml" }],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// LEGACY: PWA icon — served for the browser HTML client
|
||||
if (pathname === "/icon.svg") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "image/svg+xml",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
});
|
||||
res.end(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180"><rect width="180" height="180" rx="40" fill="#0d1117"/><text x="90" y="133" font-family="-apple-system,Helvetica,Arial,sans-serif" font-size="110" text-anchor="middle" fill="#3fb950">π</text></svg>`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// LEGACY: browser HTML client root route
|
||||
if (pathname === "/" || pathname === "/index.html") {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
const sc = cookies[SESSION_COOKIE];
|
||||
const hasValidSession =
|
||||
sc !== undefined && (validSessions.get(sc) ?? 0) > Date.now();
|
||||
const providedToken = url.searchParams.get("token");
|
||||
const hasValidToken =
|
||||
providedToken && validateToken(providedToken, token);
|
||||
|
||||
if (!hasValidSession && !hasValidToken) {
|
||||
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end(
|
||||
"Forbidden — valid token required. Use the URL shown in the pi terminal.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If authenticated via token (first visit), issue a session cookie and redirect to clean URL
|
||||
if (!hasValidSession && hasValidToken) {
|
||||
pruneExpiredSessions();
|
||||
const sessionId = generateSessionId();
|
||||
validSessions.set(sessionId, Date.now() + SESSION_TTL_MS);
|
||||
res.writeHead(302, {
|
||||
"Set-Cookie": `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
|
||||
Location: "/",
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Valid session cookie — serve the browser HTML UI
|
||||
const nonce = randomBytes(16).toString("base64");
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; manifest-src 'self'; base-uri 'none'`,
|
||||
});
|
||||
res.end(buildHTML(nonce)); // LEGACY: renders the browser HTML client
|
||||
return;
|
||||
}
|
||||
|
||||
// New API routes (IC-2) — bearer token auth
|
||||
const asyncHandler = async (): Promise<boolean> => {
|
||||
// GET /pair-qr — generates pairing token + prints QR to response
|
||||
if (pathname === "/pair-qr" && req.method === "GET") {
|
||||
if (!isAuthenticated(req)) {
|
||||
sendJson(res, 403, { error: "forbidden" });
|
||||
return true;
|
||||
}
|
||||
const addr = httpServer.address() as {
|
||||
address: string;
|
||||
port: number;
|
||||
} | null;
|
||||
const port = addr?.port ?? 7777;
|
||||
const rawHost =
|
||||
(config.publicBaseUrl ?? config.advertisedBaseUrl)
|
||||
?.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "") ??
|
||||
addr?.address ??
|
||||
"localhost";
|
||||
// Strip :port suffix if publicBaseUrl already contains it
|
||||
const host = rawHost.includes(":")
|
||||
? rawHost.slice(0, rawHost.lastIndexOf(":"))
|
||||
: rawHost;
|
||||
const pairingEntry = generatePairingToken();
|
||||
const url = buildPairingUrl({
|
||||
host,
|
||||
port,
|
||||
pairingToken: pairingEntry.token,
|
||||
fingerprint: "NO-TLS-YET-REPLACE-IN-T-1-3",
|
||||
sidecarName: "pi-remote",
|
||||
});
|
||||
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
// Render QR inline
|
||||
const qrcode = await import("qrcode");
|
||||
const qr = await qrcode.toString(url, {
|
||||
type: "terminal",
|
||||
small: true,
|
||||
});
|
||||
res.end(`${qr}\n${url}\n\nExpires in 10 minutes.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /pair — unauthenticated, uses one-time pairingToken
|
||||
if (pathname === "/pair" && req.method === "POST") {
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
const raw = await readBody(req);
|
||||
if (raw.trim()) body = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "bad_request", message: "Invalid JSON" });
|
||||
return true;
|
||||
}
|
||||
const pairingToken =
|
||||
typeof body.pairingToken === "string" ? body.pairingToken : null;
|
||||
if (!pairingToken || !consumePairingToken(pairingToken)) {
|
||||
sendJson(res, 403, {
|
||||
error: "invalid_pairing_token",
|
||||
message: "Pairing token invalid or expired",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const deviceName =
|
||||
typeof body.deviceName === "string" ? body.deviceName : "iOS device";
|
||||
const entry = await createToken(deviceName);
|
||||
sendJson(res, 200, { bearerToken: entry.token, sidecarId: entry.id });
|
||||
return true;
|
||||
}
|
||||
|
||||
// All other API routes require auth
|
||||
const isApiAuthed = await isAuthenticatedAsync(req);
|
||||
if (!isApiAuthed) return false;
|
||||
|
||||
// GET /health
|
||||
if (pathname === "/health" && req.method === "GET") {
|
||||
await handleHealth(req, res);
|
||||
return true;
|
||||
}
|
||||
|
||||
// /sessions — list + create
|
||||
if (pathname === "/sessions") {
|
||||
await handleSessions(req, res);
|
||||
return true;
|
||||
}
|
||||
|
||||
// /sessions/:id — PATCH, DELETE
|
||||
const sessMatch = pathname.match(/^\/sessions\/([^/]+)$/);
|
||||
if (sessMatch) {
|
||||
await handleSessions(req, res, decodeURIComponent(sessMatch[1]));
|
||||
return true;
|
||||
}
|
||||
|
||||
// /sessions/:id/thumbnail
|
||||
const thumbMatch = pathname.match(/^\/sessions\/([^/]+)\/thumbnail$/);
|
||||
if (thumbMatch) {
|
||||
await handleSessions(
|
||||
req,
|
||||
res,
|
||||
decodeURIComponent(thumbMatch[1]),
|
||||
"thumbnail",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// /sessions/:id/commands
|
||||
const cmdMatch = pathname.match(/^\/sessions\/([^/]+)\/commands$/);
|
||||
if (cmdMatch) {
|
||||
await handleCommands(req, res, decodeURIComponent(cmdMatch[1]), pi);
|
||||
return true;
|
||||
}
|
||||
|
||||
// /sessions/:id/input
|
||||
const inputMatch = pathname.match(/^\/sessions\/([^/]+)\/input$/);
|
||||
if (inputMatch) {
|
||||
await handleInput(req, res, decodeURIComponent(inputMatch[1]));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
asyncHandler()
|
||||
.then((handled) => {
|
||||
if (!handled) {
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("Not found");
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end(err.message);
|
||||
});
|
||||
});
|
||||
|
||||
// ── WebSocket server ────────────────────────────────────────────────────
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
httpServer.on("error", (err: Error) => {
|
||||
console.error("[remote-control] httpServer error:", err.message);
|
||||
});
|
||||
|
||||
wss.on("error", (err: Error) => {
|
||||
console.error("[remote-control] wss error:", err.message);
|
||||
});
|
||||
|
||||
// Wire upgrade handler (see upgrade.ts for path routing)
|
||||
httpServer.on("upgrade", createUpgradeHandler(wss, isAuthenticated));
|
||||
|
||||
// ── LEGACY: browser HTML client WebSocket connection handling ───────────
|
||||
|
||||
wss.on("connection", (ws: WsClient) => {
|
||||
clients.add(ws);
|
||||
for (const cb of clientChangeListeners) cb();
|
||||
|
||||
// Send full state snapshot to the new client
|
||||
try {
|
||||
ws.send(JSON.stringify(buildSyncMessage(ctx)));
|
||||
} catch {
|
||||
/* client disconnected before first send */
|
||||
}
|
||||
|
||||
// Per-connection rate limiting: max 30 prompts per 60 seconds
|
||||
const RATE_WINDOW_MS = 60_000;
|
||||
const RATE_MAX = 30;
|
||||
const MAX_MSG_BYTES = 64 * 1024;
|
||||
const recentPrompts: number[] = [];
|
||||
|
||||
ws.on("message", (data: Buffer) => {
|
||||
if (data.length > MAX_MSG_BYTES) return;
|
||||
let msg: { type?: string; text?: string };
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(data.toString());
|
||||
if (typeof parsed !== "object" || parsed === null) return;
|
||||
msg = parsed as { type?: string; text?: string };
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.type === "stop") {
|
||||
if (!ctx.isIdle()) {
|
||||
ctx.abort();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
msg.type === "prompt" &&
|
||||
typeof msg.text === "string" &&
|
||||
msg.text.trim()
|
||||
) {
|
||||
const text = msg.text.trim();
|
||||
// Sliding-window rate limit
|
||||
const now = Date.now();
|
||||
const cutoff = now - RATE_WINDOW_MS;
|
||||
while (recentPrompts.length > 0 && recentPrompts[0] < cutoff)
|
||||
recentPrompts.shift();
|
||||
if (recentPrompts.length >= RATE_MAX) return;
|
||||
recentPrompts.push(now);
|
||||
if (ctx.isIdle()) {
|
||||
pi.sendUserMessage(text);
|
||||
} else {
|
||||
pi.sendUserMessage(text, { deliverAs: "followUp" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const onClose = (): void => {
|
||||
clients.delete(ws);
|
||||
broadcast({ type: "status", clientCount: clients.size });
|
||||
for (const cb of clientChangeListeners) cb();
|
||||
};
|
||||
ws.on("close", onClose);
|
||||
ws.on("error", onClose);
|
||||
});
|
||||
|
||||
// ── Listen ───────────────────────────────────────────────────────────────
|
||||
|
||||
return new Promise((resolve) => {
|
||||
httpServer.listen(bindPort, bindHost, () => {
|
||||
resolve({
|
||||
broadcast,
|
||||
sync,
|
||||
stop: () =>
|
||||
new Promise<void>((res) => {
|
||||
// Forcefully kill all WebSocket clients — terminate() sends no
|
||||
// close frame and doesn't wait for the remote end to acknowledge,
|
||||
// so it can't hang on an unresponsive client.
|
||||
for (const client of clients) {
|
||||
try {
|
||||
client.terminate();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
clients.clear();
|
||||
|
||||
// Safety timeout — if wss/http shutdown callbacks never fire
|
||||
// (e.g. lingering keep-alive sockets), resolve anyway so the
|
||||
// session_shutdown handler doesn't block pi from exiting.
|
||||
const timeout = setTimeout(() => {
|
||||
httpServer.close(() => {});
|
||||
httpServer.closeAllConnections?.();
|
||||
res();
|
||||
}, 2000);
|
||||
|
||||
wss.close(() =>
|
||||
httpServer.close(() => {
|
||||
clearTimeout(timeout);
|
||||
res();
|
||||
}),
|
||||
);
|
||||
}),
|
||||
clientCount: () => clients.size,
|
||||
onClientChange: (cb: () => void) => {
|
||||
clientChangeListeners.push(cb);
|
||||
},
|
||||
get port() {
|
||||
return (httpServer.address() as AddressInfo | null)?.port ?? 0;
|
||||
},
|
||||
get token() {
|
||||
return token;
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Shared type definitions for the remote-control server.
|
||||
*
|
||||
* These interfaces describe the WebSocket client/server shapes loaded
|
||||
* dynamically from the `ws` module bundled with pi, and the public surface
|
||||
* of the HTTP+WS server returned to callers.
|
||||
*/
|
||||
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { Socket } from "node:net";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket interfaces (ws module loaded via createRequire — no @types/ws)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WsClient {
|
||||
readyState: number;
|
||||
send(data: string): void;
|
||||
terminate(): void;
|
||||
on(event: "message", listener: (data: Buffer) => void): void;
|
||||
on(event: "close" | "error", listener: () => void): void;
|
||||
}
|
||||
|
||||
export interface WsServer {
|
||||
on(event: "connection", listener: (ws: WsClient) => void): void;
|
||||
on(event: "error", listener: (err: Error) => void): void;
|
||||
handleUpgrade(
|
||||
request: IncomingMessage,
|
||||
socket: Socket,
|
||||
head: Buffer,
|
||||
cb: (ws: WsClient) => void,
|
||||
): void;
|
||||
emit(event: string, ...args: unknown[]): void;
|
||||
close(cb?: () => void): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public server handle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RemoteServer {
|
||||
broadcast: (msg: object) => void;
|
||||
sync: (
|
||||
ctx: import("@earendil-works/pi-coding-agent").ExtensionContext,
|
||||
) => void;
|
||||
stop: () => Promise<void>;
|
||||
clientCount: () => number;
|
||||
onClientChange: (cb: () => void) => void;
|
||||
port: number;
|
||||
token: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* WebSocket upgrade routing.
|
||||
*
|
||||
* Routes incoming HTTP Upgrade requests to the appropriate WebSocket handler
|
||||
* based on the request path and session/topic. Non-matching paths are
|
||||
* destroyed immediately.
|
||||
*
|
||||
* Routes:
|
||||
* /ws — LEGACY browser HTML client WebSocket endpoint
|
||||
* /sessions/:id/stream — binary ANSI stream per tmux session (T-1.5)
|
||||
*/
|
||||
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { Socket } from "node:net";
|
||||
import { handleSideUpgrade, type SideRouteOptions } from "./routes/side.js";
|
||||
import {
|
||||
handleStreamUpgrade,
|
||||
type StreamRouteOptions,
|
||||
} from "./routes/stream.js";
|
||||
import type { WsClient, WsServer } from "./types.js";
|
||||
|
||||
export interface UpgradeHandlerOptions {
|
||||
wss: WsServer;
|
||||
isAuthenticated: (req: IncomingMessage) => boolean;
|
||||
stream: Omit<StreamRouteOptions, "wss" | "isAuthenticated">;
|
||||
side: Omit<SideRouteOptions, "wss" | "isAuthenticated">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the HTTP `upgrade` event handler.
|
||||
*
|
||||
* @param opts - Handler options including wss, auth predicate, and stream config.
|
||||
* @returns A handler suitable for `httpServer.on("upgrade", handler)`.
|
||||
*/
|
||||
export function createUpgradeHandler(
|
||||
wss: WsServer,
|
||||
isAuthenticated: (req: IncomingMessage) => boolean,
|
||||
streamOpts?: Omit<StreamRouteOptions, "wss" | "isAuthenticated">,
|
||||
): (request: IncomingMessage, socket: Socket, head: Buffer) => void {
|
||||
return (request: IncomingMessage, socket: Socket, head: Buffer): void => {
|
||||
const url = new URL(request.url ?? "/", "http://localhost");
|
||||
|
||||
if (url.pathname === "/ws") {
|
||||
// LEGACY: browser HTML client WebSocket endpoint — auth guard
|
||||
if (!isAuthenticated(request)) {
|
||||
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(request, socket, head, (ws: WsClient) => {
|
||||
wss.emit("connection", ws, request);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// /sessions/:id/stream
|
||||
const streamMatch = url.pathname.match(/^\/sessions\/([^/]+)\/stream$/);
|
||||
if (streamMatch) {
|
||||
if (!isAuthenticated(request)) {
|
||||
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const sessionId = decodeURIComponent(streamMatch[1]);
|
||||
handleStreamUpgrade(sessionId, request, socket, head, {
|
||||
wss,
|
||||
isAuthenticated,
|
||||
...streamOpts,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// /sessions/:id/side
|
||||
const sideMatch = url.pathname.match(/^\/sessions\/([^/]+)\/side$/);
|
||||
if (sideMatch) {
|
||||
if (!isAuthenticated(request)) {
|
||||
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const sessionId = decodeURIComponent(sideMatch[1]);
|
||||
handleSideUpgrade(sessionId, request, socket, head, {
|
||||
wss,
|
||||
isAuthenticated,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown upgrade path — reject
|
||||
socket.destroy();
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Shared HTTP server utilities for route handlers.
|
||||
* Owner: T-1.5
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
/** Read the full request body as a string. */
|
||||
export function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Send a JSON response. */
|
||||
export function sendJson(
|
||||
res: ServerResponse,
|
||||
status: number,
|
||||
body: unknown,
|
||||
): void {
|
||||
const payload = JSON.stringify(body);
|
||||
res.writeHead(status, {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(payload),
|
||||
});
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
/** Extract a path segment by index (0-based, after splitting on '/'). */
|
||||
export function pathSegment(url: string, index: number): string | undefined {
|
||||
return url.split("?")[0].split("/").filter(Boolean)[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse bearer token from Authorization header or ?token= query param.
|
||||
* Returns the raw token string or null.
|
||||
*/
|
||||
export function extractBearer(req: IncomingMessage): string | null {
|
||||
const auth = req.headers["authorization"];
|
||||
if (auth?.startsWith("Bearer ")) return auth.slice(7).trim();
|
||||
if (auth?.startsWith("bearer ")) return auth.slice(7).trim();
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
return url.searchParams.get("token");
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* tmux control-mode client — per-session.
|
||||
*
|
||||
* Spawns `tmux -C attach -t <session>`, parses `%output` notifications,
|
||||
* decodes octal-escaped bytes, and broadcasts raw ANSI to subscribers.
|
||||
*
|
||||
* Design:
|
||||
* - One ControlClient instance per tmux session (per-session, not per-server).
|
||||
* - Subscribers register a callback; each raw Buffer chunk is broadcast.
|
||||
* - On %exit or process close, all subscribers are notified and removed.
|
||||
*
|
||||
* Reference: feat/spike-tmux-cc / spike-cc.ts (Phase 0.5 PoC).
|
||||
*
|
||||
* Risk mitigations:
|
||||
* R4: streaming line-parser, per-line decode, no full-buffer copies.
|
||||
*
|
||||
* Owner: T-1.1
|
||||
*/
|
||||
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
export type OutputCallback = (chunk: Buffer) => void;
|
||||
export type CloseCallback = (reason: string) => void;
|
||||
|
||||
export interface ControlClientOptions {
|
||||
session: string;
|
||||
onClose?: CloseCallback;
|
||||
}
|
||||
|
||||
export class ControlClient {
|
||||
readonly session: string;
|
||||
private proc: ChildProcess | null = null;
|
||||
private subscribers = new Map<symbol, OutputCallback>();
|
||||
private closed = false;
|
||||
private onClose?: CloseCallback;
|
||||
|
||||
constructor(opts: ControlClientOptions) {
|
||||
this.session = opts.session;
|
||||
this.onClose = opts.onClose;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
start(): void {
|
||||
if (this.proc) return;
|
||||
this.closed = false;
|
||||
|
||||
// -C = control mode; tmux sends %output events for pane data (do NOT use -CC which bypasses %output)
|
||||
this.proc = spawn("tmux", ["-C", "attach", "-t", this.session], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const rl = createInterface({
|
||||
// biome-ignore lint/style/noNonNullAssertion: stdout is always set when stdio includes 'pipe'
|
||||
input: this.proc.stdout!,
|
||||
crlfDelay: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
rl.on("line", (line: string) => {
|
||||
this.parseLine(line);
|
||||
});
|
||||
|
||||
this.proc.stderr?.on("data", (_d: Buffer) => {
|
||||
// Ignore tmux stderr (status messages). Can log at debug level if needed.
|
||||
});
|
||||
|
||||
this.proc.on("close", (code: number | null) => {
|
||||
this.closed = true;
|
||||
this.subscribers.clear();
|
||||
this.onClose?.(`tmux process exited (code=${code})`);
|
||||
});
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.proc && !this.closed) {
|
||||
this.proc.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return !this.closed && this.proc !== null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subscriptions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
subscribe(cb: OutputCallback): () => void {
|
||||
const key = Symbol();
|
||||
this.subscribers.set(key, cb);
|
||||
return () => this.subscribers.delete(key);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse one line of tmux control-mode output.
|
||||
*
|
||||
* Control-mode lines that matter:
|
||||
* %output %<pane-id> <octal-escaped-bytes>
|
||||
* %exit [reason]
|
||||
* Everything else is ignored.
|
||||
*/
|
||||
private parseLine(line: string): void {
|
||||
if (!line.startsWith("%")) return;
|
||||
|
||||
const spaceIdx = line.indexOf(" ");
|
||||
const type = spaceIdx === -1 ? line.slice(1) : line.slice(1, spaceIdx);
|
||||
const rest = spaceIdx === -1 ? "" : line.slice(spaceIdx + 1);
|
||||
|
||||
if (type === "output") {
|
||||
this.handleOutput(rest);
|
||||
} else if (type === "exit") {
|
||||
this.closed = true;
|
||||
this.subscribers.clear();
|
||||
this.onClose?.(`%exit ${rest}`);
|
||||
}
|
||||
// layout-change, window-add, etc. are ignored
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a %output notification.
|
||||
* Format: %<pane-id> <octal-escaped-value>
|
||||
*/
|
||||
private handleOutput(data: string): void {
|
||||
const spaceIdx = data.indexOf(" ");
|
||||
if (spaceIdx === -1) return; // malformed, skip
|
||||
|
||||
const escapedValue = data.slice(spaceIdx + 1);
|
||||
const decoded = decodeOctalEscapes(escapedValue);
|
||||
if (decoded.length === 0) return;
|
||||
|
||||
for (const cb of this.subscribers.values()) {
|
||||
cb(decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Octal-escape decoder (from spike-cc.ts, adapted)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Decode tmux's octal-escaped output format.
|
||||
* "hello\\012world" → Buffer containing "hello\nworld"
|
||||
*/
|
||||
export function decodeOctalEscapes(input: string): Buffer {
|
||||
// Fast-path: nothing to decode
|
||||
if (!input.includes("\\")) return Buffer.from(input, "binary");
|
||||
|
||||
const bytes: number[] = [];
|
||||
let i = 0;
|
||||
while (i < input.length) {
|
||||
if (input[i] === "\\" && i + 3 < input.length) {
|
||||
const oct = input.slice(i + 1, i + 4);
|
||||
if (/^[0-7]{3}$/.test(oct)) {
|
||||
bytes.push(parseInt(oct, 8));
|
||||
i += 4;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
bytes.push(input.charCodeAt(i));
|
||||
i++;
|
||||
}
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* tmux send-keys input translation.
|
||||
*
|
||||
* Translates IC-1 key names (and literal text) into tmux send-keys arguments.
|
||||
* Used by the input route (T-1.5) to deliver keystrokes to a pane.
|
||||
*
|
||||
* Owner: T-1.1
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Named keys from IC-1 ClientToServer `{ type: "key"; name: string }`. */
|
||||
const KEY_MAP: Record<string, string> = {
|
||||
escape: "Escape",
|
||||
tab: "Tab",
|
||||
up: "Up",
|
||||
down: "Down",
|
||||
left: "Left",
|
||||
right: "Right",
|
||||
enter: "Enter",
|
||||
"shift-enter": "S-Enter",
|
||||
backspace: "BSpace",
|
||||
"ctrl-c": "C-c",
|
||||
"ctrl-d": "C-d",
|
||||
"ctrl-z": "C-z",
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a single named key to a tmux pane.
|
||||
* Pane defaults to the first pane of the session (session:0.0).
|
||||
*/
|
||||
export async function sendKey(session: string, name: string): Promise<void> {
|
||||
const tmuxKey = KEY_MAP[name.toLowerCase()];
|
||||
if (!tmuxKey) {
|
||||
throw new Error(
|
||||
`Unknown key name: "${name}". Supported: ${Object.keys(KEY_MAP).join(", ")}`,
|
||||
);
|
||||
}
|
||||
// Target just the session — tmux selects the active window/pane automatically.
|
||||
// Avoids base-index issues (user's tmux.conf may start windows at 1, not 0).
|
||||
await execFileAsync("tmux", ["send-keys", "-t", session, tmuxKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send literal text to a tmux pane (IC-1 `{ type: "keys"; data: string }`).
|
||||
* Uses send-keys -l which sends each character literally.
|
||||
*/
|
||||
export async function sendKeys(session: string, data: string): Promise<void> {
|
||||
await execFileAsync("tmux", ["send-keys", "-t", session, "-l", data]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send bracketed-paste to a tmux pane (IC-1 `{ type: "paste"; data: string }`).
|
||||
* Wraps the data in bracketed-paste sequences then sends literally.
|
||||
*/
|
||||
export async function sendPaste(session: string, data: string): Promise<void> {
|
||||
const wrapped = `\x1b[200~${data}\x1b[201~`;
|
||||
await execFileAsync("tmux", ["send-keys", "-t", session, "-l", wrapped]);
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* tmux session manager.
|
||||
*
|
||||
* Spawn, list, kill sessions and read metadata stored via tmux @description
|
||||
* option. Checks tmux version at startup (requires >= 2.5).
|
||||
*
|
||||
* Owner: T-1.1
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface TmuxSession {
|
||||
id: string; // tmux session name (used as our ID)
|
||||
name: string; // human name (same as id for now; T-1.4 may rename via @description)
|
||||
description?: string; // from tmux @description option
|
||||
createdAt: string; // ISO string, from tmux session_created_string
|
||||
lastActivityAt: string; // ISO string, from tmux session_last_attached
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let versionChecked = false;
|
||||
|
||||
export async function checkTmuxVersion(): Promise<void> {
|
||||
if (versionChecked) return;
|
||||
const { stdout } = await execFileAsync("tmux", ["-V"]);
|
||||
const match = stdout.trim().match(/tmux (\d+)\.(\d+)/);
|
||||
if (!match) throw new Error(`Cannot parse tmux version: ${stdout.trim()}`);
|
||||
const major = parseInt(match[1], 10);
|
||||
const minor = parseInt(match[2], 10);
|
||||
if (major < 2 || (major === 2 && minor < 5)) {
|
||||
throw new Error(
|
||||
`tmux >= 2.5 required (found ${stdout.trim()}). Upgrade tmux to use pi-remote-control.`,
|
||||
);
|
||||
}
|
||||
versionChecked = true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Spawn a new detached tmux session.
|
||||
* Returns the session name (used as stable ID).
|
||||
*/
|
||||
export async function spawnSession(opts: {
|
||||
name: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
command?: string;
|
||||
}): Promise<string> {
|
||||
await checkTmuxVersion();
|
||||
const { name, width = 80, height = 24, command = "" } = opts;
|
||||
|
||||
// Set default-terminal globally so programs inside tmux get xterm-256color
|
||||
// and emit the escape sequences that SwiftTerm / xterm-compatible clients expect.
|
||||
await execFileAsync("tmux", [
|
||||
"set-option",
|
||||
"-g",
|
||||
"default-terminal",
|
||||
"xterm-256color",
|
||||
]).catch(() => {}); // best-effort; older tmux may not support all options
|
||||
|
||||
const args = [
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
name,
|
||||
"-x",
|
||||
String(width),
|
||||
"-y",
|
||||
String(height),
|
||||
];
|
||||
if (command) args.push(command);
|
||||
|
||||
await execFileAsync("tmux", args);
|
||||
|
||||
// Mark as sidecar-managed so listSessions() can filter out unrelated
|
||||
// tmux sessions (e.g. the pi-sidecar launcher session itself).
|
||||
await execFileAsync("tmux", [
|
||||
"set-option",
|
||||
"-t",
|
||||
name,
|
||||
"@pi-remote-managed",
|
||||
"1",
|
||||
]).catch(() => {});
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an existing session's window.
|
||||
* Safe to call at any time; silently ignores unknown sessions.
|
||||
*/
|
||||
export async function resizeSession(
|
||||
name: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
): Promise<void> {
|
||||
const c = Math.max(1, Math.min(Math.round(cols), 500));
|
||||
const r = Math.max(1, Math.min(Math.round(rows), 200));
|
||||
await execFileAsync("tmux", [
|
||||
"resize-window",
|
||||
"-t",
|
||||
name,
|
||||
"-x",
|
||||
String(c),
|
||||
"-y",
|
||||
String(r),
|
||||
]).catch(() => {}); // session may not exist yet; ignore
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tmux sessions with metadata.
|
||||
*/
|
||||
export async function listSessions(): Promise<TmuxSession[]> {
|
||||
await checkTmuxVersion();
|
||||
|
||||
// Use a separator that's unlikely to appear in session names
|
||||
const SEP = "\x1F"; // ASCII unit separator
|
||||
const fmt = [
|
||||
"#{session_name}",
|
||||
"#{session_created_string}",
|
||||
"#{session_last_attached_string}",
|
||||
"#{window_width}",
|
||||
"#{window_height}",
|
||||
].join(SEP);
|
||||
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await execFileAsync("tmux", ["list-sessions", "-F", fmt]));
|
||||
} catch (err: unknown) {
|
||||
// tmux exits 1 when no sessions exist
|
||||
if (
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"code" in err &&
|
||||
(err as { code: number }).code === 1
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const sessions: TmuxSession[] = [];
|
||||
for (const line of stdout.trim().split("\n")) {
|
||||
if (!line) continue;
|
||||
const [id, createdAt, lastActivityAt, w, h] = line.split(SEP);
|
||||
|
||||
// Only include sessions created by the sidecar.
|
||||
try {
|
||||
const r = await execFileAsync("tmux", [
|
||||
"show-options",
|
||||
"-t",
|
||||
id,
|
||||
"-qv",
|
||||
"@pi-remote-managed",
|
||||
]);
|
||||
if (!r.stdout.trim()) continue; // unmanaged session — skip
|
||||
} catch {
|
||||
continue; // can't read options → skip
|
||||
}
|
||||
|
||||
// Fetch @description option separately (may not be set)
|
||||
let description: string | undefined;
|
||||
try {
|
||||
const r = await execFileAsync("tmux", [
|
||||
"show-options",
|
||||
"-t",
|
||||
id,
|
||||
"-qv",
|
||||
"@description",
|
||||
]);
|
||||
const v = r.stdout.trim();
|
||||
if (v) description = v;
|
||||
} catch {
|
||||
// option not set — that's fine
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
id,
|
||||
name: id,
|
||||
description,
|
||||
createdAt,
|
||||
lastActivityAt,
|
||||
width: parseInt(w, 10) || 120,
|
||||
height: parseInt(h, 10) || 40,
|
||||
});
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single session by name. Returns null if not found.
|
||||
*/
|
||||
export async function getSession(name: string): Promise<TmuxSession | null> {
|
||||
const all = await listSessions();
|
||||
return all.find((s) => s.id === name) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a session. Throws if it doesn't exist.
|
||||
*/
|
||||
export async function killSession(name: string): Promise<void> {
|
||||
await checkTmuxVersion();
|
||||
await execFileAsync("tmux", ["kill-session", "-t", name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the @description option on a session.
|
||||
*/
|
||||
export async function setDescription(
|
||||
name: string,
|
||||
description: string,
|
||||
): Promise<void> {
|
||||
await execFileAsync("tmux", [
|
||||
"set-option",
|
||||
"-t",
|
||||
name,
|
||||
"@description",
|
||||
description,
|
||||
]);
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* tmux capture-pane snapshot.
|
||||
*
|
||||
* Returns a plain-text snapshot of a pane's visible content.
|
||||
* Used by the snapshot route (T-1.5) and the /thumbnail endpoint (T-1.6).
|
||||
*
|
||||
* Owner: T-1.1
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface SnapshotOptions {
|
||||
/** tmux session name */
|
||||
session: string;
|
||||
/** pane index within session (default "0.0") */
|
||||
pane?: string;
|
||||
/** capture width (default: actual pane width) */
|
||||
width?: number;
|
||||
/** capture height (default: actual pane height) */
|
||||
height?: number;
|
||||
/** include escape sequences for colour/style (default: false = plain text) */
|
||||
escapes?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a plain-text (or escape-annotated) snapshot of a tmux pane.
|
||||
* Returns raw text as a string.
|
||||
*/
|
||||
export async function capturePane(opts: SnapshotOptions): Promise<string> {
|
||||
const { session, pane, escapes = false } = opts;
|
||||
// Target just the session when no pane specified — avoids base-index issues.
|
||||
const target = pane ? `${session}:${pane}` : session;
|
||||
|
||||
const args = ["capture-pane", "-t", target, "-p"];
|
||||
if (escapes) args.push("-e"); // include escape sequences
|
||||
// Note: -S/-E (start/end line) omitted — captures current visible content
|
||||
|
||||
const { stdout } = await execFileAsync("tmux", args);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a thumbnail-sized snapshot (40×12) for the REST thumbnail endpoint.
|
||||
* Returns plain text, trimmed.
|
||||
*/
|
||||
export async function captureThumbnail(
|
||||
session: string,
|
||||
pane?: string,
|
||||
): Promise<string> {
|
||||
// tmux can't resize the capture directly via capture-pane flags, so we
|
||||
// capture full content and truncate to 40-char wide × 12 lines.
|
||||
const raw = await capturePane({ session, pane, escapes: false });
|
||||
const lines = raw.split("\n").slice(0, 12);
|
||||
return lines.map((l) => l.slice(0, 40)).join("\n");
|
||||
}
|
||||
|
|
@ -15,7 +15,8 @@
|
|||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.12",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"husky": "^9.1.7"
|
||||
"husky": "^9.1.7",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*",
|
||||
|
|
@ -3970,6 +3971,20 @@
|
|||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uint8array-extras": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "pi-remote-control",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"description": "Expose a running pi session over HTTP/WebSocket — view and interact from any browser on your network.",
|
||||
"keywords": [
|
||||
|
|
@ -17,11 +18,17 @@
|
|||
"scripts": {
|
||||
"lint": "biome check --write .",
|
||||
"lint:check": "biome check .",
|
||||
"prepare": "node .husky/install.mjs"
|
||||
"prepare": "node .husky/install.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"smoke": "node --test scripts/smoke/smoke.mjs",
|
||||
"smoke:stream": "node --test scripts/smoke/stream.test.mjs",
|
||||
"smoke:pair": "node --test scripts/smoke/pair.test.mjs",
|
||||
"smoke:all": "node --test scripts/smoke/smoke.mjs scripts/smoke/stream.test.mjs scripts/smoke/pair.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.12",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"husky": "^9.1.7"
|
||||
"husky": "^9.1.7",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
# Smoke Test Harness
|
||||
|
||||
End-to-end smoke tests for the `remote-control` extension. These tests spawn
|
||||
a real `pi` subprocess with the extension loaded and hit the HTTP/WebSocket
|
||||
endpoints to verify they respond correctly.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
npm run smoke
|
||||
```
|
||||
|
||||
Or directly:
|
||||
|
||||
```sh
|
||||
node --test scripts/smoke/smoke.mjs
|
||||
```
|
||||
|
||||
## What is tested (MVP — T-1.0a)
|
||||
|
||||
| # | Test | Description |
|
||||
|---|------|-------------|
|
||||
| 1 | Server starts | pi spawns with `--remote-control`; TCP port opens within 12 s |
|
||||
| 2 | `GET /manifest.json` | 200, `application/manifest+json`, parses, has `name`/`short_name`/`start_url` |
|
||||
| 3 | `GET /icon.svg` | 200, `image/svg+xml`, body starts with `<svg` |
|
||||
| 4 | `GET /` (with token) | 302→200, `text/html`, contains HTML marker |
|
||||
| 5 | `GET /` (no token) | 403 Forbidden |
|
||||
| 6 | `WS /ws` (with token) | 101 upgrade, WebSocket enters OPEN state |
|
||||
| 7 | Process alive | pi hasn't crashed during the test run |
|
||||
| T | Teardown | SIGTERM pi, wait for exit, remove temp HOME |
|
||||
|
||||
## Port
|
||||
|
||||
Default port: **19876**. Override with the `SMOKE_PORT` environment variable:
|
||||
|
||||
```sh
|
||||
SMOKE_PORT=20000 npm run smoke
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
The harness creates a temporary `HOME` directory containing:
|
||||
- `$HOME/.pi/remote-control/config.json` — `publicBaseUrl` + `bindAddress` pointing at `SMOKE_PORT`
|
||||
- `$HOME/.pi/remote-control/token` — a known deterministic token for auth
|
||||
|
||||
`pi` is spawned with `HOME=<tmpdir>` so **no real `~/.pi` files are read or
|
||||
written**. The temp directory is deleted in the `after()` teardown hook even
|
||||
if tests fail.
|
||||
|
||||
## Authentication
|
||||
|
||||
The server requires either a valid session cookie or `?token=<value>` query
|
||||
parameter. The smoke tests pre-seed a known token (`smoke-test-token-deterministic-1234`)
|
||||
and pass it in the `?token=` parameter. No production auth logic is modified.
|
||||
|
||||
## Adding new tests
|
||||
|
||||
1. Create a new file `scripts/smoke/<feature>.test.mjs`
|
||||
2. Import helpers from `./helpers.mjs`
|
||||
3. Import your test file from `smoke.mjs` (or run it standalone with `node --test`)
|
||||
|
||||
Example:
|
||||
|
||||
```js
|
||||
// scripts/smoke/stream.test.mjs
|
||||
import { describe, it } from "node:test";
|
||||
import { openWebSocket, closeWebSocket } from "./helpers.mjs";
|
||||
|
||||
export function registerStreamTests(port, token) {
|
||||
describe("stream tests", () => {
|
||||
it("WS /sessions/:id/stream → 101", async () => {
|
||||
// ...
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Flags used
|
||||
|
||||
Same flags as `make dev`:
|
||||
```
|
||||
pi -nt -ne -ns -np -nc --no-session --offline -e extensions/remote-control --remote-control
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Port busy**: if `SMOKE_PORT` is already in use, the test will fail at the
|
||||
`waitForPort` step. Change the port: `SMOKE_PORT=20001 npm run smoke`.
|
||||
|
||||
**pi not found**: ensure `pi` is on your `PATH`. Check with `which pi`.
|
||||
|
||||
**Server not starting**: run with verbose output to see what pi logs:
|
||||
```sh
|
||||
node --test --reporter spec scripts/smoke/smoke.mjs
|
||||
```
|
||||
The port-wait error will include captured stdout/stderr from pi.
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
/**
|
||||
* Smoke-test helpers — spawn-pi, wait-for-port, fetch, WebSocket.
|
||||
*
|
||||
* Designed to be imported by smoke.mjs and any future test files
|
||||
* (e.g. scripts/smoke/stream.test.mjs). All helpers are stateless;
|
||||
* the caller is responsible for lifecycle.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// ── WebSocket (from project's bundled ws dependency) ─────────────────────────
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
/** @type {import("ws")} */
|
||||
const wsModule = _require("ws");
|
||||
const WebSocket = wsModule.WebSocket ?? wsModule.default ?? wsModule;
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Milliseconds to wait for the TCP port to open before giving up. */
|
||||
const PORT_READY_TIMEOUT_MS = 12_000;
|
||||
/** Milliseconds between TCP-port-ready poll attempts. */
|
||||
const PORT_POLL_INTERVAL_MS = 100;
|
||||
|
||||
// ── Temporary HOME setup ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a minimal fake HOME directory that satisfies remote-control's config
|
||||
* requirements:
|
||||
* $HOME/.pi/remote-control/config.json — publicBaseUrl + bindAddress
|
||||
* $HOME/.pi/remote-control/token — known token for auth
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {number} opts.port - TCP port to bind to.
|
||||
* @param {string} opts.token - Auth token to pre-seed.
|
||||
* @returns {Promise<string>} Path to the temp HOME directory.
|
||||
*/
|
||||
export async function createSmokeHome({ port, token }) {
|
||||
const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), "pi-smoke-"));
|
||||
const rcDir = path.join(tmpBase, ".pi", "remote-control");
|
||||
await fs.mkdir(rcDir, { recursive: true });
|
||||
|
||||
const config = {
|
||||
publicBaseUrl: `http://127.0.0.1:${port}`,
|
||||
bindAddress: `127.0.0.1:${port}`,
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(rcDir, "config.json"),
|
||||
`${JSON.stringify(config, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(rcDir, "token"), token, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
return tmpBase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the temp HOME directory created by createSmokeHome.
|
||||
* Silently ignores errors (e.g. already deleted).
|
||||
*
|
||||
* @param {string} tmpHome
|
||||
*/
|
||||
export async function removeSmokeHome(tmpHome) {
|
||||
try {
|
||||
await fs.rm(tmpHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pi subprocess ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Spawn a `pi` process with the remote-control extension loaded.
|
||||
*
|
||||
* Uses the same flags as `make dev` plus `--remote-control` to auto-start
|
||||
* the HTTP server. Sets HOME to the given fake home so config/token paths
|
||||
* resolve correctly without touching the real ~/.pi directory.
|
||||
*
|
||||
* Pi is a TUI application that requires a PTY (pseudo-terminal) to enter its
|
||||
* interactive session mode and fire `session_start`. We use Python's built-in
|
||||
* `pty.spawn` to allocate a PTY for pi; this is available on all macOS/Linux
|
||||
* systems without any additional dependencies.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.extensionPath - Absolute path to the extension dir.
|
||||
* @param {string} opts.fakeHome - Path to the fake HOME directory.
|
||||
* @returns {{ proc: import("node:child_process").ChildProcess, logs: string[] }}
|
||||
*/
|
||||
export function spawnPi({ extensionPath, fakeHome }) {
|
||||
const logs = [];
|
||||
|
||||
// Python script that allocates a PTY and spawns pi inside it.
|
||||
// Using pty.spawn() keeps the child alive (no EOF injection like `script` does).
|
||||
const pyScript = [
|
||||
"import os, pty",
|
||||
`os.environ['HOME'] = ${JSON.stringify(fakeHome)}`,
|
||||
`pty.spawn(['pi', '-nt', '-ne', '-ns', '-np', '-nc', '--no-session', '--offline', '-e', ${JSON.stringify(extensionPath)}, '--remote-control'])`,
|
||||
].join("\n");
|
||||
|
||||
const proc = spawn("python3", ["-c", pyScript], {
|
||||
env: { ...process.env, HOME: fakeHome },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const capture = (chunk) => {
|
||||
logs.push(chunk.toString());
|
||||
};
|
||||
proc.stdout.on("data", capture);
|
||||
proc.stderr.on("data", capture);
|
||||
|
||||
return { proc, logs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a pi process and wait for it to exit.
|
||||
* Sends SIGTERM first; if it doesn't exit within 3 s, sends SIGKILL.
|
||||
*
|
||||
* @param {import("node:child_process").ChildProcess} proc
|
||||
* @returns {Promise<number|null>} The exit code (or null if killed by signal).
|
||||
*/
|
||||
export function killPi(proc) {
|
||||
return new Promise((resolve) => {
|
||||
if (proc.exitCode !== null) {
|
||||
resolve(proc.exitCode);
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const settle = (code) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(sigkillTimer);
|
||||
resolve(code ?? null);
|
||||
}
|
||||
};
|
||||
|
||||
proc.once("exit", (code) => settle(code));
|
||||
|
||||
proc.kill("SIGTERM");
|
||||
|
||||
// Safety: escalate to SIGKILL after 3 s
|
||||
const sigkillTimer = setTimeout(() => {
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
}, 3_000);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Port readiness ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Poll a TCP port until it accepts connections or the timeout expires.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {number} opts.port
|
||||
* @param {string} [opts.host] - Default "127.0.0.1".
|
||||
* @param {number} [opts.timeoutMs] - Default PORT_READY_TIMEOUT_MS.
|
||||
* @param {number} [opts.intervalMs] - Default PORT_POLL_INTERVAL_MS.
|
||||
* @returns {Promise<void>} Resolves when port is open; rejects on timeout.
|
||||
*/
|
||||
export function waitForPort({
|
||||
port,
|
||||
host = "127.0.0.1",
|
||||
timeoutMs = PORT_READY_TIMEOUT_MS,
|
||||
intervalMs = PORT_POLL_INTERVAL_MS,
|
||||
}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
const attempt = () => {
|
||||
const sock = net.createConnection({ port, host });
|
||||
|
||||
sock.once("connect", () => {
|
||||
sock.destroy();
|
||||
resolve();
|
||||
});
|
||||
|
||||
sock.once("error", () => {
|
||||
sock.destroy();
|
||||
if (Date.now() >= deadline) {
|
||||
reject(
|
||||
new Error(
|
||||
`Port ${host}:${port} did not open within ${timeoutMs} ms`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setTimeout(attempt, intervalMs);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
attempt();
|
||||
});
|
||||
}
|
||||
|
||||
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the base URL for the smoke server.
|
||||
*
|
||||
* @param {number} port
|
||||
* @returns {string}
|
||||
*/
|
||||
export function baseUrl(port) {
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a URL and return the response plus text body.
|
||||
* Throws if the network request itself fails.
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {RequestInit} [init]
|
||||
* @returns {Promise<{ res: Response; body: string }>}
|
||||
*/
|
||||
export async function fetchText(url, init) {
|
||||
const res = await fetch(url, init);
|
||||
const body = await res.text();
|
||||
return { res, body };
|
||||
}
|
||||
|
||||
// ── WebSocket helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open a WebSocket connection and wait for either an `open` or `error` event.
|
||||
* Resolves with the WebSocket instance on success.
|
||||
* Rejects with the error on failure.
|
||||
*
|
||||
* @param {string} url - WebSocket URL (ws:// or wss://).
|
||||
* @returns {Promise<import("ws").WebSocket>}
|
||||
*/
|
||||
export function openWebSocket(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
ws.once("open", () => resolve(ws));
|
||||
ws.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a WebSocket and wait for the close event.
|
||||
*
|
||||
* @param {import("ws").WebSocket} ws
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function closeWebSocket(ws) {
|
||||
return new Promise((resolve) => {
|
||||
if (ws.readyState === ws.constructor.CLOSED) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
ws.once("close", () => resolve());
|
||||
ws.close();
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
/**
|
||||
* T-1.3 Integration smoke: POST /pair pairing flow.
|
||||
*
|
||||
* Requires a running pi process with remote-control (spawned by the before() hook).
|
||||
* Re-uses the helpers from helpers.mjs.
|
||||
*
|
||||
* Tests:
|
||||
* 1. GET /pair-qr → 200 text/plain containing a pi-remote:// URL
|
||||
* 2. POST /pair → 200 { bearerToken, sidecarId } with valid pairingToken
|
||||
* 3. GET /sessions?token=<bearerToken> → 200 (bearer token authenticates)
|
||||
* 4. POST /pair (invalid) → 403 invalid_pairing_token
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { after, before, describe, it } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
baseUrl,
|
||||
createSmokeHome,
|
||||
fetchText,
|
||||
killPi,
|
||||
removeSmokeHome,
|
||||
spawnPi,
|
||||
waitForPort,
|
||||
} from "./helpers.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const EXTENSION_PATH = path.resolve(
|
||||
__dirname,
|
||||
"../../extensions/remote-control",
|
||||
);
|
||||
|
||||
const SMOKE_PORT = Number(process.env.SMOKE_PORT_PAIR ?? 19878);
|
||||
const SMOKE_TOKEN = "pair-test-token-deterministic-789";
|
||||
const BASE = baseUrl(SMOKE_PORT);
|
||||
const AUTH = `?token=${SMOKE_TOKEN}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: fetch JSON from a URL
|
||||
// ---------------------------------------------------------------------------
|
||||
async function fetchJson(url, init) {
|
||||
const res = await fetch(url, init);
|
||||
let body;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
return { res, body };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: extract a query-param value from a URL string
|
||||
// ---------------------------------------------------------------------------
|
||||
function extractParam(urlStr, param) {
|
||||
// Find the pi-remote:// URL in a potentially multi-line body
|
||||
const match = urlStr.match(/pi-remote:\/\/[^\s]+/);
|
||||
if (!match) return null;
|
||||
// URLSearchParams doesn't parse custom schemes, so strip the scheme+host
|
||||
const raw = match[0];
|
||||
const qIdx = raw.indexOf("?");
|
||||
if (qIdx === -1) return null;
|
||||
const params = new URLSearchParams(raw.slice(qIdx + 1));
|
||||
return params.get(param);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let piProc = null;
|
||||
let tmpHome = null;
|
||||
|
||||
describe("T-1.3 pair smoke", () => {
|
||||
before(async () => {
|
||||
tmpHome = await createSmokeHome({ port: SMOKE_PORT, token: SMOKE_TOKEN });
|
||||
const { proc } = spawnPi({
|
||||
extensionPath: EXTENSION_PATH,
|
||||
fakeHome: tmpHome,
|
||||
});
|
||||
piProc = proc;
|
||||
await waitForPort({ port: SMOKE_PORT });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (piProc) await killPi(piProc);
|
||||
if (tmpHome) await removeSmokeHome(tmpHome);
|
||||
});
|
||||
|
||||
// ── Test 1: GET /pair-qr ─────────────────────────────────────────────────
|
||||
|
||||
it("GET /pair-qr returns a pairing URL", async () => {
|
||||
const { res, body } = await fetchText(`${BASE}/pair-qr${AUTH}`);
|
||||
assert.equal(res.status, 200, `Expected 200, got ${res.status}: ${body}`);
|
||||
assert.ok(
|
||||
body.includes("pi-remote://"),
|
||||
`Response should contain pi-remote:// URL. Got:\n${body}`,
|
||||
);
|
||||
const token = extractParam(body, "pair");
|
||||
assert.ok(
|
||||
token && token.length > 0,
|
||||
`Should be able to extract pair token from URL. Body:\n${body}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Test 2: POST /pair with valid pairingToken ────────────────────────────
|
||||
|
||||
it("POST /pair with valid pairingToken returns bearerToken", async () => {
|
||||
// Step 1: obtain a fresh pairing token via GET /pair-qr
|
||||
const { res: qrRes, body: qrBody } = await fetchText(
|
||||
`${BASE}/pair-qr${AUTH}`,
|
||||
);
|
||||
assert.equal(
|
||||
qrRes.status,
|
||||
200,
|
||||
`GET /pair-qr failed: ${qrRes.status} ${qrBody}`,
|
||||
);
|
||||
const pairingToken = extractParam(qrBody, "pair");
|
||||
assert.ok(pairingToken, "Could not extract pairingToken from /pair-qr");
|
||||
|
||||
// Step 2: exchange pairingToken for bearerToken
|
||||
const { res, body } = await fetchJson(`${BASE}/pair`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
pairingToken,
|
||||
deviceToken: "fake-device-token-smoke",
|
||||
environment: "sandbox",
|
||||
}),
|
||||
});
|
||||
assert.equal(res.status, 200, `Expected 200, got ${res.status}: ${JSON.stringify(body)}`);
|
||||
assert.ok(body, "Response body should be JSON");
|
||||
assert.ok(
|
||||
typeof body.bearerToken === "string" && body.bearerToken.length > 0,
|
||||
`Response should have bearerToken string. Got: ${JSON.stringify(body)}`,
|
||||
);
|
||||
assert.ok(
|
||||
typeof body.sidecarId === "string" && body.sidecarId.length > 0,
|
||||
`Response should have sidecarId string. Got: ${JSON.stringify(body)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Test 3: bearerToken authenticates GET /sessions ──────────────────────
|
||||
|
||||
it("bearerToken from pair can authenticate GET /sessions", async () => {
|
||||
// Obtain pairing token
|
||||
const { res: qrRes, body: qrBody } = await fetchText(
|
||||
`${BASE}/pair-qr${AUTH}`,
|
||||
);
|
||||
assert.equal(qrRes.status, 200, `GET /pair-qr failed: ${qrRes.status}`);
|
||||
const pairingToken = extractParam(qrBody, "pair");
|
||||
assert.ok(pairingToken, "Could not extract pairingToken");
|
||||
|
||||
// Exchange for bearer token
|
||||
const { res: pairRes, body: pairBody } = await fetchJson(`${BASE}/pair`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pairingToken }),
|
||||
});
|
||||
assert.equal(
|
||||
pairRes.status,
|
||||
200,
|
||||
`POST /pair failed: ${pairRes.status} ${JSON.stringify(pairBody)}`,
|
||||
);
|
||||
const { bearerToken } = pairBody;
|
||||
assert.ok(bearerToken, "Must have bearerToken to test auth");
|
||||
|
||||
// Use bearerToken to call GET /sessions
|
||||
const { res: sessRes, body: sessBody } = await fetchJson(
|
||||
`${BASE}/sessions?token=${encodeURIComponent(bearerToken)}`,
|
||||
);
|
||||
assert.equal(
|
||||
sessRes.status,
|
||||
200,
|
||||
`GET /sessions with bearerToken should return 200, got ${sessRes.status}: ${JSON.stringify(sessBody)}`,
|
||||
);
|
||||
assert.ok(Array.isArray(sessBody), "GET /sessions should return an array");
|
||||
});
|
||||
|
||||
// ── Test 4: POST /pair with invalid pairingToken → 403 ───────────────────
|
||||
|
||||
it("POST /pair with invalid pairingToken returns 403", async () => {
|
||||
const { res, body } = await fetchJson(`${BASE}/pair`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pairingToken: "invalid-xxx" }),
|
||||
});
|
||||
assert.equal(
|
||||
res.status,
|
||||
403,
|
||||
`Expected 403 for invalid token, got ${res.status}: ${JSON.stringify(body)}`,
|
||||
);
|
||||
assert.ok(body, "Should return JSON error body");
|
||||
assert.equal(body.error, "invalid_pairing_token");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
/**
|
||||
* Smoke test — end-to-end validation of the remote-control HTTP/WS server.
|
||||
*
|
||||
* Runs with: node --test scripts/smoke/smoke.mjs
|
||||
* Or: npm run smoke
|
||||
*
|
||||
* What is tested (MVP):
|
||||
* 1. Server starts — pi spawns with --remote-control and the HTTP port opens.
|
||||
* 2. GET /manifest.json — 200, application/manifest+json, parses, has name/short_name/start_url.
|
||||
* 3. GET /icon.svg — 200, image/svg+xml, body starts with <svg.
|
||||
* 4. GET / — 200 (with auth token), text/html, contains HTML marker.
|
||||
* 5. WS /ws — successful 101 upgrade (with auth token). Close cleanly.
|
||||
* 6. Teardown — SIGTERM pi, process exits.
|
||||
*
|
||||
* Auth strategy:
|
||||
* The server requires a token on GET / and WS /ws. We pre-seed a known token
|
||||
* in a temporary HOME directory ($HOME/.pi/remote-control/token) and pass it
|
||||
* via the ?token= query parameter using the existing mechanism. No production
|
||||
* code is modified.
|
||||
*
|
||||
* Port strategy:
|
||||
* We write bindAddress + publicBaseUrl to the temporary config so the server
|
||||
* binds to a deterministic port (SMOKE_PORT env, default 19876). No real
|
||||
* ~/.pi files are touched.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { after, before, describe, it } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
baseUrl,
|
||||
closeWebSocket,
|
||||
createSmokeHome,
|
||||
fetchText,
|
||||
killPi,
|
||||
openWebSocket,
|
||||
removeSmokeHome,
|
||||
spawnPi,
|
||||
waitForPort,
|
||||
} from "./helpers.mjs";
|
||||
|
||||
// ── Configuration ─────────────────────────────────────────────────────────────
|
||||
|
||||
const SMOKE_PORT = Number(process.env.SMOKE_PORT ?? 19876);
|
||||
const SMOKE_TOKEN = "smoke-test-token-deterministic-1234";
|
||||
|
||||
const EXTENSION_PATH = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../extensions/remote-control",
|
||||
);
|
||||
|
||||
// ── Suite ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("remote-control smoke tests", () => {
|
||||
/** @type {import("node:child_process").ChildProcess} */
|
||||
let proc;
|
||||
/** @type {string} */
|
||||
let fakeHome;
|
||||
/** @type {string[]} */
|
||||
let logs;
|
||||
|
||||
before(async () => {
|
||||
// Create temp HOME with config + token
|
||||
fakeHome = await createSmokeHome({ port: SMOKE_PORT, token: SMOKE_TOKEN });
|
||||
|
||||
// Spawn pi
|
||||
({ proc, logs } = spawnPi({ extensionPath: EXTENSION_PATH, fakeHome }));
|
||||
|
||||
// Wait for the HTTP server to open the TCP port
|
||||
try {
|
||||
await waitForPort({ port: SMOKE_PORT });
|
||||
} catch (err) {
|
||||
// Attach captured logs to the error for easy debugging
|
||||
const captured = logs.join("");
|
||||
throw new Error(
|
||||
`${err.message}\n\n--- pi stdout/stderr ---\n${captured || "(empty)"}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
// Always clean up — kill pi and remove temp HOME
|
||||
if (proc) {
|
||||
await killPi(proc);
|
||||
}
|
||||
if (fakeHome) {
|
||||
await removeSmokeHome(fakeHome);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Test 1: manifest.json ───────────────────────────────────────────────────
|
||||
|
||||
it("GET /manifest.json → 200 application/manifest+json with required fields", async () => {
|
||||
const { res, body } = await fetchText(
|
||||
`${baseUrl(SMOKE_PORT)}/manifest.json`,
|
||||
);
|
||||
|
||||
assert.equal(res.status, 200, `Expected 200, got ${res.status}`);
|
||||
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
assert.ok(
|
||||
ct.includes("application/manifest+json"),
|
||||
`Expected content-type application/manifest+json, got: ${ct}`,
|
||||
);
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(body);
|
||||
} catch {
|
||||
assert.fail(
|
||||
`manifest.json body is not valid JSON: ${body.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
typeof manifest.name === "string" && manifest.name.length > 0,
|
||||
"manifest.name must be a non-empty string",
|
||||
);
|
||||
assert.ok(
|
||||
typeof manifest.short_name === "string" && manifest.short_name.length > 0,
|
||||
"manifest.short_name must be a non-empty string",
|
||||
);
|
||||
assert.ok(
|
||||
typeof manifest.start_url === "string" && manifest.start_url.length > 0,
|
||||
"manifest.start_url must be a non-empty string",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Test 2: icon.svg ────────────────────────────────────────────────────────
|
||||
|
||||
it("GET /icon.svg → 200 image/svg+xml, body starts with <svg", async () => {
|
||||
const { res, body } = await fetchText(`${baseUrl(SMOKE_PORT)}/icon.svg`);
|
||||
|
||||
assert.equal(res.status, 200, `Expected 200, got ${res.status}`);
|
||||
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
assert.ok(
|
||||
ct.includes("image/svg+xml"),
|
||||
`Expected content-type image/svg+xml, got: ${ct}`,
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
body.trimStart().startsWith("<svg"),
|
||||
`Expected body to start with <svg, got: ${body.slice(0, 100)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Test 3: root with auth ──────────────────────────────────────────────────
|
||||
|
||||
it("GET / (with token) → 200 text/html containing HTML marker", async () => {
|
||||
const url = `${baseUrl(SMOKE_PORT)}/?token=${SMOKE_TOKEN}`;
|
||||
// The server issues a 302 redirect on first token auth (sets session cookie),
|
||||
// then serves 200 on the redirect target. Follow redirects manually to keep
|
||||
// cookies across the redirect chain.
|
||||
const cookieJar = {};
|
||||
|
||||
// First request: expect 302
|
||||
const first = await fetch(url, { redirect: "manual" });
|
||||
|
||||
let body;
|
||||
if (first.status === 302) {
|
||||
// Extract session cookie
|
||||
for (const [name, value] of first.headers.entries()) {
|
||||
if (name.toLowerCase() === "set-cookie") {
|
||||
const match = value.match(/^([^=]+)=([^;]+)/);
|
||||
if (match) cookieJar[match[1]] = match[2];
|
||||
}
|
||||
}
|
||||
const location = first.headers.get("location") ?? "/";
|
||||
const cookieHeader = Object.entries(cookieJar)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
|
||||
const second = await fetch(`${baseUrl(SMOKE_PORT)}${location}`, {
|
||||
headers: cookieHeader ? { Cookie: cookieHeader } : {},
|
||||
});
|
||||
assert.equal(
|
||||
second.status,
|
||||
200,
|
||||
`Expected 200 on redirect target, got ${second.status}`,
|
||||
);
|
||||
const ct = second.headers.get("content-type") ?? "";
|
||||
assert.ok(
|
||||
ct.includes("text/html"),
|
||||
`Expected content-type text/html, got: ${ct}`,
|
||||
);
|
||||
body = await second.text();
|
||||
} else {
|
||||
assert.equal(first.status, 200, `Expected 200, got ${first.status}`);
|
||||
const ct = first.headers.get("content-type") ?? "";
|
||||
assert.ok(
|
||||
ct.includes("text/html"),
|
||||
`Expected content-type text/html, got: ${ct}`,
|
||||
);
|
||||
body = await first.text();
|
||||
}
|
||||
|
||||
// Check for a marker from html.ts — the title is always present
|
||||
assert.ok(
|
||||
body.includes("<!DOCTYPE html>"),
|
||||
"Expected body to contain <!DOCTYPE html>",
|
||||
);
|
||||
assert.ok(
|
||||
body.includes("remote-control") ||
|
||||
body.includes("Pi Remote") ||
|
||||
body.includes("π"),
|
||||
"Expected body to contain a known HTML marker (remote-control / Pi Remote / π)",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Test 4: 403 on unauthenticated root ─────────────────────────────────────
|
||||
|
||||
it("GET / (no token) → 403 Forbidden", async () => {
|
||||
const { res } = await fetchText(`${baseUrl(SMOKE_PORT)}/`);
|
||||
assert.equal(
|
||||
res.status,
|
||||
403,
|
||||
`Expected 403 without token, got ${res.status}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Test 5: WebSocket upgrade ───────────────────────────────────────────────
|
||||
|
||||
it("WS /ws (with token) → successful 101 upgrade", async () => {
|
||||
const wsUrl = `ws://127.0.0.1:${SMOKE_PORT}/ws?token=${SMOKE_TOKEN}`;
|
||||
|
||||
// First, get a session cookie via HTTP (WS token auth also works directly
|
||||
// since isAuthenticated checks URL searchParams on the Upgrade request)
|
||||
const ws = await openWebSocket(wsUrl);
|
||||
|
||||
assert.ok(
|
||||
ws.readyState === ws.constructor.OPEN,
|
||||
"WebSocket should be in OPEN state after successful handshake",
|
||||
);
|
||||
|
||||
await closeWebSocket(ws);
|
||||
});
|
||||
|
||||
// ── Test 6: teardown (handled in after(), verified here) ───────────────────
|
||||
|
||||
it("pi process is alive during tests", () => {
|
||||
assert.equal(
|
||||
proc.exitCode,
|
||||
null,
|
||||
"pi process should still be running during tests",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
/**
|
||||
* T-1.8 Integration smoke: stream attach, send-keys, drop + reconnect, delta replay.
|
||||
*
|
||||
* Requires a running pi process with remote-control (spawned by the before() hook).
|
||||
* Re-uses the helpers from helpers.mjs.
|
||||
*
|
||||
* Tests:
|
||||
* 1. POST /sessions → creates a tmux session
|
||||
* 2. WS /sessions/:id/stream → attaches, sends { type:"resume"; lastSeq:null }
|
||||
* 3. Receives at least some binary frames (output from the session)
|
||||
* 4. Sends { type:"keys"; data:"echo smoke-marker\n" } via HTTP POST /sessions/:id/input
|
||||
* 5. Observes "smoke-marker" in the stream within timeout
|
||||
* 6. Notes lastSeq, disconnects
|
||||
* 7. Reconnects with { type:"resume"; lastSeq } → receives only delta frames
|
||||
* 8. GET /sessions/:id/thumbnail → text/plain, non-empty
|
||||
* 9. DELETE /sessions/:id → 204
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { after, before, describe, it } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
baseUrl,
|
||||
closeWebSocket,
|
||||
createSmokeHome,
|
||||
fetchText,
|
||||
killPi,
|
||||
openWebSocket,
|
||||
removeSmokeHome,
|
||||
spawnPi,
|
||||
waitForPort,
|
||||
} from "./helpers.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const EXTENSION_PATH = path.resolve(
|
||||
__dirname,
|
||||
"../../extensions/remote-control",
|
||||
);
|
||||
|
||||
const SMOKE_PORT = Number(process.env.SMOKE_PORT_STREAM ?? 19877); // different from smoke.mjs (19876)
|
||||
const SMOKE_TOKEN = "stream-test-token-deterministic-567";
|
||||
const BASE = baseUrl(SMOKE_PORT);
|
||||
const WS_BASE = `ws://127.0.0.1:${SMOKE_PORT}`;
|
||||
const AUTH = `?token=${SMOKE_TOKEN}`;
|
||||
|
||||
let piProc = null;
|
||||
let tmpHome = null;
|
||||
let sessionId = null;
|
||||
|
||||
describe("T-1.8 stream integration", () => {
|
||||
before(async () => {
|
||||
tmpHome = await createSmokeHome({ port: SMOKE_PORT, token: SMOKE_TOKEN });
|
||||
const { proc } = spawnPi({
|
||||
extensionPath: EXTENSION_PATH,
|
||||
fakeHome: tmpHome,
|
||||
});
|
||||
piProc = proc;
|
||||
await waitForPort({ port: SMOKE_PORT });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (sessionId) {
|
||||
await fetchText(`${BASE}/sessions/${sessionId}${AUTH}`, {
|
||||
method: "DELETE",
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (piProc) await killPi(piProc);
|
||||
if (tmpHome) await removeSmokeHome(tmpHome);
|
||||
});
|
||||
|
||||
it("POST /sessions → 201 with id + name", async () => {
|
||||
const { res, body } = await fetchText(`${BASE}/sessions${AUTH}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "smoke-stream" }),
|
||||
});
|
||||
assert.equal(res.status, 201, `Expected 201, got ${res.status}: ${body}`);
|
||||
const json = JSON.parse(body);
|
||||
assert.ok(json.id, "Response should have id");
|
||||
assert.equal(json.name, "smoke-stream");
|
||||
sessionId = json.id;
|
||||
});
|
||||
|
||||
it("WS stream: attach, receive frames, send keys, observe output", async () => {
|
||||
const ws = await openWebSocket(
|
||||
`${WS_BASE}/sessions/${sessionId}/stream${AUTH}`,
|
||||
);
|
||||
|
||||
// Send resume from scratch
|
||||
ws.send(JSON.stringify({ type: "resume", lastSeq: null }));
|
||||
|
||||
// Collect frames for up to 5 seconds
|
||||
const frames = [];
|
||||
let lastSeq = 0;
|
||||
const receivedMarker = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error("Timed out waiting for output frames")),
|
||||
5000,
|
||||
);
|
||||
|
||||
ws.on("message", (data) => {
|
||||
if (Buffer.isBuffer(data) && data.length >= 8) {
|
||||
// Binary frame: [seq: 8 bytes][data]
|
||||
const seq = Number(data.readBigUInt64BE(0));
|
||||
lastSeq = Math.max(lastSeq, seq);
|
||||
frames.push({ seq, data: data.slice(8) });
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
assert.ok(receivedMarker, "Should receive at least one binary frame");
|
||||
assert.ok(frames.length > 0, "frames array should be non-empty");
|
||||
assert.ok(lastSeq > 0, "lastSeq should be > 0");
|
||||
|
||||
await closeWebSocket(ws);
|
||||
});
|
||||
|
||||
it("POST /sessions/:id/input → send keys, observe in stream", async () => {
|
||||
const ws = await openWebSocket(
|
||||
`${WS_BASE}/sessions/${sessionId}/stream${AUTH}`,
|
||||
);
|
||||
ws.send(JSON.stringify({ type: "resume", lastSeq: null }));
|
||||
|
||||
// Send a distinctive command via HTTP input endpoint
|
||||
// Send text then Enter as two separate requests (avoids \n in execFile args)
|
||||
const marker = `smoke-${Date.now()}`;
|
||||
const { res: r1, body: b1 } = await fetchText(
|
||||
`${BASE}/sessions/${sessionId}/input${AUTH}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "keys", data: `echo ${marker}` }),
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
r1.status,
|
||||
204,
|
||||
`keys POST should return 204, got ${r1.status}. Body: ${b1}`,
|
||||
);
|
||||
const { res: r2, body: b2 } = await fetchText(
|
||||
`${BASE}/sessions/${sessionId}/input${AUTH}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "key", name: "enter" }),
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
r2.status,
|
||||
204,
|
||||
`enter POST should return 204, got ${r2.status}: ${b2}`,
|
||||
);
|
||||
|
||||
// Wait for marker in stream output
|
||||
const found = await new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(false), 6000);
|
||||
let accumulated = "";
|
||||
ws.on("message", (data) => {
|
||||
if (Buffer.isBuffer(data) && data.length > 8) {
|
||||
accumulated += data.slice(8).toString("utf8", 0, 200);
|
||||
if (accumulated.includes(marker)) {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await closeWebSocket(ws);
|
||||
assert.ok(found, `Should observe marker "${marker}" in stream output`);
|
||||
});
|
||||
|
||||
it("WS stream: send keys via WS, observe in stream output", async () => {
|
||||
const ws = await openWebSocket(
|
||||
`${WS_BASE}/sessions/${sessionId}/stream${AUTH}`,
|
||||
);
|
||||
ws.send(JSON.stringify({ type: "resume", lastSeq: null }));
|
||||
|
||||
const marker = `ws-smoke-${Date.now()}`;
|
||||
|
||||
// Wait a tick to let resume complete, then send keys via WS
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
ws.send(JSON.stringify({ type: "keys", data: `echo ${marker}` }));
|
||||
ws.send(JSON.stringify({ type: "key", name: "enter" }));
|
||||
|
||||
const found = await new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(false), 6000);
|
||||
let accumulated = "";
|
||||
ws.on("message", (data) => {
|
||||
if (Buffer.isBuffer(data) && data.length > 8) {
|
||||
accumulated += data.slice(8).toString("utf8");
|
||||
if (accumulated.includes(marker)) {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await closeWebSocket(ws);
|
||||
assert.ok(
|
||||
found,
|
||||
`Should observe marker "${marker}" sent via WS keys message in stream output`,
|
||||
);
|
||||
});
|
||||
|
||||
it("WS stream: reconnect with lastSeq → receives only delta", async () => {
|
||||
// First pass: collect frames and note highest seq
|
||||
const ws1 = await openWebSocket(
|
||||
`${WS_BASE}/sessions/${sessionId}/stream${AUTH}`,
|
||||
);
|
||||
ws1.send(JSON.stringify({ type: "resume", lastSeq: null }));
|
||||
|
||||
let highestSeq = 0;
|
||||
await new Promise((resolve) => {
|
||||
const timeout = setTimeout(resolve, 2000);
|
||||
ws1.on("message", (data) => {
|
||||
if (Buffer.isBuffer(data) && data.length >= 8) {
|
||||
const seq = Number(data.readBigUInt64BE(0));
|
||||
highestSeq = Math.max(highestSeq, seq);
|
||||
clearTimeout(timeout);
|
||||
setTimeout(resolve, 500); // wait a bit for more frames
|
||||
}
|
||||
});
|
||||
});
|
||||
await closeWebSocket(ws1);
|
||||
|
||||
// Send more output (text + enter separately)
|
||||
await fetchText(`${BASE}/sessions/${sessionId}/input${AUTH}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "keys", data: "echo delta-check" }),
|
||||
});
|
||||
await fetchText(`${BASE}/sessions/${sessionId}/input${AUTH}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "key", name: "enter" }),
|
||||
});
|
||||
|
||||
// Small delay to let output be buffered
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
// Reconnect with lastSeq = highestSeq → should only get frames after that
|
||||
const ws2 = await openWebSocket(
|
||||
`${WS_BASE}/sessions/${sessionId}/stream${AUTH}`,
|
||||
);
|
||||
ws2.send(JSON.stringify({ type: "resume", lastSeq: highestSeq }));
|
||||
|
||||
const deltaFrames = [];
|
||||
await new Promise((resolve) => {
|
||||
const timeout = setTimeout(resolve, 3000);
|
||||
ws2.on("message", (data) => {
|
||||
if (Buffer.isBuffer(data) && data.length >= 8) {
|
||||
const seq = Number(data.readBigUInt64BE(0));
|
||||
if (seq > highestSeq) deltaFrames.push(seq);
|
||||
clearTimeout(timeout);
|
||||
setTimeout(resolve, 500);
|
||||
}
|
||||
});
|
||||
});
|
||||
await closeWebSocket(ws2);
|
||||
|
||||
// Delta frames should all have seq > highestSeq
|
||||
for (const seq of deltaFrames) {
|
||||
assert.ok(
|
||||
seq > highestSeq,
|
||||
`Delta frame seq ${seq} should be > ${highestSeq}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("GET /sessions/:id/thumbnail → text/plain, non-empty", async () => {
|
||||
const { res, body } = await fetchText(
|
||||
`${BASE}/sessions/${sessionId}/thumbnail${AUTH}`,
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(
|
||||
res.headers.get("content-type")?.includes("text/plain"),
|
||||
"Should be text/plain",
|
||||
);
|
||||
// capture-pane may return whitespace-only for a fresh shell — check length not trim
|
||||
assert.ok(body.length > 0, "Thumbnail response should have content");
|
||||
});
|
||||
|
||||
it("DELETE /sessions/:id → 204", async () => {
|
||||
const { res } = await fetchText(`${BASE}/sessions/${sessionId}${AUTH}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
assert.equal(res.status, 204);
|
||||
sessionId = null; // prevent after() from double-deleting
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Simulate pi's TypeScript runtime environment
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
|
||||
// Type-check only, no emit — pi handles the actual transpilation
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
|
||||
// Resolve peer deps from pi's global node_modules
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@earendil-works/pi-coding-agent": [
|
||||
"../../../../../../usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.d.ts"
|
||||
],
|
||||
"@earendil-works/pi-tui": [
|
||||
"../../../../../../usr/local/lib/node_modules/@earendil-works/pi-tui/dist/index.d.ts"
|
||||
]
|
||||
},
|
||||
"typeRoots": [
|
||||
"./node_modules/@types",
|
||||
"/usr/local/lib/node_modules/@types"
|
||||
],
|
||||
|
||||
// Project uses .js extensions in imports (NodeNext convention)
|
||||
"allowImportingTsExtensions": false,
|
||||
|
||||
// Relax some checks that are impractical without the real pi runtime
|
||||
"skipLibCheck": true,
|
||||
"ignoreDeprecations": "6.0"
|
||||
},
|
||||
"include": [
|
||||
"extensions/remote-control/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"build"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue