diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d582fe..13300f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ ## Unreleased +## v2.1.0 — 2026-08-31 + +VS Code adapter and shared client extraction on top of the v2.0.0 relay. The relay remains the core; OpenCode and VS Code are thin adapters on the same `/host` + `/ws` protocol. + +GitHub tag `v2.1.0` is the source of truth for this release. `@chorus/plugin` remains install-from-git (packages are private). Rebuild `chorus-relay` from this tag when upgrading from v2.0.0. + +### VS Code adapter + +- New extension (`packages/vscode`) — share/join/chat, Session sidebar, approve/deny joiners +- Session access control matches OpenCode: required display name, host approval, optional email/repo gates +- Cross-adapter relay: VS Code can host for terminal joiners or join a terminal host + +### Shared client + +- Extract `@chorus/client` (`JoinClient` + `RelayServer`) used by OpenCode plugin and VS Code +- OpenCode plugin re-exports from `@chorus/client` (includes v2.0.0 network options: CIDR allowlists, async `stop()`) + +### Tests & docs + +- `bun run test:vscode-e2e` — email gate, pending approve, `collab.input` +- `bun run test:vscode-relay-e2e` — three-env gate + VS Code ↔ terminal cross-adapter +- README, STATUS, DECISIONS, and `packages/vscode/README.md` updated + ## v2.0.0 — 2026-08-21 Enterprise network lockdown for Chorus: keep the relay off the open internet with CIDR allowlists, private bind, and VPN/VPC deployment docs. diff --git a/Cargo.lock b/Cargo.lock index 6926b20..2500e5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -157,7 +157,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chorus-relay" -version = "2.0.0" +version = "2.1.0" dependencies = [ "axum", "clap", diff --git a/README.md b/README.md index 528572c..f453737 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,17 @@ Collaborative OpenCode session sharing. Pair-program a live AI session from anot ## How it works -Chorus is an [OpenCode](https://github.com/sst/opencode) plugin plus a **Rust WebSocket relay** (`chorus-relay`). +Chorus is an [OpenCode](https://github.com/sst/opencode) plugin, a **VS Code adapter**, and a **Rust WebSocket relay** (`chorus-relay`). The relay is the core: adapters are thin clients on the same `/host` + `/ws` wire protocol (`@chorus/shared`). ``` -Host runs opencode → plugin loads -Host runs: /chorus-share → plugin spawns chorus-relay + issues join token -Joiner runs: /chorus-join → connects to relay /ws -Joiner prompts → relay → host control channel → host session.prompt +Host adapter (OpenCode or VS Code) → spawns chorus-relay + issues join token via /host +Joiner adapter (OpenCode or VS Code) → connects to relay /ws +Joiner prompts → relay → host control channel → host session (OpenCode) or host panel (VS Code) ``` -Both sides need OpenCode + the chorus plugin. The host machine needs the `chorus-relay` binary on `PATH` or built in-repo (`target/release/chorus-relay`). Override with `CHORUS_RELAY_BIN`. +**OpenCode ↔ OpenCode** is the primary path (full LLM loop + transcript mirror). **VS Code** uses the same relay: it can host a session that terminal/OpenCode joiners connect to, or join a terminal/OpenCode host. VS Code does not drive OpenCode’s model when sharing — publish host/AI lines manually or pair with an OpenCode host. See [packages/vscode/README.md](packages/vscode/README.md). + +The host machine needs the `chorus-relay` binary on `PATH` or built in-repo (`target/release/chorus-relay`). Override with `CHORUS_RELAY_BIN`. ## Installation @@ -43,6 +44,16 @@ Then add to OpenCode config: } ``` +### VS Code adapter + +```sh +cargo build -p chorus-relay --release +bun install +bun run build:ts +``` + +In VS Code: **Extensions: Install from Location…** → `packages/vscode`, then **Chorus: Share Session** / **Join Session**. Details: [packages/vscode/README.md](packages/vscode/README.md). + ## Layout One monorepo, two ecosystems, one wire contract: @@ -50,6 +61,8 @@ One monorepo, two ecosystems, one wire contract: | Path | Artifact | Description | |---|---|---| | `packages/plugin` | npm `@chorus/plugin` | OpenCode plugin — tools, hooks, spawns/manages relay | +| `packages/client` | npm `@chorus/client` | Shared `JoinClient` + `RelayServer` for host adapters | +| `packages/vscode` | VS Code extension `chorus` | Share/join Chorus sessions from VS Code | | `packages/shared` | npm `@chorus/shared` | TypeScript types + codecs for joiner and host-control protocols | | `crates/chorus-relay` | `chorus-relay` binary | Rust WebSocket relay (`/ws` joiners, `/host` control plane) | | `protocol/` | fixtures (not published) | Canonical JSON examples both TS and Rust must deserialize | @@ -105,6 +118,28 @@ For live mirrored context on joiners, open the **web UI** (not only `opencode at `opencode attach` works for driving the session, but OpenCode’s attach TUI often does not live-render plugin-injected transcript lines. +### Adapter & relay e2e (no GUI) + +Protocol-level tests for the shared `@chorus/client` stack (same code paths as the OpenCode plugin and VS Code extension): + +```sh +bun run test:vscode-e2e # VS Code adapter: email gate, pending approve, collab.input +bun run test:vscode-relay-e2e # cross-adapter: VS Code host ↔ terminal joiner (both directions) +bun run test:adapters-e2e # both of the above +bun run test:security-e2e # OpenCode host + protocol joiner (requires multi-agent up) +bun run test:network-e2e # CIDR / source-port allowlist on chorus-relay +``` + +**Three-environment relay test** (`test:vscode-relay-e2e`): models host vscode, joiner vscode, and a disallowed joiner. Run all scenarios locally (default), or split across machines with `CHORUS_E2E_ROLE`: + +| Env | Role | Command | +|-----|------|---------| +| env1 | host vscode | `CHORUS_E2E_ROLE=host-vscode bun scripts/vscode-relay-e2e.ts` | +| env2 | joiner vscode | `CHORUS_E2E_ROLE=joiner-vscode bun scripts/vscode-relay-e2e.ts` | +| env3 | disallowed joiner | `CHORUS_E2E_ROLE=disallowed-joiner bun scripts/vscode-relay-e2e.ts` | + +Coordination file: `CHORUS_E2E_STATE` (default `/tmp/chorus-vscode-relay-e2e.json`). + ## Configuration Chorus reads a layered **config file** plus environment variables. diff --git a/bun.lock b/bun.lock index e543bac..0f639d2 100644 --- a/bun.lock +++ b/bun.lock @@ -8,12 +8,25 @@ "typescript": "^5.7.3", }, }, + "packages/client": { + "name": "@chorus/client", + "version": "0.1.0", + "dependencies": { + "@chorus/shared": "workspace:*", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^22.10.7", + "typescript": "^5.7.3", + }, + }, "packages/plugin": { "name": "@chorus/plugin", "version": "0.1.6", "dependencies": { "@aws-sdk/client-s3": "^3.726.0", "@aws-sdk/lib-storage": "^3.726.0", + "@chorus/client": "workspace:*", "@chorus/shared": "workspace:*", "zod": "4.1.8", }, @@ -41,6 +54,19 @@ "vitest": "^2.1.8", }, }, + "packages/vscode": { + "name": "chorus", + "version": "0.1.0", + "dependencies": { + "@chorus/client": "workspace:*", + "@chorus/shared": "workspace:*", + }, + "devDependencies": { + "@types/node": "^22.10.7", + "@types/vscode": "^1.90.0", + "typescript": "^5.7.3", + }, + }, }, "packages": { "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], @@ -107,6 +133,8 @@ "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], + "@chorus/client": ["@chorus/client@workspace:packages/client"], + "@chorus/plugin": ["@chorus/plugin@workspace:packages/plugin"], "@chorus/shared": ["@chorus/shared@workspace:packages/shared"], @@ -235,6 +263,8 @@ "@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], + "@types/vscode": ["@types/vscode@1.125.0", "", {}, "sha512-0icm/ZQAaism87P0ekHqi4/Ju9du+Tm0RUW+y7vqRsxY2cY0FNRX1nAnaW7nT6npPt2tfHiheZ55Zm9UhqonFA=="], + "@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="], "@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="], @@ -265,6 +295,8 @@ "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + "chorus": ["chorus@workspace:packages/vscode"], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], diff --git a/crates/chorus-relay/Cargo.toml b/crates/chorus-relay/Cargo.toml index 5edfe31..38cd9ce 100644 --- a/crates/chorus-relay/Cargo.toml +++ b/crates/chorus-relay/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chorus-relay" -version = "2.0.0" +version = "2.1.0" edition.workspace = true license.workspace = true authors.workspace = true diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 38e25fc..3d0ed04 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -48,8 +48,9 @@ Recommended shape: ``` - **v1:** OpenCode adapter = this repo’s plugin + local **Rust** `chorus-relay` -- **v1.x:** keep `@chorus/shared` as the stable joiner + host-control contract -- **v2:** optional second adapter (Codex app-server is the best #2) only after OpenCode UX is solid +- **v1.x:** keep `@chorus/shared` as the stable joiner + host-control contract; `@chorus/client` holds `JoinClient` / `RelayServer` for non-OpenCode hosts +- **v1.x adapters in-monorepo:** VS Code (`packages/vscode`) — same wire protocol and `@chorus/client` relay stack; host-specific UI only. Cross-adapter (VS Code host ↔ terminal joiner) is validated by `bun run test:vscode-relay-e2e`. +- **v2:** optional further adapters (Codex app-server remains a strong candidate) once OpenCode UX is solid; split adapter packages into separate repos only when publish/CI ownership diverges **Do not** make Claude Code / Amp the primary host — large audiences, but proprietary control planes. Treat them as distribution adapters later, not the core. diff --git a/docs/STATUS.md b/docs/STATUS.md index b766f5b..ca0a71d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,19 +1,22 @@ # Chorus status — what still needs to happen -Snapshot as of 2026-08-21 (toward `v2.0.0` network lockdown). Tests/typecheck/build are green. +Snapshot as of 2026-08-31 (`v2.1.0` — VS Code adapter + `@chorus/client`). Tests/typecheck/build are green. ## Current state -Chorus is an **OpenCode↔OpenCode** LAN collaboration stack: +Chorus is a **relay-first** LAN collaboration stack with two adapters on one wire protocol: -- Host: `/chorus-share` → spawns Rust `chorus-relay` + issues token via `/host` -- Joiner: `/chorus-join` → connects to relay `/ws`, forwards prompts into the host session -- Side channel: `/chorus-chat` + typing toasts +- **OpenCode** (terminal): `/chorus-share` → spawns Rust `chorus-relay` + issues token via `/host`; `/chorus-join` → `/ws`; full LLM loop + transcript mirror +- **VS Code** (`packages/vscode`): same `JoinClient` + `RelayServer` via `@chorus/client`; can host relay for terminal joiners or join a terminal host +- Side channel: `/chorus-chat` + typing toasts (OpenCode); VS Code panel chat - Optional S3/R2 backup of user events +- **v2.0.0 network lockdown**: CIDR allow/deny, bind policy, source-port allowlist ([docs/NETWORK.md](./NETWORK.md)) -The browser companion (`packages/web`) was intentionally removed. Joiners now mirror the host transcript into their OpenCode session (`[Host]:` / `[AI]:` lines via `noReply` inject); side-channel chat/typing remain toasts. +The browser companion (`packages/web`) was intentionally removed. Joiners mirror the host transcript into their OpenCode session (`[Host]:` / `[AI]:` lines via `noReply` inject); side-channel chat/typing remain toasts. The in-process Bun relay has been replaced by `crates/chorus-relay`. +**Editor adapters:** VS Code shipped in-repo (`packages/vscode`); Zed adapter deferred to a separate branch/PR. + Differentiation vs nearby OpenCode plugins (`opencode-live`, `opencode-sessions`, `opencode-ensemble`, `opencode-relay`): those target **multi-agent / same-DB sync**. Chorus targets **multi-human** pair programming on one live AI session. ## Priority backlog @@ -64,19 +67,32 @@ Enterprise gap analysis (what would actually pass a security review vs what ship ### P2 — backup & polish -### P2 — backup & polish - 22. Backup AI + chat events; expose restore/list; set `endedAt` on stop. 23. Drop unused `@aws-sdk/lib-storage` or use it. 24. Broader tests: roles/view-forbid, kick/close, chat/typing, plugin entry, tunnel. 25. Native slash-command registration when [opencode#5305](https://github.com/sst/opencode/issues/5305) lands. +### Adapter e2e shipped + +- `bun run test:vscode-e2e` — VS Code path: email gate, pending approve, `collab.input` +- `bun run test:vscode-relay-e2e` — three-env gate (host vscode / joiner vscode / disallowed joiner) + VS Code ↔ terminal cross-adapter +- `bun run test:adapters-e2e` — both of the above +- `bun run test:security-e2e` — OpenCode host + protocol joiner (approval flow) +- `bun run test:network-e2e` — CIDR / source-port allowlist on `chorus-relay` + ## Local multi-agent harness `scripts/multi-agent.ts` (via `bun run multi-agent`) can spawn N isolated `opencode serve` instances with the Chorus plugin, then automate `/chorus-share` + `/chorus-join` through OpenCode’s `/session/:id/command` API. Also includes `relay-stress` for concurrent protocol joiners without OpenCode. +## Adapter surface (monorepo) + +- **OpenCode** (`packages/plugin`) — primary host; full LLM loop + transcript mirror. Re-exports `@chorus/client` for relay/join. +- **VS Code** (`packages/vscode`) — share/join via `@chorus/client`; sidebar transcript; session access control (approval, email gate, repo gate). Can host relay for terminal joiners or join a terminal host. Does **not** drive OpenCode’s model when sharing — publish host lines manually or pair with OpenCode. +- **Shared client** (`packages/client`) — `JoinClient` + `RelayServer`; used by both adapters. UI is secondary; relay host/joiner interaction is the contract. + ## Explicit non-goals (for now) - Rebuilding a full coding agent harness from scratch. - Competing with multi-agent orchestration plugins. - Making the browser app the primary UX again (optional later for non-OpenCode observers). +- Splitting the monorepo until adapter publish cadence actually diverges. diff --git a/docs/walkthrough/chorus_join_dialog.png b/docs/walkthrough/chorus_join_dialog.png new file mode 100644 index 0000000..5c35f05 Binary files /dev/null and b/docs/walkthrough/chorus_join_dialog.png differ diff --git a/docs/walkthrough/chorus_pending_approve.png b/docs/walkthrough/chorus_pending_approve.png new file mode 100644 index 0000000..1727085 Binary files /dev/null and b/docs/walkthrough/chorus_pending_approve.png differ diff --git a/docs/walkthrough/chorus_share_active.png b/docs/walkthrough/chorus_share_active.png new file mode 100644 index 0000000..9857716 Binary files /dev/null and b/docs/walkthrough/chorus_share_active.png differ diff --git a/package.json b/package.json index a612396..029b9df 100644 --- a/package.json +++ b/package.json @@ -27,19 +27,26 @@ "scripts": { "build:relay": "cargo build -p chorus-relay --release", "build:shared": "bun run --filter @chorus/shared build", + "build:client": "bun run --filter @chorus/client build", "build:plugin": "bun run --filter @chorus/plugin build", - "build:ts": "bun run build:shared && bun run build:plugin", + "build:vscode": "bun run --filter chorus build", + "build:ts": "bun run build:shared && bun run build:client && bun run build:plugin && bun run build:vscode", "build": "bun run build:relay && bun run build:ts", "test:relay": "cargo test -p chorus-relay", - "test:ts": "bun run build:shared && bun run --filter @chorus/shared test && bun run --filter @chorus/plugin test", + "test:ts": "bun run build:shared && bun run build:client && bun run --filter @chorus/shared test && bun run --filter @chorus/client test && bun run --filter @chorus/plugin test && bun run --filter chorus test", "test": "bun run build:relay && bun run test:relay && bun run test:ts", - "typecheck": "bun run build:shared && bun run --filter @chorus/shared typecheck && bun run --filter @chorus/plugin typecheck", - "clean": "bun run --filter @chorus/shared clean && bun run --filter @chorus/plugin clean && cargo clean", + "typecheck": "bun run build:shared && bun run build:client && bun run --filter @chorus/shared typecheck && bun run --filter @chorus/client typecheck && bun run --filter @chorus/plugin typecheck && bun run --filter chorus typecheck", + "clean": "bun run --filter @chorus/shared clean && bun run --filter @chorus/client clean && bun run --filter @chorus/plugin clean && bun run --filter chorus clean && cargo clean", "multi-agent": "bun scripts/multi-agent.ts", "test:multi-agent": "bun scripts/multi-agent.ts setup --agents 2 && bun scripts/multi-agent.ts up --agents 2 && bun scripts/multi-agent.ts smoke && bun scripts/multi-agent.ts pair && bun scripts/multi-agent.ts down", - "test:integration": "bun run test && bun run typecheck && bun run test:multi-agent && bun run test:security-e2e && bun run test:relay-stress", + "test:integration": "bun run test && bun run typecheck && bun run test:multi-agent && bun run test:security-e2e && bun run test:relay-stress && bun run test:vscode-relay-e2e", "test:relay-stress": "bun scripts/multi-agent.ts relay-stress --agents 5", "test:network-e2e": "bun run build:relay && bun scripts/network-e2e.ts", + "test:security-e2e": "bun scripts/security-e2e.ts", + "test:vscode-e2e": "bun scripts/vscode-e2e.ts", + "test:vscode-relay-e2e": "bun scripts/vscode-relay-e2e.ts", + "test:plugin-e2e": "bun run test:multi-agent && bun run test:security-e2e", + "test:adapters-e2e": "bun run test:vscode-e2e && bun run test:vscode-relay-e2e", "docker-agents": "bun scripts/docker-agents.ts", "test:docker-agents": "bun scripts/docker-agents.ts up --agents 2 && bun scripts/docker-agents.ts smoke && bun scripts/docker-agents.ts pair && bun scripts/docker-agents.ts down" }, diff --git a/packages/client/dist/index.d.ts b/packages/client/dist/index.d.ts new file mode 100644 index 0000000..11edbe7 --- /dev/null +++ b/packages/client/dist/index.d.ts @@ -0,0 +1,3 @@ +export { JoinClient, type JoinState, type JoinStatus, } from "./join.js"; +export { RelayServer, relayOptionsFromEnv, type RelayServerOptions, type RelayNetworkOptions, } from "./relay.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/client/dist/index.d.ts.map b/packages/client/dist/index.d.ts.map new file mode 100644 index 0000000..189c941 --- /dev/null +++ b/packages/client/dist/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,KAAK,SAAS,EACd,KAAK,UAAU,GAChB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,WAAW,EACX,mBAAmB,EACnB,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,GACzB,MAAM,YAAY,CAAC"} \ No newline at end of file diff --git a/packages/client/dist/index.js b/packages/client/dist/index.js new file mode 100644 index 0000000..c71c32b --- /dev/null +++ b/packages/client/dist/index.js @@ -0,0 +1,3 @@ +export { JoinClient, } from "./join.js"; +export { RelayServer, relayOptionsFromEnv, } from "./relay.js"; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/packages/client/dist/index.js.map b/packages/client/dist/index.js.map new file mode 100644 index 0000000..1b62086 --- /dev/null +++ b/packages/client/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,GAGX,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,WAAW,EACX,mBAAmB,GAGpB,MAAM,YAAY,CAAC"} \ No newline at end of file diff --git a/packages/client/dist/join.d.ts b/packages/client/dist/join.d.ts new file mode 100644 index 0000000..701a4b8 --- /dev/null +++ b/packages/client/dist/join.d.ts @@ -0,0 +1,37 @@ +import type { SessionEvent, ConnectedUser } from "@chorus/shared"; +export type JoinStatus = "connecting" | "pending" | "connected" | "disconnected" | "error"; +export interface JoinState { + status: JoinStatus; + sessionId: string; + userId?: string; + users: ConnectedUser[]; + recentEvents: SessionEvent[]; + error?: string; +} +export declare class JoinClient { + private readonly relayUrl; + private readonly token; + private readonly displayName; + private readonly repoRemote?; + private readonly email?; + private ws; + private state; + private onEvent?; + private onChatMessage?; + private onTyping?; + private onPending?; + private onApproved?; + constructor(relayUrl: string, token: string, displayName: string, repoRemote?: string | undefined, email?: string | undefined); + connect(): Promise; + sendInput(content: string): void; + sendChat(content: string): void; + setChatHandler(fn: (displayName: string | undefined, content: string) => void): void; + setTypingHandler(fn: (displayName: string | undefined) => void): void; + setEventHandler(fn: (event: SessionEvent) => void): void; + setPendingHandler(fn: (userId: string) => void): void; + setApprovedHandler(fn: () => void): void; + sendTyping(): void; + getState(): Readonly; + disconnect(): void; +} +//# sourceMappingURL=join.d.ts.map \ No newline at end of file diff --git a/packages/client/dist/join.d.ts.map b/packages/client/dist/join.d.ts.map new file mode 100644 index 0000000..c840c37 --- /dev/null +++ b/packages/client/dist/join.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"join.d.ts","sourceRoot":"","sources":["../src/join.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAOlE,MAAM,MAAM,UAAU,GAAG,YAAY,GAAG,SAAS,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAC;AAE3F,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,UAAU,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,YAAY,EAAE,YAAY,EAAE,CAAC;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,qBAAa,UAAU;IAUnB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC;IAC5B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC;IAbzB,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,KAAK,CAAY;IACzB,OAAO,CAAC,OAAO,CAAC,CAAgC;IAChD,OAAO,CAAC,aAAa,CAAC,CAA6D;IACnF,OAAO,CAAC,QAAQ,CAAC,CAA4C;IAC7D,OAAO,CAAC,SAAS,CAAC,CAA2B;IAC7C,OAAO,CAAC,UAAU,CAAC,CAAa;gBAGb,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,EACnB,UAAU,CAAC,EAAE,MAAM,YAAA,EACnB,KAAK,CAAC,EAAE,MAAM,YAAA;IAUjC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAgIxB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAKhC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAK/B,cAAc,CAAC,EAAE,EAAE,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI;IAIpF,gBAAgB,CAAC,EAAE,EAAE,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,GAAG,IAAI;IAIrE,eAAe,CAAC,EAAE,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,GAAG,IAAI;IAIxD,iBAAiB,CAAC,EAAE,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI;IAIrD,kBAAkB,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,IAAI;IAIxC,UAAU,IAAI,IAAI;IAKlB,QAAQ,IAAI,QAAQ,CAAC,SAAS,CAAC;IAI/B,UAAU,IAAI,IAAI;CAKnB"} \ No newline at end of file diff --git a/packages/client/dist/join.js b/packages/client/dist/join.js new file mode 100644 index 0000000..af12bfb --- /dev/null +++ b/packages/client/dist/join.js @@ -0,0 +1,172 @@ +import { encodeMessage, decodeServerMessage, } from "@chorus/shared"; +export class JoinClient { + relayUrl; + token; + displayName; + repoRemote; + email; + ws = null; + state; + onEvent; + onChatMessage; + onTyping; + onPending; + onApproved; + constructor(relayUrl, token, displayName, repoRemote, email) { + this.relayUrl = relayUrl; + this.token = token; + this.displayName = displayName; + this.repoRemote = repoRemote; + this.email = email; + this.state = { + status: "connecting", + sessionId: "", + users: [], + recentEvents: [], + }; + } + connect() { + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.relayUrl); + this.ws = ws; + let settled = false; + const succeed = () => { + if (settled) + return; + settled = true; + resolve(); + }; + const fail = (err) => { + if (settled) + return; + settled = true; + reject(err); + }; + ws.onopen = () => { + const auth = { + type: "auth", + token: this.token, + displayName: this.displayName, + }; + if (this.repoRemote) + auth.repoRemote = this.repoRemote; + if (this.email) + auth.email = this.email; + ws.send(encodeMessage(auth)); + }; + ws.onmessage = (ev) => { + let msg; + try { + msg = decodeServerMessage(ev.data); + } + catch { + return; + } + switch (msg.type) { + case "auth.pending": + this.state.status = "pending"; + this.state.userId = msg.userId; + this.onPending?.(msg.userId); + succeed(); + break; + case "auth.denied": + this.state.status = "error"; + this.state.error = msg.message; + fail(new Error(msg.message)); + ws.close(); + break; + case "session.history": + this.state.recentEvents = msg.events.slice(-50); + this.state.status = "connected"; + this.onApproved?.(); + succeed(); + break; + case "session.event": + this.state.recentEvents = [...this.state.recentEvents.slice(-49), msg.event]; + this.onEvent?.(msg.event); + break; + case "user.list": + this.state.users = msg.users; + break; + case "user.joined": + this.state.users = [...this.state.users, msg.user]; + break; + case "user.left": + this.state.users = this.state.users.filter((u) => u.userId !== msg.userId); + break; + case "user.role_changed": + this.state.users = this.state.users.map((u) => u.userId === msg.userId ? { ...u, role: msg.role } : u); + break; + case "chat.message": + this.onChatMessage?.(msg.message.displayName, msg.message.content); + break; + case "user.typing": + this.onTyping?.(msg.displayName); + break; + case "session.closed": + this.state.status = "disconnected"; + ws.close(); + break; + case "error": { + const wasAdmitted = this.state.status === "connected" || this.state.status === "pending"; + this.state.status = "error"; + this.state.error = msg.message; + if (!wasAdmitted) { + fail(new Error(msg.message)); + } + break; + } + } + }; + ws.onerror = () => { + this.state.status = "error"; + this.state.error = "Connection error"; + fail(new Error("WebSocket connection error")); + }; + ws.onclose = () => { + if (this.state.status === "connected" || this.state.status === "pending") { + this.state.status = "disconnected"; + } + }; + }); + } + sendInput(content) { + if (!this.ws || this.state.status !== "connected") + return; + this.ws.send(encodeMessage({ type: "collab.input", content })); + } + sendChat(content) { + if (!this.ws || this.state.status !== "connected") + return; + this.ws.send(encodeMessage({ type: "chat.send", content })); + } + setChatHandler(fn) { + this.onChatMessage = fn; + } + setTypingHandler(fn) { + this.onTyping = fn; + } + setEventHandler(fn) { + this.onEvent = fn; + } + setPendingHandler(fn) { + this.onPending = fn; + } + setApprovedHandler(fn) { + this.onApproved = fn; + } + sendTyping() { + if (!this.ws || this.state.status !== "connected") + return; + this.ws.send(encodeMessage({ type: "typing" })); + } + getState() { + return this.state; + } + disconnect() { + this.ws?.close(); + this.ws = null; + this.state.status = "disconnected"; + } +} +//# sourceMappingURL=join.js.map \ No newline at end of file diff --git a/packages/client/dist/join.js.map b/packages/client/dist/join.js.map new file mode 100644 index 0000000..6c77a06 --- /dev/null +++ b/packages/client/dist/join.js.map @@ -0,0 +1 @@ +{"version":3,"file":"join.js","sourceRoot":"","sources":["../src/join.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/client/dist/relay.d.ts b/packages/client/dist/relay.d.ts new file mode 100644 index 0000000..b9b892e --- /dev/null +++ b/packages/client/dist/relay.d.ts @@ -0,0 +1,113 @@ +import type { ConnectedUser, SessionEvent, SessionToken, UserRole } from "@chorus/shared"; +export type RelayServerOptions = { + /** Host running the relay (default 127.0.0.1). */ + host?: string; + /** When set with external mode, attach instead of spawning. */ + hostToken?: string; + /** + * Attach to an already-running relay (e.g. on the Docker host). + * When true, requires hostToken and does not spawn/kill a subprocess. + */ + external?: boolean; + /** Bind address passed to chorus-relay (default 0.0.0.0). */ + bind?: string; + /** CIDR/IP allowlist for TCP peers. */ + allowedCidrs?: string[]; + /** Explicit deny CIDRs (deny wins). */ + deniedCidrs?: string[]; + /** Peer source-port allowlist (single-machine e2e). */ + allowedPorts?: number[]; + /** Refuse 0.0.0.0 / :: bind when false. */ + allowOpenBind?: boolean; + /** Admit loopback IPs even when allowlist is set (default true). */ + allowLoopback?: boolean; +}; +export type RelayNetworkOptions = { + bind?: string; + allowedCidrs?: string[]; + deniedCidrs?: string[]; + allowedPorts?: number[]; + allowOpenBind?: boolean; + allowLoopback?: boolean; +}; +/** + * Resolve relay connection settings from env. + * + * External attach (relay already running elsewhere): + * CHORUS_RELAY_HOST=host.docker.internal:7742 + * CHORUS_HOST_TOKEN= + * CHORUS_EXTERNAL_RELAY=1 (optional; implied when HOST_TOKEN is set) + */ +export declare function relayOptionsFromEnv(defaultPort: number): { + port: number; + opts: RelayServerOptions; +}; +/** + * Manages the Rust `chorus-relay` subprocess and the host control WebSocket. + * Joiner-facing protocol on `/ws` is unchanged; host adapters talk to `/host`. + */ +export declare class RelayServer { + private readonly port; + private child; + private ws; + private hostToken; + private running; + private clients; + private readonly host; + private readonly external; + private bind; + private allowedCidrs; + private deniedCidrs; + private allowedPorts; + private allowOpenBind; + private allowLoopback; + private pendingToken; + private onInjectInput?; + private onChatMessage?; + private onTyping?; + private onUserPending?; + private onUserJoined?; + private onUserLeft?; + constructor(port: number, opts?: RelayServerOptions); + /** Apply network policy before start() (from project/org chorus.json). */ + setNetworkOptions(opts: RelayNetworkOptions): void; + setInputHandler(fn: (content: string, userId: string, displayName?: string) => Promise): void; + setChatHandler(fn: (displayName: string | undefined, content: string) => void): void; + setTypingHandler(fn: (displayName: string | undefined) => void): void; + setUserPendingHandler(fn: (user: ConnectedUser) => void): void; + setUserJoinedHandler(fn: (user: ConnectedUser) => void): void; + setUserLeftHandler(fn: (userId: string) => void): void; + start(): Promise; + private statusUrl; + private hostWsUrl; + private waitForPort; + private connectHost; + private handleHostMessage; + private send; + issueToken(sessionId: string, role?: UserRole, ttlMs?: number): Promise; + setSessionPolicy(opts: { + requireApproval?: boolean; + repoRemote?: string | null; + allowedEmailDomain?: string | null; + additionalRepoRemotePrefixes?: string[] | null; + repoRemoteRewrites?: { + from: string; + to: string; + }[] | null; + }): void; + approveUser(userId: string): void; + denyUser(userId: string): void; + kickUser(userId: string): void; + pushEvent(event: SessionEvent): void; + sendChat(displayName: string | undefined, content: string): void; + stop(): Promise; + private waitUntilStopped; + get isRunning(): boolean; + get clientCount(): number; + getPort(): number; + getHost(): string; + getBind(): string; + getAllowedCidrs(): string[]; + isExternal(): boolean; +} +//# sourceMappingURL=relay.d.ts.map \ No newline at end of file diff --git a/packages/client/dist/relay.d.ts.map b/packages/client/dist/relay.d.ts.map new file mode 100644 index 0000000..f5af588 --- /dev/null +++ b/packages/client/dist/relay.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"relay.d.ts","sourceRoot":"","sources":["../src/relay.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/client/dist/relay.js b/packages/client/dist/relay.js new file mode 100644 index 0000000..1df791d --- /dev/null +++ b/packages/client/dist/relay.js @@ -0,0 +1,396 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomBytes } from "node:crypto"; +import { encodeHostMessage, decodeRelayToHost, } from "@chorus/shared"; +function resolveRelayBin() { + if (process.env["CHORUS_RELAY_BIN"]) + return process.env["CHORUS_RELAY_BIN"]; + const here = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + join(here, "../../../target/release/chorus-relay"), + join(here, "../../../target/debug/chorus-relay"), + join(here, "../../../../target/release/chorus-relay"), + join(here, "../../../../target/debug/chorus-relay"), + join(here, "../../../../../target/release/chorus-relay"), + join(here, "../../../../../target/debug/chorus-relay"), + ]; + for (const path of candidates) { + if (existsSync(path)) + return path; + } + return "chorus-relay"; +} +function parseRelayHost(raw, fallbackPort) { + if (!raw) + return { host: "127.0.0.1", port: fallbackPort }; + const trimmed = raw.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""); + const [hostPart, portPart] = trimmed.split(":"); + const port = portPart ? parseInt(portPart, 10) : fallbackPort; + return { host: hostPart || "127.0.0.1", port: Number.isFinite(port) ? port : fallbackPort }; +} +/** + * Resolve relay connection settings from env. + * + * External attach (relay already running elsewhere): + * CHORUS_RELAY_HOST=host.docker.internal:7742 + * CHORUS_HOST_TOKEN= + * CHORUS_EXTERNAL_RELAY=1 (optional; implied when HOST_TOKEN is set) + */ +export function relayOptionsFromEnv(defaultPort) { + const parsed = parseRelayHost(process.env["CHORUS_RELAY_HOST"], defaultPort); + const hostToken = process.env["CHORUS_HOST_TOKEN"]; + const external = process.env["CHORUS_EXTERNAL_RELAY"] === "1" || + process.env["CHORUS_EXTERNAL_RELAY"] === "true" || + Boolean(hostToken && process.env["CHORUS_RELAY_HOST"]); + return { + port: parsed.port, + opts: { + host: parsed.host, + hostToken: hostToken || undefined, + external, + }, + }; +} +/** + * Manages the Rust `chorus-relay` subprocess and the host control WebSocket. + * Joiner-facing protocol on `/ws` is unchanged; host adapters talk to `/host`. + */ +export class RelayServer { + port; + child = null; + ws = null; + hostToken = ""; + running = false; + clients = 0; + host; + external; + bind; + allowedCidrs; + deniedCidrs; + allowedPorts; + allowOpenBind; + allowLoopback; + pendingToken = null; + onInjectInput; + onChatMessage; + onTyping; + onUserPending; + onUserJoined; + onUserLeft; + constructor(port, opts = {}) { + this.port = port; + this.host = opts.host ?? "127.0.0.1"; + this.external = Boolean(opts.external); + this.hostToken = opts.hostToken ?? ""; + this.bind = opts.bind ?? "0.0.0.0"; + this.allowedCidrs = opts.allowedCidrs ? [...opts.allowedCidrs] : []; + this.deniedCidrs = opts.deniedCidrs ? [...opts.deniedCidrs] : []; + this.allowedPorts = opts.allowedPorts ? [...opts.allowedPorts] : []; + this.allowOpenBind = opts.allowOpenBind ?? true; + this.allowLoopback = opts.allowLoopback ?? true; + } + /** Apply network policy before start() (from project/org chorus.json). */ + setNetworkOptions(opts) { + if (opts.bind !== undefined) + this.bind = opts.bind; + if (opts.allowedCidrs !== undefined) + this.allowedCidrs = [...opts.allowedCidrs]; + if (opts.deniedCidrs !== undefined) + this.deniedCidrs = [...opts.deniedCidrs]; + if (opts.allowedPorts !== undefined) + this.allowedPorts = [...opts.allowedPorts]; + if (opts.allowOpenBind !== undefined) + this.allowOpenBind = opts.allowOpenBind; + if (opts.allowLoopback !== undefined) + this.allowLoopback = opts.allowLoopback; + } + setInputHandler(fn) { + this.onInjectInput = fn; + } + setChatHandler(fn) { + this.onChatMessage = fn; + } + setTypingHandler(fn) { + this.onTyping = fn; + } + setUserPendingHandler(fn) { + this.onUserPending = fn; + } + setUserJoinedHandler(fn) { + this.onUserJoined = fn; + } + setUserLeftHandler(fn) { + this.onUserLeft = fn; + } + async start() { + if (this.running) + return; + if (this.external) { + if (!this.hostToken) { + throw new Error("External relay mode requires CHORUS_HOST_TOKEN (and usually CHORUS_RELAY_HOST)."); + } + await this.waitForPort(); + await this.connectHost(); + this.running = true; + return; + } + this.hostToken = this.hostToken || randomBytes(32).toString("hex"); + const bin = resolveRelayBin(); + const args = [ + "--port", + String(this.port), + "--bind", + this.bind, + "--host-token", + this.hostToken, + "--allow-open-bind", + this.allowOpenBind ? "true" : "false", + "--allow-loopback", + this.allowLoopback ? "true" : "false", + ]; + for (const cidr of this.allowedCidrs) { + args.push("--allow-cidr", cidr); + } + for (const cidr of this.deniedCidrs) { + args.push("--deny-cidr", cidr); + } + for (const port of this.allowedPorts) { + args.push("--allow-port", String(port)); + } + this.child = spawn(bin, args, { + stdio: ["ignore", "ignore", "pipe"], + env: { ...process.env }, + }); + this.child.on("exit", () => { + this.running = false; + this.ws = null; + }); + await this.waitForPort(); + await this.connectHost(); + this.running = true; + } + statusUrl() { + return `http://${this.host}:${this.port}/status`; + } + hostWsUrl() { + return `ws://${this.host}:${this.port}/host`; + } + async waitForPort(timeoutMs = 8000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(this.statusUrl(), { signal: AbortSignal.timeout(1000) }); + if (res.ok) + return; + } + catch { + // not up yet + } + await new Promise((r) => setTimeout(r, 40)); + } + throw new Error(this.external + ? `External chorus-relay not reachable at ${this.host}:${this.port}. Is it running on the host?` + : `chorus-relay did not become ready on port ${this.port}. ` + + `Is the binary available? (CHORUS_RELAY_BIN or cargo build -p chorus-relay --release)`); + } + connectHost() { + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.hostWsUrl()); + this.ws = ws; + const timer = setTimeout(() => reject(new Error("host control connect timeout")), 5000); + ws.onopen = () => { + this.send({ type: "host.auth", token: this.hostToken }); + }; + ws.onmessage = (ev) => { + let msg; + try { + msg = decodeRelayToHost(String(ev.data)); + } + catch { + return; + } + this.handleHostMessage(msg, () => { + clearTimeout(timer); + resolve(); + }); + }; + ws.onerror = () => { + clearTimeout(timer); + reject(new Error("host control WebSocket error")); + }; + ws.onclose = () => { + this.ws = null; + this.running = false; + }; + }); + } + handleHostMessage(msg, onReady) { + switch (msg.type) { + case "host.ready": + onReady?.(); + break; + case "token.issued": { + const { type: _t, ...token } = msg; + this.pendingToken?.resolve(token); + this.pendingToken = null; + break; + } + case "collab.input": + this.onInjectInput?.(msg.content, msg.userId, msg.displayName)?.catch(console.error); + break; + case "chat.message": + this.onChatMessage?.(msg.message.displayName, msg.message.content); + break; + case "user.typing": + this.onTyping?.(msg.displayName); + break; + case "user.pending": + this.onUserPending?.(msg.user); + break; + case "user.joined": + this.clients += 1; + this.onUserJoined?.(msg.user); + break; + case "user.left": + this.clients = Math.max(0, this.clients - 1); + this.onUserLeft?.(msg.userId); + break; + case "user.list": + this.clients = msg.users.filter((u) => u.status === "active").length; + break; + case "status": + this.clients = msg.clients; + break; + case "error": + this.pendingToken?.reject(new Error(msg.message)); + this.pendingToken = null; + break; + } + } + send(msg) { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) + return; + this.ws.send(encodeHostMessage(msg)); + } + issueToken(sessionId, role = "edit", ttlMs) { + return new Promise((resolve, reject) => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + reject(new Error("relay host control not connected")); + return; + } + this.pendingToken = { resolve, reject }; + this.send({ type: "token.issue", sessionId, role, ttlMs }); + setTimeout(() => { + if (this.pendingToken) { + this.pendingToken.reject(new Error("token.issue timed out")); + this.pendingToken = null; + } + }, 5000); + }); + } + setSessionPolicy(opts) { + this.send({ + type: "session.policy", + requireApproval: opts.requireApproval, + repoRemote: opts.repoRemote === null ? "" : opts.repoRemote, + allowedEmailDomain: opts.allowedEmailDomain === null ? "" : opts.allowedEmailDomain, + additionalRepoRemotePrefixes: opts.additionalRepoRemotePrefixes ?? undefined, + repoRemoteRewrites: opts.repoRemoteRewrites ?? undefined, + }); + } + approveUser(userId) { + this.send({ type: "host.approve", userId }); + } + denyUser(userId) { + this.send({ type: "host.deny", userId }); + } + kickUser(userId) { + this.send({ type: "host.kick", userId }); + } + pushEvent(event) { + this.send({ type: "session.event", event }); + } + sendChat(displayName, content) { + this.send({ type: "chat.send", content, displayName }); + } + async stop() { + if (!this.external) { + this.send({ type: "host.close" }); + } + try { + this.ws?.close(); + } + catch { + // ignore + } + this.ws = null; + const child = this.child; + this.child = null; + this.running = false; + this.clients = 0; + if (child && child.exitCode === null && child.signalCode === null) { + await new Promise((resolve) => { + const finish = () => resolve(); + child.once("exit", finish); + try { + child.kill("SIGTERM"); + } + catch { + finish(); + return; + } + setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + try { + child.kill("SIGKILL"); + } + catch { + finish(); + } + } + }, 1000).unref?.(); + }); + } + if (!this.external) { + await this.waitUntilStopped(); + } + } + async waitUntilStopped(timeoutMs = 2000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(this.statusUrl(), { signal: AbortSignal.timeout(250) }); + if (!res.ok) + return; + } + catch { + return; + } + await new Promise((r) => setTimeout(r, 20)); + } + } + get isRunning() { + return this.running; + } + get clientCount() { + return this.clients; + } + getPort() { + return this.port; + } + getHost() { + return this.host; + } + getBind() { + return this.bind; + } + getAllowedCidrs() { + return [...this.allowedCidrs]; + } + isExternal() { + return this.external; + } +} +//# sourceMappingURL=relay.js.map \ No newline at end of file diff --git a/packages/client/dist/relay.js.map b/packages/client/dist/relay.js.map new file mode 100644 index 0000000..6e17eef --- /dev/null +++ b/packages/client/dist/relay.js.map @@ -0,0 +1 @@ +{"version":3,"file":"relay.js","sourceRoot":"","sources":["../src/relay.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..700c7a3 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,28 @@ +{ + "name": "@chorus/client", + "version": "2.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "bun test test/join.test.ts test/relay.test.ts", + "clean": "rm -rf dist" + }, + "dependencies": { + "@chorus/shared": "workspace:*" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^22.10.7", + "typescript": "^5.7.3" + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..31fcde8 --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,11 @@ +export { + JoinClient, + type JoinState, + type JoinStatus, +} from "./join.js"; +export { + RelayServer, + relayOptionsFromEnv, + type RelayServerOptions, + type RelayNetworkOptions, +} from "./relay.js"; diff --git a/packages/client/src/join.ts b/packages/client/src/join.ts new file mode 100644 index 0000000..e3b78a5 --- /dev/null +++ b/packages/client/src/join.ts @@ -0,0 +1,215 @@ +import type { SessionEvent, ConnectedUser } from "@chorus/shared"; +import { + encodeMessage, + decodeServerMessage, + type ServerMessage, +} from "@chorus/shared"; + +export type JoinStatus = "connecting" | "pending" | "connected" | "disconnected" | "error"; + +export interface JoinState { + status: JoinStatus; + sessionId: string; + userId?: string; + users: ConnectedUser[]; + recentEvents: SessionEvent[]; + error?: string; +} + +export class JoinClient { + private ws: WebSocket | null = null; + private state: JoinState; + private onEvent?: (event: SessionEvent) => void; + private onChatMessage?: (displayName: string | undefined, content: string) => void; + private onTyping?: (displayName: string | undefined) => void; + private onPending?: (userId: string) => void; + private onApproved?: () => void; + + constructor( + private readonly relayUrl: string, + private readonly token: string, + private readonly displayName: string, + private readonly repoRemote?: string, + private readonly email?: string + ) { + this.state = { + status: "connecting", + sessionId: "", + users: [], + recentEvents: [], + }; + } + + connect(): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.relayUrl); + this.ws = ws; + let settled = false; + + const succeed = () => { + if (settled) return; + settled = true; + resolve(); + }; + + const fail = (err: Error) => { + if (settled) return; + settled = true; + reject(err); + }; + + ws.onopen = () => { + const auth: { + type: "auth"; + token: string; + displayName: string; + repoRemote?: string; + email?: string; + } = { + type: "auth", + token: this.token, + displayName: this.displayName, + }; + if (this.repoRemote) auth.repoRemote = this.repoRemote; + if (this.email) auth.email = this.email; + ws.send(encodeMessage(auth)); + }; + + ws.onmessage = (ev) => { + let msg: ServerMessage; + try { + msg = decodeServerMessage(ev.data as string); + } catch { + return; + } + + switch (msg.type) { + case "auth.pending": + this.state.status = "pending"; + this.state.userId = msg.userId; + this.onPending?.(msg.userId); + succeed(); + break; + + case "auth.denied": + this.state.status = "error"; + this.state.error = msg.message; + fail(new Error(msg.message)); + ws.close(); + break; + + case "session.history": + this.state.recentEvents = msg.events.slice(-50); + this.state.status = "connected"; + this.onApproved?.(); + succeed(); + break; + + case "session.event": + this.state.recentEvents = [...this.state.recentEvents.slice(-49), msg.event]; + this.onEvent?.(msg.event); + break; + + case "user.list": + this.state.users = msg.users; + break; + + case "user.joined": + this.state.users = [...this.state.users, msg.user]; + break; + + case "user.left": + this.state.users = this.state.users.filter((u) => u.userId !== msg.userId); + break; + + case "user.role_changed": + this.state.users = this.state.users.map((u) => + u.userId === msg.userId ? { ...u, role: msg.role } : u + ); + break; + + case "chat.message": + this.onChatMessage?.(msg.message.displayName, msg.message.content); + break; + + case "user.typing": + this.onTyping?.(msg.displayName); + break; + + case "session.closed": + this.state.status = "disconnected"; + ws.close(); + break; + + case "error": { + const wasAdmitted = + this.state.status === "connected" || this.state.status === "pending"; + this.state.status = "error"; + this.state.error = msg.message; + if (!wasAdmitted) { + fail(new Error(msg.message)); + } + break; + } + } + }; + + ws.onerror = () => { + this.state.status = "error"; + this.state.error = "Connection error"; + fail(new Error("WebSocket connection error")); + }; + + ws.onclose = () => { + if (this.state.status === "connected" || this.state.status === "pending") { + this.state.status = "disconnected"; + } + }; + }); + } + + sendInput(content: string): void { + if (!this.ws || this.state.status !== "connected") return; + this.ws.send(encodeMessage({ type: "collab.input", content })); + } + + sendChat(content: string): void { + if (!this.ws || this.state.status !== "connected") return; + this.ws.send(encodeMessage({ type: "chat.send", content })); + } + + setChatHandler(fn: (displayName: string | undefined, content: string) => void): void { + this.onChatMessage = fn; + } + + setTypingHandler(fn: (displayName: string | undefined) => void): void { + this.onTyping = fn; + } + + setEventHandler(fn: (event: SessionEvent) => void): void { + this.onEvent = fn; + } + + setPendingHandler(fn: (userId: string) => void): void { + this.onPending = fn; + } + + setApprovedHandler(fn: () => void): void { + this.onApproved = fn; + } + + sendTyping(): void { + if (!this.ws || this.state.status !== "connected") return; + this.ws.send(encodeMessage({ type: "typing" })); + } + + getState(): Readonly { + return this.state; + } + + disconnect(): void { + this.ws?.close(); + this.ws = null; + this.state.status = "disconnected"; + } +} diff --git a/packages/client/src/relay.ts b/packages/client/src/relay.ts new file mode 100644 index 0000000..7d19f41 --- /dev/null +++ b/packages/client/src/relay.ts @@ -0,0 +1,498 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomBytes } from "node:crypto"; +import type { ConnectedUser, SessionEvent, SessionToken, UserRole } from "@chorus/shared"; +import { + encodeHostMessage, + decodeRelayToHost, + type HostToRelay, + type RelayToHost, +} from "@chorus/shared"; + +function resolveRelayBin(): string { + if (process.env["CHORUS_RELAY_BIN"]) return process.env["CHORUS_RELAY_BIN"]; + + const here = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + join(here, "../../../target/release/chorus-relay"), + join(here, "../../../target/debug/chorus-relay"), + join(here, "../../../../target/release/chorus-relay"), + join(here, "../../../../target/debug/chorus-relay"), + join(here, "../../../../../target/release/chorus-relay"), + join(here, "../../../../../target/debug/chorus-relay"), + ]; + for (const path of candidates) { + if (existsSync(path)) return path; + } + return "chorus-relay"; +} + +export type RelayServerOptions = { + /** Host running the relay (default 127.0.0.1). */ + host?: string; + /** When set with external mode, attach instead of spawning. */ + hostToken?: string; + /** + * Attach to an already-running relay (e.g. on the Docker host). + * When true, requires hostToken and does not spawn/kill a subprocess. + */ + external?: boolean; + /** Bind address passed to chorus-relay (default 0.0.0.0). */ + bind?: string; + /** CIDR/IP allowlist for TCP peers. */ + allowedCidrs?: string[]; + /** Explicit deny CIDRs (deny wins). */ + deniedCidrs?: string[]; + /** Peer source-port allowlist (single-machine e2e). */ + allowedPorts?: number[]; + /** Refuse 0.0.0.0 / :: bind when false. */ + allowOpenBind?: boolean; + /** Admit loopback IPs even when allowlist is set (default true). */ + allowLoopback?: boolean; +}; + +export type RelayNetworkOptions = { + bind?: string; + allowedCidrs?: string[]; + deniedCidrs?: string[]; + allowedPorts?: number[]; + allowOpenBind?: boolean; + allowLoopback?: boolean; +}; + +function parseRelayHost(raw: string | undefined, fallbackPort: number): { host: string; port: number } { + if (!raw) return { host: "127.0.0.1", port: fallbackPort }; + const trimmed = raw.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""); + const [hostPart, portPart] = trimmed.split(":"); + const port = portPart ? parseInt(portPart, 10) : fallbackPort; + return { host: hostPart || "127.0.0.1", port: Number.isFinite(port) ? port : fallbackPort }; +} + +/** + * Resolve relay connection settings from env. + * + * External attach (relay already running elsewhere): + * CHORUS_RELAY_HOST=host.docker.internal:7742 + * CHORUS_HOST_TOKEN= + * CHORUS_EXTERNAL_RELAY=1 (optional; implied when HOST_TOKEN is set) + */ +export function relayOptionsFromEnv(defaultPort: number): { + port: number; + opts: RelayServerOptions; +} { + const parsed = parseRelayHost(process.env["CHORUS_RELAY_HOST"], defaultPort); + const hostToken = process.env["CHORUS_HOST_TOKEN"]; + const external = + process.env["CHORUS_EXTERNAL_RELAY"] === "1" || + process.env["CHORUS_EXTERNAL_RELAY"] === "true" || + Boolean(hostToken && process.env["CHORUS_RELAY_HOST"]); + + return { + port: parsed.port, + opts: { + host: parsed.host, + hostToken: hostToken || undefined, + external, + }, + }; +} + +/** + * Manages the Rust `chorus-relay` subprocess and the host control WebSocket. + * Joiner-facing protocol on `/ws` is unchanged; host adapters talk to `/host`. + */ +export class RelayServer { + private child: ChildProcess | null = null; + private ws: WebSocket | null = null; + private hostToken = ""; + private running = false; + private clients = 0; + private readonly host: string; + private readonly external: boolean; + private bind: string; + private allowedCidrs: string[]; + private deniedCidrs: string[]; + private allowedPorts: number[]; + private allowOpenBind: boolean; + private allowLoopback: boolean; + private pendingToken: { + resolve: (t: SessionToken) => void; + reject: (e: Error) => void; + } | null = null; + + private onInjectInput?: (content: string, userId: string, displayName?: string) => Promise; + private onChatMessage?: (displayName: string | undefined, content: string) => void; + private onTyping?: (displayName: string | undefined) => void; + private onUserPending?: (user: ConnectedUser) => void; + private onUserJoined?: (user: ConnectedUser) => void; + private onUserLeft?: (userId: string) => void; + + constructor( + private readonly port: number, + opts: RelayServerOptions = {} + ) { + this.host = opts.host ?? "127.0.0.1"; + this.external = Boolean(opts.external); + this.hostToken = opts.hostToken ?? ""; + this.bind = opts.bind ?? "0.0.0.0"; + this.allowedCidrs = opts.allowedCidrs ? [...opts.allowedCidrs] : []; + this.deniedCidrs = opts.deniedCidrs ? [...opts.deniedCidrs] : []; + this.allowedPorts = opts.allowedPorts ? [...opts.allowedPorts] : []; + this.allowOpenBind = opts.allowOpenBind ?? true; + this.allowLoopback = opts.allowLoopback ?? true; + } + + /** Apply network policy before start() (from project/org chorus.json). */ + setNetworkOptions(opts: RelayNetworkOptions): void { + if (opts.bind !== undefined) this.bind = opts.bind; + if (opts.allowedCidrs !== undefined) this.allowedCidrs = [...opts.allowedCidrs]; + if (opts.deniedCidrs !== undefined) this.deniedCidrs = [...opts.deniedCidrs]; + if (opts.allowedPorts !== undefined) this.allowedPorts = [...opts.allowedPorts]; + if (opts.allowOpenBind !== undefined) this.allowOpenBind = opts.allowOpenBind; + if (opts.allowLoopback !== undefined) this.allowLoopback = opts.allowLoopback; + } + + setInputHandler( + fn: (content: string, userId: string, displayName?: string) => Promise + ): void { + this.onInjectInput = fn; + } + + setChatHandler(fn: (displayName: string | undefined, content: string) => void): void { + this.onChatMessage = fn; + } + + setTypingHandler(fn: (displayName: string | undefined) => void): void { + this.onTyping = fn; + } + + setUserPendingHandler(fn: (user: ConnectedUser) => void): void { + this.onUserPending = fn; + } + + setUserJoinedHandler(fn: (user: ConnectedUser) => void): void { + this.onUserJoined = fn; + } + + setUserLeftHandler(fn: (userId: string) => void): void { + this.onUserLeft = fn; + } + + async start(): Promise { + if (this.running) return; + + if (this.external) { + if (!this.hostToken) { + throw new Error( + "External relay mode requires CHORUS_HOST_TOKEN (and usually CHORUS_RELAY_HOST)." + ); + } + await this.waitForPort(); + await this.connectHost(); + this.running = true; + return; + } + + this.hostToken = this.hostToken || randomBytes(32).toString("hex"); + const bin = resolveRelayBin(); + + const args = [ + "--port", + String(this.port), + "--bind", + this.bind, + "--host-token", + this.hostToken, + "--allow-open-bind", + this.allowOpenBind ? "true" : "false", + "--allow-loopback", + this.allowLoopback ? "true" : "false", + ]; + for (const cidr of this.allowedCidrs) { + args.push("--allow-cidr", cidr); + } + for (const cidr of this.deniedCidrs) { + args.push("--deny-cidr", cidr); + } + for (const port of this.allowedPorts) { + args.push("--allow-port", String(port)); + } + + this.child = spawn(bin, args, { + stdio: ["ignore", "ignore", "pipe"], + env: { ...process.env }, + }); + + this.child.on("exit", () => { + this.running = false; + this.ws = null; + }); + + await this.waitForPort(); + await this.connectHost(); + this.running = true; + } + + private statusUrl(): string { + return `http://${this.host}:${this.port}/status`; + } + + private hostWsUrl(): string { + return `ws://${this.host}:${this.port}/host`; + } + + private async waitForPort(timeoutMs = 8000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(this.statusUrl(), { signal: AbortSignal.timeout(1000) }); + if (res.ok) return; + } catch { + // not up yet + } + await new Promise((r) => setTimeout(r, 40)); + } + throw new Error( + this.external + ? `External chorus-relay not reachable at ${this.host}:${this.port}. Is it running on the host?` + : `chorus-relay did not become ready on port ${this.port}. ` + + `Is the binary available? (CHORUS_RELAY_BIN or cargo build -p chorus-relay --release)` + ); + } + + private connectHost(): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.hostWsUrl()); + this.ws = ws; + + const timer = setTimeout(() => reject(new Error("host control connect timeout")), 5000); + + ws.onopen = () => { + this.send({ type: "host.auth", token: this.hostToken }); + }; + + ws.onmessage = (ev) => { + let msg: RelayToHost; + try { + msg = decodeRelayToHost(String(ev.data)); + } catch { + return; + } + this.handleHostMessage(msg, () => { + clearTimeout(timer); + resolve(); + }); + }; + + ws.onerror = () => { + clearTimeout(timer); + reject(new Error("host control WebSocket error")); + }; + + ws.onclose = () => { + this.ws = null; + this.running = false; + }; + }); + } + + private handleHostMessage(msg: RelayToHost, onReady?: () => void): void { + switch (msg.type) { + case "host.ready": + onReady?.(); + break; + + case "token.issued": { + const { type: _t, ...token } = msg; + this.pendingToken?.resolve(token); + this.pendingToken = null; + break; + } + + case "collab.input": + this.onInjectInput?.(msg.content, msg.userId, msg.displayName)?.catch(console.error); + break; + + case "chat.message": + this.onChatMessage?.(msg.message.displayName, msg.message.content); + break; + + case "user.typing": + this.onTyping?.(msg.displayName); + break; + + case "user.pending": + this.onUserPending?.(msg.user); + break; + + case "user.joined": + this.clients += 1; + this.onUserJoined?.(msg.user); + break; + + case "user.left": + this.clients = Math.max(0, this.clients - 1); + this.onUserLeft?.(msg.userId); + break; + + case "user.list": + this.clients = msg.users.filter((u) => u.status === "active").length; + break; + + case "status": + this.clients = msg.clients; + break; + + case "error": + this.pendingToken?.reject(new Error(msg.message)); + this.pendingToken = null; + break; + } + } + + private send(msg: HostToRelay): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + this.ws.send(encodeHostMessage(msg)); + } + + issueToken(sessionId: string, role: UserRole = "edit", ttlMs?: number): Promise { + return new Promise((resolve, reject) => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + reject(new Error("relay host control not connected")); + return; + } + this.pendingToken = { resolve, reject }; + this.send({ type: "token.issue", sessionId, role, ttlMs }); + setTimeout(() => { + if (this.pendingToken) { + this.pendingToken.reject(new Error("token.issue timed out")); + this.pendingToken = null; + } + }, 5000); + }); + } + + setSessionPolicy(opts: { + requireApproval?: boolean; + repoRemote?: string | null; + allowedEmailDomain?: string | null; + additionalRepoRemotePrefixes?: string[] | null; + repoRemoteRewrites?: { from: string; to: string }[] | null; + }): void { + this.send({ + type: "session.policy", + requireApproval: opts.requireApproval, + repoRemote: opts.repoRemote === null ? "" : opts.repoRemote, + allowedEmailDomain: + opts.allowedEmailDomain === null ? "" : opts.allowedEmailDomain, + additionalRepoRemotePrefixes: opts.additionalRepoRemotePrefixes ?? undefined, + repoRemoteRewrites: opts.repoRemoteRewrites ?? undefined, + }); + } + + approveUser(userId: string): void { + this.send({ type: "host.approve", userId }); + } + + denyUser(userId: string): void { + this.send({ type: "host.deny", userId }); + } + + kickUser(userId: string): void { + this.send({ type: "host.kick", userId }); + } + + pushEvent(event: SessionEvent): void { + this.send({ type: "session.event", event }); + } + + sendChat(displayName: string | undefined, content: string): void { + this.send({ type: "chat.send", content, displayName }); + } + + async stop(): Promise { + if (!this.external) { + this.send({ type: "host.close" }); + } + try { + this.ws?.close(); + } catch { + // ignore + } + this.ws = null; + + const child = this.child; + this.child = null; + this.running = false; + this.clients = 0; + + if (child && child.exitCode === null && child.signalCode === null) { + await new Promise((resolve) => { + const finish = () => resolve(); + child.once("exit", finish); + try { + child.kill("SIGTERM"); + } catch { + finish(); + return; + } + setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + try { + child.kill("SIGKILL"); + } catch { + finish(); + } + } + }, 1000).unref?.(); + }); + } + + if (!this.external) { + await this.waitUntilStopped(); + } + } + + private async waitUntilStopped(timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(this.statusUrl(), { signal: AbortSignal.timeout(250) }); + if (!res.ok) return; + } catch { + return; + } + await new Promise((r) => setTimeout(r, 20)); + } + } + + get isRunning(): boolean { + return this.running; + } + + get clientCount(): number { + return this.clients; + } + + getPort(): number { + return this.port; + } + + getHost(): string { + return this.host; + } + + getBind(): string { + return this.bind; + } + + getAllowedCidrs(): string[] { + return [...this.allowedCidrs]; + } + + isExternal(): boolean { + return this.external; + } +} diff --git a/packages/plugin/test/join.test.ts b/packages/client/test/join.test.ts similarity index 97% rename from packages/plugin/test/join.test.ts rename to packages/client/test/join.test.ts index 66f8159..09235bb 100644 --- a/packages/plugin/test/join.test.ts +++ b/packages/client/test/join.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, setDefaultTimeout } from "bun:test"; -import { JoinClient } from "../src/join/index.js"; -import { RelayServer } from "../src/relay/index.js"; +import { JoinClient, RelayServer } from "../src/index.js"; setDefaultTimeout(15_000); diff --git a/packages/plugin/test/relay.test.ts b/packages/client/test/relay.test.ts similarity index 99% rename from packages/plugin/test/relay.test.ts rename to packages/client/test/relay.test.ts index 08062e0..2aa27d7 100644 --- a/packages/plugin/test/relay.test.ts +++ b/packages/client/test/relay.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, setDefaultTimeout } from "bun:test"; -import { RelayServer } from "../src/relay/index.js"; +import { RelayServer } from "../src/index.js"; import type { ServerMessage } from "@chorus/shared"; import { encodeMessage, decodeServerMessage } from "@chorus/shared"; diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..bdbdd6e --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["bun", "node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/plugin/dist/config/schema.d.ts b/packages/plugin/dist/config/schema.d.ts index 65ae899..54aae5b 100644 --- a/packages/plugin/dist/config/schema.d.ts +++ b/packages/plugin/dist/config/schema.d.ts @@ -1,9 +1,9 @@ import { z } from "zod"; /** Role granted to new share tokens when the host omits an explicit role. */ export declare const chorusRoleSchema: z.ZodEnum<{ - admin: "admin"; edit: "edit"; view: "view"; + admin: "admin"; }>; export declare const chorusSecurityConfigSchema: z.ZodObject<{ requireApproval: z.ZodDefault; @@ -11,9 +11,9 @@ export declare const chorusSecurityConfigSchema: z.ZodObject<{ requireRepoMatch: z.ZodDefault; requireEmailDomainMatch: z.ZodDefault; defaultRole: z.ZodDefault>; allowedEmailDomain: z.ZodOptional; additionalRepoRemotePrefixes: z.ZodDefault>; @@ -56,9 +56,9 @@ export declare const chorusConfigSchema: z.ZodObject<{ requireRepoMatch: z.ZodDefault; requireEmailDomainMatch: z.ZodDefault; defaultRole: z.ZodDefault>; allowedEmailDomain: z.ZodOptional; additionalRepoRemotePrefixes: z.ZodDefault>; diff --git a/packages/plugin/dist/index.d.ts b/packages/plugin/dist/index.d.ts index 43198b1..191fdc0 100644 --- a/packages/plugin/dist/index.d.ts +++ b/packages/plugin/dist/index.d.ts @@ -111,9 +111,9 @@ export default function chorusPlugin(input: PluginInput): Promise<{ description: string; args: { role: z.ZodOptional>; requireApproval: z.ZodOptional; }; diff --git a/packages/plugin/dist/join/index.d.ts b/packages/plugin/dist/join/index.d.ts index 7daf458..a119e06 100644 --- a/packages/plugin/dist/join/index.d.ts +++ b/packages/plugin/dist/join/index.d.ts @@ -1,37 +1,2 @@ -import type { SessionEvent, ConnectedUser } from "@chorus/shared"; -export type JoinStatus = "connecting" | "pending" | "connected" | "disconnected" | "error"; -export interface JoinState { - status: JoinStatus; - sessionId: string; - userId?: string; - users: ConnectedUser[]; - recentEvents: SessionEvent[]; - error?: string; -} -export declare class JoinClient { - private readonly relayUrl; - private readonly token; - private readonly displayName; - private readonly repoRemote?; - private readonly email?; - private ws; - private state; - private onEvent?; - private onChatMessage?; - private onTyping?; - private onPending?; - private onApproved?; - constructor(relayUrl: string, token: string, displayName: string, repoRemote?: string | undefined, email?: string | undefined); - connect(): Promise; - sendInput(content: string): void; - sendChat(content: string): void; - setChatHandler(fn: (displayName: string | undefined, content: string) => void): void; - setTypingHandler(fn: (displayName: string | undefined) => void): void; - setEventHandler(fn: (event: SessionEvent) => void): void; - setPendingHandler(fn: (userId: string) => void): void; - setApprovedHandler(fn: () => void): void; - sendTyping(): void; - getState(): Readonly; - disconnect(): void; -} +export { JoinClient, type JoinState, type JoinStatus } from "@chorus/client"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/plugin/dist/join/index.d.ts.map b/packages/plugin/dist/join/index.d.ts.map index 3c2c060..76e7d85 100644 --- a/packages/plugin/dist/join/index.d.ts.map +++ b/packages/plugin/dist/join/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/join/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAOlE,MAAM,MAAM,UAAU,GAAG,YAAY,GAAG,SAAS,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAC;AAE3F,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,UAAU,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,YAAY,EAAE,YAAY,EAAE,CAAC;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,qBAAa,UAAU;IAUnB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC;IAC5B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC;IAbzB,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,KAAK,CAAY;IACzB,OAAO,CAAC,OAAO,CAAC,CAAgC;IAChD,OAAO,CAAC,aAAa,CAAC,CAA6D;IACnF,OAAO,CAAC,QAAQ,CAAC,CAA4C;IAC7D,OAAO,CAAC,SAAS,CAAC,CAA2B;IAC7C,OAAO,CAAC,UAAU,CAAC,CAAa;gBAGb,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,EACnB,UAAU,CAAC,EAAE,MAAM,YAAA,EACnB,KAAK,CAAC,EAAE,MAAM,YAAA;IAUjC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAgIxB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAKhC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAK/B,cAAc,CAAC,EAAE,EAAE,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI;IAIpF,gBAAgB,CAAC,EAAE,EAAE,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,GAAG,IAAI;IAIrE,eAAe,CAAC,EAAE,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,GAAG,IAAI;IAIxD,iBAAiB,CAAC,EAAE,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI;IAIrD,kBAAkB,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,IAAI;IAIxC,UAAU,IAAI,IAAI;IAKlB,QAAQ,IAAI,QAAQ,CAAC,SAAS,CAAC;IAI/B,UAAU,IAAI,IAAI;CAKnB"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/join/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC"} \ No newline at end of file diff --git a/packages/plugin/dist/join/index.js b/packages/plugin/dist/join/index.js index a8722b1..a40d6b6 100644 --- a/packages/plugin/dist/join/index.js +++ b/packages/plugin/dist/join/index.js @@ -1,172 +1,2 @@ -import { encodeMessage, decodeServerMessage, } from "@chorus/shared"; -export class JoinClient { - relayUrl; - token; - displayName; - repoRemote; - email; - ws = null; - state; - onEvent; - onChatMessage; - onTyping; - onPending; - onApproved; - constructor(relayUrl, token, displayName, repoRemote, email) { - this.relayUrl = relayUrl; - this.token = token; - this.displayName = displayName; - this.repoRemote = repoRemote; - this.email = email; - this.state = { - status: "connecting", - sessionId: "", - users: [], - recentEvents: [], - }; - } - connect() { - return new Promise((resolve, reject) => { - const ws = new WebSocket(this.relayUrl); - this.ws = ws; - let settled = false; - const succeed = () => { - if (settled) - return; - settled = true; - resolve(); - }; - const fail = (err) => { - if (settled) - return; - settled = true; - reject(err); - }; - ws.onopen = () => { - const auth = { - type: "auth", - token: this.token, - displayName: this.displayName, - }; - if (this.repoRemote) - auth.repoRemote = this.repoRemote; - if (this.email) - auth.email = this.email; - ws.send(encodeMessage(auth)); - }; - ws.onmessage = (ev) => { - let msg; - try { - msg = decodeServerMessage(ev.data); - } - catch { - return; - } - switch (msg.type) { - case "auth.pending": - this.state.status = "pending"; - this.state.userId = msg.userId; - this.onPending?.(msg.userId); - succeed(); - break; - case "auth.denied": - this.state.status = "error"; - this.state.error = msg.message; - fail(new Error(msg.message)); - ws.close(); - break; - case "session.history": - this.state.recentEvents = msg.events.slice(-50); - this.state.status = "connected"; - this.onApproved?.(); - succeed(); - break; - case "session.event": - this.state.recentEvents = [...this.state.recentEvents.slice(-49), msg.event]; - this.onEvent?.(msg.event); - break; - case "user.list": - this.state.users = msg.users; - break; - case "user.joined": - this.state.users = [...this.state.users, msg.user]; - break; - case "user.left": - this.state.users = this.state.users.filter((u) => u.userId !== msg.userId); - break; - case "user.role_changed": - this.state.users = this.state.users.map((u) => u.userId === msg.userId ? { ...u, role: msg.role } : u); - break; - case "chat.message": - this.onChatMessage?.(msg.message.displayName, msg.message.content); - break; - case "user.typing": - this.onTyping?.(msg.displayName); - break; - case "session.closed": - this.state.status = "disconnected"; - ws.close(); - break; - case "error": { - const wasAdmitted = this.state.status === "connected" || this.state.status === "pending"; - this.state.status = "error"; - this.state.error = msg.message; - if (!wasAdmitted) { - fail(new Error(msg.message)); - } - break; - } - } - }; - ws.onerror = () => { - this.state.status = "error"; - this.state.error = "Connection error"; - fail(new Error("WebSocket connection error")); - }; - ws.onclose = () => { - if (this.state.status === "connected" || this.state.status === "pending") { - this.state.status = "disconnected"; - } - }; - }); - } - sendInput(content) { - if (!this.ws || this.state.status !== "connected") - return; - this.ws.send(encodeMessage({ type: "collab.input", content })); - } - sendChat(content) { - if (!this.ws || this.state.status !== "connected") - return; - this.ws.send(encodeMessage({ type: "chat.send", content })); - } - setChatHandler(fn) { - this.onChatMessage = fn; - } - setTypingHandler(fn) { - this.onTyping = fn; - } - setEventHandler(fn) { - this.onEvent = fn; - } - setPendingHandler(fn) { - this.onPending = fn; - } - setApprovedHandler(fn) { - this.onApproved = fn; - } - sendTyping() { - if (!this.ws || this.state.status !== "connected") - return; - this.ws.send(encodeMessage({ type: "typing" })); - } - getState() { - return this.state; - } - disconnect() { - this.ws?.close(); - this.ws = null; - this.state.status = "disconnected"; - } -} +export { JoinClient } from "@chorus/client"; //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/packages/plugin/dist/join/index.js.map b/packages/plugin/dist/join/index.js.map index eebc8db..dea2dc6 100644 --- a/packages/plugin/dist/join/index.js.map +++ b/packages/plugin/dist/join/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/join/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/join/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAmC,MAAM,gBAAgB,CAAC"} \ No newline at end of file diff --git a/packages/plugin/dist/relay/index.d.ts b/packages/plugin/dist/relay/index.d.ts index 1710d37..a567d61 100644 --- a/packages/plugin/dist/relay/index.d.ts +++ b/packages/plugin/dist/relay/index.d.ts @@ -1,113 +1,2 @@ -import type { ConnectedUser, SessionEvent, SessionToken, UserRole } from "@chorus/shared"; -export type RelayServerOptions = { - /** Host running the relay (default 127.0.0.1). */ - host?: string; - /** When set with external mode, attach instead of spawning. */ - hostToken?: string; - /** - * Attach to an already-running relay (e.g. on the Docker host). - * When true, requires hostToken and does not spawn/kill a subprocess. - */ - external?: boolean; - /** Bind address passed to chorus-relay (default 0.0.0.0). */ - bind?: string; - /** CIDR/IP allowlist for TCP peers. */ - allowedCidrs?: string[]; - /** Explicit deny CIDRs (deny wins). */ - deniedCidrs?: string[]; - /** Peer source-port allowlist (single-machine e2e). */ - allowedPorts?: number[]; - /** Refuse 0.0.0.0 / :: bind when false. */ - allowOpenBind?: boolean; - /** Admit loopback IPs even when allowlist is set (default true). */ - allowLoopback?: boolean; -}; -export type RelayNetworkOptions = { - bind?: string; - allowedCidrs?: string[]; - deniedCidrs?: string[]; - allowedPorts?: number[]; - allowOpenBind?: boolean; - allowLoopback?: boolean; -}; -/** - * Resolve relay connection settings from env. - * - * External attach (relay already running elsewhere): - * CHORUS_RELAY_HOST=host.docker.internal:7742 - * CHORUS_HOST_TOKEN= - * CHORUS_EXTERNAL_RELAY=1 (optional; implied when HOST_TOKEN is set) - */ -export declare function relayOptionsFromEnv(defaultPort: number): { - port: number; - opts: RelayServerOptions; -}; -/** - * Manages the Rust `chorus-relay` subprocess and the host control WebSocket. - * Joiner-facing protocol on `/ws` is unchanged; the plugin talks to `/host`. - */ -export declare class RelayServer { - private readonly port; - private child; - private ws; - private hostToken; - private running; - private clients; - private readonly host; - private readonly external; - private bind; - private allowedCidrs; - private deniedCidrs; - private allowedPorts; - private allowOpenBind; - private allowLoopback; - private pendingToken; - private onInjectInput?; - private onChatMessage?; - private onTyping?; - private onUserPending?; - private onUserJoined?; - private onUserLeft?; - constructor(port: number, opts?: RelayServerOptions); - /** Apply network policy before start() (from project/org chorus.json). */ - setNetworkOptions(opts: RelayNetworkOptions): void; - setInputHandler(fn: (content: string, userId: string, displayName?: string) => Promise): void; - setChatHandler(fn: (displayName: string | undefined, content: string) => void): void; - setTypingHandler(fn: (displayName: string | undefined) => void): void; - setUserPendingHandler(fn: (user: ConnectedUser) => void): void; - setUserJoinedHandler(fn: (user: ConnectedUser) => void): void; - setUserLeftHandler(fn: (userId: string) => void): void; - start(): Promise; - private statusUrl; - private hostWsUrl; - private waitForPort; - private connectHost; - private handleHostMessage; - private send; - issueToken(sessionId: string, role?: UserRole, ttlMs?: number): Promise; - setSessionPolicy(opts: { - requireApproval?: boolean; - repoRemote?: string | null; - allowedEmailDomain?: string | null; - additionalRepoRemotePrefixes?: string[] | null; - repoRemoteRewrites?: { - from: string; - to: string; - }[] | null; - }): void; - approveUser(userId: string): void; - denyUser(userId: string): void; - kickUser(userId: string): void; - pushEvent(event: SessionEvent): void; - sendChat(displayName: string | undefined, content: string): void; - stop(): Promise; - private waitUntilStopped; - get isRunning(): boolean; - get clientCount(): number; - getPort(): number; - getHost(): string; - getBind(): string; - getAllowedCidrs(): string[]; - isExternal(): boolean; -} +export { RelayServer, relayOptionsFromEnv, type RelayServerOptions, type RelayNetworkOptions, } from "@chorus/client"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/plugin/dist/relay/index.d.ts.map b/packages/plugin/dist/relay/index.d.ts.map index 862f647..71e1017 100644 --- a/packages/plugin/dist/relay/index.d.ts.map +++ b/packages/plugin/dist/relay/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/relay/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/relay/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,mBAAmB,EACnB,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,GACzB,MAAM,gBAAgB,CAAC"} \ No newline at end of file diff --git a/packages/plugin/dist/relay/index.js b/packages/plugin/dist/relay/index.js index 5c31c8a..947f81c 100644 --- a/packages/plugin/dist/relay/index.js +++ b/packages/plugin/dist/relay/index.js @@ -1,397 +1,2 @@ -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { randomBytes } from "node:crypto"; -import { encodeHostMessage, decodeRelayToHost, } from "@chorus/shared"; -function resolveRelayBin() { - if (process.env["CHORUS_RELAY_BIN"]) - return process.env["CHORUS_RELAY_BIN"]; - const here = dirname(fileURLToPath(import.meta.url)); - const candidates = [ - join(here, "../../../../target/release/chorus-relay"), - join(here, "../../../../target/debug/chorus-relay"), - join(here, "../../../../../target/release/chorus-relay"), - join(here, "../../../../../target/debug/chorus-relay"), - ]; - for (const path of candidates) { - if (existsSync(path)) - return path; - } - return "chorus-relay"; -} -function parseRelayHost(raw, fallbackPort) { - if (!raw) - return { host: "127.0.0.1", port: fallbackPort }; - // Accept host, host:port, or ws(s)://host:port[/path] - const trimmed = raw.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""); - const [hostPart, portPart] = trimmed.split(":"); - const port = portPart ? parseInt(portPart, 10) : fallbackPort; - return { host: hostPart || "127.0.0.1", port: Number.isFinite(port) ? port : fallbackPort }; -} -/** - * Resolve relay connection settings from env. - * - * External attach (relay already running elsewhere): - * CHORUS_RELAY_HOST=host.docker.internal:7742 - * CHORUS_HOST_TOKEN= - * CHORUS_EXTERNAL_RELAY=1 (optional; implied when HOST_TOKEN is set) - */ -export function relayOptionsFromEnv(defaultPort) { - const parsed = parseRelayHost(process.env["CHORUS_RELAY_HOST"], defaultPort); - const hostToken = process.env["CHORUS_HOST_TOKEN"]; - const external = process.env["CHORUS_EXTERNAL_RELAY"] === "1" || - process.env["CHORUS_EXTERNAL_RELAY"] === "true" || - Boolean(hostToken && process.env["CHORUS_RELAY_HOST"]); - return { - port: parsed.port, - opts: { - host: parsed.host, - hostToken: hostToken || undefined, - external, - }, - }; -} -/** - * Manages the Rust `chorus-relay` subprocess and the host control WebSocket. - * Joiner-facing protocol on `/ws` is unchanged; the plugin talks to `/host`. - */ -export class RelayServer { - port; - child = null; - ws = null; - hostToken = ""; - running = false; - clients = 0; - host; - external; - bind; - allowedCidrs; - deniedCidrs; - allowedPorts; - allowOpenBind; - allowLoopback; - pendingToken = null; - onInjectInput; - onChatMessage; - onTyping; - onUserPending; - onUserJoined; - onUserLeft; - constructor(port, opts = {}) { - this.port = port; - this.host = opts.host ?? "127.0.0.1"; - this.external = Boolean(opts.external); - this.hostToken = opts.hostToken ?? ""; - this.bind = opts.bind ?? "0.0.0.0"; - this.allowedCidrs = opts.allowedCidrs ? [...opts.allowedCidrs] : []; - this.deniedCidrs = opts.deniedCidrs ? [...opts.deniedCidrs] : []; - this.allowedPorts = opts.allowedPorts ? [...opts.allowedPorts] : []; - this.allowOpenBind = opts.allowOpenBind ?? true; - this.allowLoopback = opts.allowLoopback ?? true; - } - /** Apply network policy before start() (from project/org chorus.json). */ - setNetworkOptions(opts) { - if (opts.bind !== undefined) - this.bind = opts.bind; - if (opts.allowedCidrs !== undefined) - this.allowedCidrs = [...opts.allowedCidrs]; - if (opts.deniedCidrs !== undefined) - this.deniedCidrs = [...opts.deniedCidrs]; - if (opts.allowedPorts !== undefined) - this.allowedPorts = [...opts.allowedPorts]; - if (opts.allowOpenBind !== undefined) - this.allowOpenBind = opts.allowOpenBind; - if (opts.allowLoopback !== undefined) - this.allowLoopback = opts.allowLoopback; - } - setInputHandler(fn) { - this.onInjectInput = fn; - } - setChatHandler(fn) { - this.onChatMessage = fn; - } - setTypingHandler(fn) { - this.onTyping = fn; - } - setUserPendingHandler(fn) { - this.onUserPending = fn; - } - setUserJoinedHandler(fn) { - this.onUserJoined = fn; - } - setUserLeftHandler(fn) { - this.onUserLeft = fn; - } - async start() { - if (this.running) - return; - if (this.external) { - if (!this.hostToken) { - throw new Error("External relay mode requires CHORUS_HOST_TOKEN (and usually CHORUS_RELAY_HOST)."); - } - await this.waitForPort(); - await this.connectHost(); - this.running = true; - return; - } - this.hostToken = this.hostToken || randomBytes(32).toString("hex"); - const bin = resolveRelayBin(); - const args = [ - "--port", - String(this.port), - "--bind", - this.bind, - "--host-token", - this.hostToken, - "--allow-open-bind", - this.allowOpenBind ? "true" : "false", - "--allow-loopback", - this.allowLoopback ? "true" : "false", - ]; - for (const cidr of this.allowedCidrs) { - args.push("--allow-cidr", cidr); - } - for (const cidr of this.deniedCidrs) { - args.push("--deny-cidr", cidr); - } - for (const port of this.allowedPorts) { - args.push("--allow-port", String(port)); - } - this.child = spawn(bin, args, { - stdio: ["ignore", "ignore", "pipe"], - env: { ...process.env }, - }); - this.child.on("exit", () => { - this.running = false; - this.ws = null; - }); - await this.waitForPort(); - await this.connectHost(); - this.running = true; - } - statusUrl() { - return `http://${this.host}:${this.port}/status`; - } - hostWsUrl() { - return `ws://${this.host}:${this.port}/host`; - } - async waitForPort(timeoutMs = 8000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const res = await fetch(this.statusUrl(), { signal: AbortSignal.timeout(1000) }); - if (res.ok) - return; - } - catch { - // not up yet - } - await new Promise((r) => setTimeout(r, 40)); - } - throw new Error(this.external - ? `External chorus-relay not reachable at ${this.host}:${this.port}. Is it running on the host?` - : `chorus-relay did not become ready on port ${this.port}. ` + - `Is the binary available? (CHORUS_RELAY_BIN or cargo build -p chorus-relay --release)`); - } - connectHost() { - return new Promise((resolve, reject) => { - const ws = new WebSocket(this.hostWsUrl()); - this.ws = ws; - const timer = setTimeout(() => reject(new Error("host control connect timeout")), 5000); - ws.onopen = () => { - this.send({ type: "host.auth", token: this.hostToken }); - }; - ws.onmessage = (ev) => { - let msg; - try { - msg = decodeRelayToHost(String(ev.data)); - } - catch { - return; - } - this.handleHostMessage(msg, () => { - clearTimeout(timer); - resolve(); - }); - }; - ws.onerror = () => { - clearTimeout(timer); - reject(new Error("host control WebSocket error")); - }; - ws.onclose = () => { - this.ws = null; - this.running = false; - }; - }); - } - handleHostMessage(msg, onReady) { - switch (msg.type) { - case "host.ready": - onReady?.(); - break; - case "token.issued": { - const { type: _t, ...token } = msg; - this.pendingToken?.resolve(token); - this.pendingToken = null; - break; - } - case "collab.input": - this.onInjectInput?.(msg.content, msg.userId, msg.displayName)?.catch(console.error); - break; - case "chat.message": - this.onChatMessage?.(msg.message.displayName, msg.message.content); - break; - case "user.typing": - this.onTyping?.(msg.displayName); - break; - case "user.pending": - this.onUserPending?.(msg.user); - break; - case "user.joined": - this.clients += 1; - this.onUserJoined?.(msg.user); - break; - case "user.left": - this.clients = Math.max(0, this.clients - 1); - this.onUserLeft?.(msg.userId); - break; - case "user.list": - this.clients = msg.users.filter((u) => u.status === "active").length; - break; - case "status": - this.clients = msg.clients; - break; - case "error": - this.pendingToken?.reject(new Error(msg.message)); - this.pendingToken = null; - break; - } - } - send(msg) { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) - return; - this.ws.send(encodeHostMessage(msg)); - } - issueToken(sessionId, role = "edit", ttlMs) { - return new Promise((resolve, reject) => { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - reject(new Error("relay host control not connected")); - return; - } - this.pendingToken = { resolve, reject }; - this.send({ type: "token.issue", sessionId, role, ttlMs }); - setTimeout(() => { - if (this.pendingToken) { - this.pendingToken.reject(new Error("token.issue timed out")); - this.pendingToken = null; - } - }, 5000); - }); - } - setSessionPolicy(opts) { - this.send({ - type: "session.policy", - requireApproval: opts.requireApproval, - repoRemote: opts.repoRemote === null ? "" : opts.repoRemote, - allowedEmailDomain: opts.allowedEmailDomain === null ? "" : opts.allowedEmailDomain, - additionalRepoRemotePrefixes: opts.additionalRepoRemotePrefixes ?? undefined, - repoRemoteRewrites: opts.repoRemoteRewrites ?? undefined, - }); - } - approveUser(userId) { - this.send({ type: "host.approve", userId }); - } - denyUser(userId) { - this.send({ type: "host.deny", userId }); - } - kickUser(userId) { - this.send({ type: "host.kick", userId }); - } - pushEvent(event) { - this.send({ type: "session.event", event }); - } - sendChat(displayName, content) { - this.send({ type: "chat.send", content, displayName }); - } - async stop() { - // Only tear down session state on relays we own. External relays stay up - // so container agents can reconnect across test runs. - if (!this.external) { - this.send({ type: "host.close" }); - } - try { - this.ws?.close(); - } - catch { - // ignore - } - this.ws = null; - const child = this.child; - this.child = null; - this.running = false; - this.clients = 0; - if (child && child.exitCode === null && child.signalCode === null) { - await new Promise((resolve) => { - const finish = () => resolve(); - child.once("exit", finish); - try { - child.kill("SIGTERM"); - } - catch { - finish(); - return; - } - setTimeout(() => { - if (child.exitCode === null && child.signalCode === null) { - try { - child.kill("SIGKILL"); - } - catch { - finish(); - } - } - }, 1000).unref?.(); - }); - } - if (!this.external) { - await this.waitUntilStopped(); - } - } - async waitUntilStopped(timeoutMs = 2000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const res = await fetch(this.statusUrl(), { signal: AbortSignal.timeout(250) }); - if (!res.ok) - return; - } - catch { - return; - } - await new Promise((r) => setTimeout(r, 20)); - } - } - get isRunning() { - return this.running; - } - get clientCount() { - return this.clients; - } - getPort() { - return this.port; - } - getHost() { - return this.host; - } - getBind() { - return this.bind; - } - getAllowedCidrs() { - return [...this.allowedCidrs]; - } - isExternal() { - return this.external; - } -} +export { RelayServer, relayOptionsFromEnv, } from "@chorus/client"; //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/packages/plugin/dist/relay/index.js.map b/packages/plugin/dist/relay/index.js.map index faa6183..70df27f 100644 --- a/packages/plugin/dist/relay/index.js.map +++ b/packages/plugin/dist/relay/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/relay/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/relay/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,mBAAmB,GAGpB,MAAM,gBAAgB,CAAC"} \ No newline at end of file diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 686bdf4..df790a0 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@chorus/plugin", - "version": "2.0.0", + "version": "2.1.0", "description": "OpenCode plugin for collaborative session sharing via chorus-relay.", "private": true, "license": "MIT", @@ -32,13 +32,14 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", - "test": "vitest run test/access.test.ts test/backup.test.ts test/config.test.ts test/join-command.test.ts test/pending-queue.test.ts test/transcript.test.ts && bun test test/relay.test.ts test/join.test.ts", + "test": "vitest run test/access.test.ts test/backup.test.ts test/config.test.ts test/join-command.test.ts test/pending-queue.test.ts test/transcript.test.ts", "test:watch": "vitest", "clean": "rm -rf dist" }, "dependencies": { "@aws-sdk/client-s3": "^3.726.0", "@aws-sdk/lib-storage": "^3.726.0", + "@chorus/client": "workspace:*", "@chorus/shared": "workspace:*", "zod": "4.1.8" }, diff --git a/packages/plugin/src/join/index.ts b/packages/plugin/src/join/index.ts index e3b78a5..030eff9 100644 --- a/packages/plugin/src/join/index.ts +++ b/packages/plugin/src/join/index.ts @@ -1,215 +1 @@ -import type { SessionEvent, ConnectedUser } from "@chorus/shared"; -import { - encodeMessage, - decodeServerMessage, - type ServerMessage, -} from "@chorus/shared"; - -export type JoinStatus = "connecting" | "pending" | "connected" | "disconnected" | "error"; - -export interface JoinState { - status: JoinStatus; - sessionId: string; - userId?: string; - users: ConnectedUser[]; - recentEvents: SessionEvent[]; - error?: string; -} - -export class JoinClient { - private ws: WebSocket | null = null; - private state: JoinState; - private onEvent?: (event: SessionEvent) => void; - private onChatMessage?: (displayName: string | undefined, content: string) => void; - private onTyping?: (displayName: string | undefined) => void; - private onPending?: (userId: string) => void; - private onApproved?: () => void; - - constructor( - private readonly relayUrl: string, - private readonly token: string, - private readonly displayName: string, - private readonly repoRemote?: string, - private readonly email?: string - ) { - this.state = { - status: "connecting", - sessionId: "", - users: [], - recentEvents: [], - }; - } - - connect(): Promise { - return new Promise((resolve, reject) => { - const ws = new WebSocket(this.relayUrl); - this.ws = ws; - let settled = false; - - const succeed = () => { - if (settled) return; - settled = true; - resolve(); - }; - - const fail = (err: Error) => { - if (settled) return; - settled = true; - reject(err); - }; - - ws.onopen = () => { - const auth: { - type: "auth"; - token: string; - displayName: string; - repoRemote?: string; - email?: string; - } = { - type: "auth", - token: this.token, - displayName: this.displayName, - }; - if (this.repoRemote) auth.repoRemote = this.repoRemote; - if (this.email) auth.email = this.email; - ws.send(encodeMessage(auth)); - }; - - ws.onmessage = (ev) => { - let msg: ServerMessage; - try { - msg = decodeServerMessage(ev.data as string); - } catch { - return; - } - - switch (msg.type) { - case "auth.pending": - this.state.status = "pending"; - this.state.userId = msg.userId; - this.onPending?.(msg.userId); - succeed(); - break; - - case "auth.denied": - this.state.status = "error"; - this.state.error = msg.message; - fail(new Error(msg.message)); - ws.close(); - break; - - case "session.history": - this.state.recentEvents = msg.events.slice(-50); - this.state.status = "connected"; - this.onApproved?.(); - succeed(); - break; - - case "session.event": - this.state.recentEvents = [...this.state.recentEvents.slice(-49), msg.event]; - this.onEvent?.(msg.event); - break; - - case "user.list": - this.state.users = msg.users; - break; - - case "user.joined": - this.state.users = [...this.state.users, msg.user]; - break; - - case "user.left": - this.state.users = this.state.users.filter((u) => u.userId !== msg.userId); - break; - - case "user.role_changed": - this.state.users = this.state.users.map((u) => - u.userId === msg.userId ? { ...u, role: msg.role } : u - ); - break; - - case "chat.message": - this.onChatMessage?.(msg.message.displayName, msg.message.content); - break; - - case "user.typing": - this.onTyping?.(msg.displayName); - break; - - case "session.closed": - this.state.status = "disconnected"; - ws.close(); - break; - - case "error": { - const wasAdmitted = - this.state.status === "connected" || this.state.status === "pending"; - this.state.status = "error"; - this.state.error = msg.message; - if (!wasAdmitted) { - fail(new Error(msg.message)); - } - break; - } - } - }; - - ws.onerror = () => { - this.state.status = "error"; - this.state.error = "Connection error"; - fail(new Error("WebSocket connection error")); - }; - - ws.onclose = () => { - if (this.state.status === "connected" || this.state.status === "pending") { - this.state.status = "disconnected"; - } - }; - }); - } - - sendInput(content: string): void { - if (!this.ws || this.state.status !== "connected") return; - this.ws.send(encodeMessage({ type: "collab.input", content })); - } - - sendChat(content: string): void { - if (!this.ws || this.state.status !== "connected") return; - this.ws.send(encodeMessage({ type: "chat.send", content })); - } - - setChatHandler(fn: (displayName: string | undefined, content: string) => void): void { - this.onChatMessage = fn; - } - - setTypingHandler(fn: (displayName: string | undefined) => void): void { - this.onTyping = fn; - } - - setEventHandler(fn: (event: SessionEvent) => void): void { - this.onEvent = fn; - } - - setPendingHandler(fn: (userId: string) => void): void { - this.onPending = fn; - } - - setApprovedHandler(fn: () => void): void { - this.onApproved = fn; - } - - sendTyping(): void { - if (!this.ws || this.state.status !== "connected") return; - this.ws.send(encodeMessage({ type: "typing" })); - } - - getState(): Readonly { - return this.state; - } - - disconnect(): void { - this.ws?.close(); - this.ws = null; - this.state.status = "disconnected"; - } -} +export { JoinClient, type JoinState, type JoinStatus } from "@chorus/client"; diff --git a/packages/plugin/src/relay/index.ts b/packages/plugin/src/relay/index.ts index 5e9d00c..9be2ec4 100644 --- a/packages/plugin/src/relay/index.ts +++ b/packages/plugin/src/relay/index.ts @@ -1,499 +1,6 @@ -import { spawn, type ChildProcess } from "node:child_process"; -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { randomBytes } from "node:crypto"; -import type { ConnectedUser, SessionEvent, SessionToken, UserRole } from "@chorus/shared"; -import { - encodeHostMessage, - decodeRelayToHost, - type HostToRelay, - type RelayToHost, -} from "@chorus/shared"; - -function resolveRelayBin(): string { - if (process.env["CHORUS_RELAY_BIN"]) return process.env["CHORUS_RELAY_BIN"]; - - const here = dirname(fileURLToPath(import.meta.url)); - const candidates = [ - join(here, "../../../../target/release/chorus-relay"), - join(here, "../../../../target/debug/chorus-relay"), - join(here, "../../../../../target/release/chorus-relay"), - join(here, "../../../../../target/debug/chorus-relay"), - ]; - for (const path of candidates) { - if (existsSync(path)) return path; - } - return "chorus-relay"; -} - -export type RelayServerOptions = { - /** Host running the relay (default 127.0.0.1). */ - host?: string; - /** When set with external mode, attach instead of spawning. */ - hostToken?: string; - /** - * Attach to an already-running relay (e.g. on the Docker host). - * When true, requires hostToken and does not spawn/kill a subprocess. - */ - external?: boolean; - /** Bind address passed to chorus-relay (default 0.0.0.0). */ - bind?: string; - /** CIDR/IP allowlist for TCP peers. */ - allowedCidrs?: string[]; - /** Explicit deny CIDRs (deny wins). */ - deniedCidrs?: string[]; - /** Peer source-port allowlist (single-machine e2e). */ - allowedPorts?: number[]; - /** Refuse 0.0.0.0 / :: bind when false. */ - allowOpenBind?: boolean; - /** Admit loopback IPs even when allowlist is set (default true). */ - allowLoopback?: boolean; -}; - -export type RelayNetworkOptions = { - bind?: string; - allowedCidrs?: string[]; - deniedCidrs?: string[]; - allowedPorts?: number[]; - allowOpenBind?: boolean; - allowLoopback?: boolean; -}; - -function parseRelayHost(raw: string | undefined, fallbackPort: number): { host: string; port: number } { - if (!raw) return { host: "127.0.0.1", port: fallbackPort }; - // Accept host, host:port, or ws(s)://host:port[/path] - const trimmed = raw.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""); - const [hostPart, portPart] = trimmed.split(":"); - const port = portPart ? parseInt(portPart, 10) : fallbackPort; - return { host: hostPart || "127.0.0.1", port: Number.isFinite(port) ? port : fallbackPort }; -} - -/** - * Resolve relay connection settings from env. - * - * External attach (relay already running elsewhere): - * CHORUS_RELAY_HOST=host.docker.internal:7742 - * CHORUS_HOST_TOKEN= - * CHORUS_EXTERNAL_RELAY=1 (optional; implied when HOST_TOKEN is set) - */ -export function relayOptionsFromEnv(defaultPort: number): { - port: number; - opts: RelayServerOptions; -} { - const parsed = parseRelayHost(process.env["CHORUS_RELAY_HOST"], defaultPort); - const hostToken = process.env["CHORUS_HOST_TOKEN"]; - const external = - process.env["CHORUS_EXTERNAL_RELAY"] === "1" || - process.env["CHORUS_EXTERNAL_RELAY"] === "true" || - Boolean(hostToken && process.env["CHORUS_RELAY_HOST"]); - - return { - port: parsed.port, - opts: { - host: parsed.host, - hostToken: hostToken || undefined, - external, - }, - }; -} - -/** - * Manages the Rust `chorus-relay` subprocess and the host control WebSocket. - * Joiner-facing protocol on `/ws` is unchanged; the plugin talks to `/host`. - */ -export class RelayServer { - private child: ChildProcess | null = null; - private ws: WebSocket | null = null; - private hostToken = ""; - private running = false; - private clients = 0; - private readonly host: string; - private readonly external: boolean; - private bind: string; - private allowedCidrs: string[]; - private deniedCidrs: string[]; - private allowedPorts: number[]; - private allowOpenBind: boolean; - private allowLoopback: boolean; - private pendingToken: { - resolve: (t: SessionToken) => void; - reject: (e: Error) => void; - } | null = null; - - private onInjectInput?: (content: string, userId: string, displayName?: string) => Promise; - private onChatMessage?: (displayName: string | undefined, content: string) => void; - private onTyping?: (displayName: string | undefined) => void; - private onUserPending?: (user: ConnectedUser) => void; - private onUserJoined?: (user: ConnectedUser) => void; - private onUserLeft?: (userId: string) => void; - - constructor( - private readonly port: number, - opts: RelayServerOptions = {} - ) { - this.host = opts.host ?? "127.0.0.1"; - this.external = Boolean(opts.external); - this.hostToken = opts.hostToken ?? ""; - this.bind = opts.bind ?? "0.0.0.0"; - this.allowedCidrs = opts.allowedCidrs ? [...opts.allowedCidrs] : []; - this.deniedCidrs = opts.deniedCidrs ? [...opts.deniedCidrs] : []; - this.allowedPorts = opts.allowedPorts ? [...opts.allowedPorts] : []; - this.allowOpenBind = opts.allowOpenBind ?? true; - this.allowLoopback = opts.allowLoopback ?? true; - } - - /** Apply network policy before start() (from project/org chorus.json). */ - setNetworkOptions(opts: RelayNetworkOptions): void { - if (opts.bind !== undefined) this.bind = opts.bind; - if (opts.allowedCidrs !== undefined) this.allowedCidrs = [...opts.allowedCidrs]; - if (opts.deniedCidrs !== undefined) this.deniedCidrs = [...opts.deniedCidrs]; - if (opts.allowedPorts !== undefined) this.allowedPorts = [...opts.allowedPorts]; - if (opts.allowOpenBind !== undefined) this.allowOpenBind = opts.allowOpenBind; - if (opts.allowLoopback !== undefined) this.allowLoopback = opts.allowLoopback; - } - - setInputHandler( - fn: (content: string, userId: string, displayName?: string) => Promise - ): void { - this.onInjectInput = fn; - } - - setChatHandler(fn: (displayName: string | undefined, content: string) => void): void { - this.onChatMessage = fn; - } - - setTypingHandler(fn: (displayName: string | undefined) => void): void { - this.onTyping = fn; - } - - setUserPendingHandler(fn: (user: ConnectedUser) => void): void { - this.onUserPending = fn; - } - - setUserJoinedHandler(fn: (user: ConnectedUser) => void): void { - this.onUserJoined = fn; - } - - setUserLeftHandler(fn: (userId: string) => void): void { - this.onUserLeft = fn; - } - - async start(): Promise { - if (this.running) return; - - if (this.external) { - if (!this.hostToken) { - throw new Error( - "External relay mode requires CHORUS_HOST_TOKEN (and usually CHORUS_RELAY_HOST)." - ); - } - await this.waitForPort(); - await this.connectHost(); - this.running = true; - return; - } - - this.hostToken = this.hostToken || randomBytes(32).toString("hex"); - const bin = resolveRelayBin(); - - const args = [ - "--port", - String(this.port), - "--bind", - this.bind, - "--host-token", - this.hostToken, - "--allow-open-bind", - this.allowOpenBind ? "true" : "false", - "--allow-loopback", - this.allowLoopback ? "true" : "false", - ]; - for (const cidr of this.allowedCidrs) { - args.push("--allow-cidr", cidr); - } - for (const cidr of this.deniedCidrs) { - args.push("--deny-cidr", cidr); - } - for (const port of this.allowedPorts) { - args.push("--allow-port", String(port)); - } - - this.child = spawn(bin, args, { - stdio: ["ignore", "ignore", "pipe"], - env: { ...process.env }, - }); - - this.child.on("exit", () => { - this.running = false; - this.ws = null; - }); - - await this.waitForPort(); - await this.connectHost(); - this.running = true; - } - - private statusUrl(): string { - return `http://${this.host}:${this.port}/status`; - } - - private hostWsUrl(): string { - return `ws://${this.host}:${this.port}/host`; - } - - private async waitForPort(timeoutMs = 8000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const res = await fetch(this.statusUrl(), { signal: AbortSignal.timeout(1000) }); - if (res.ok) return; - } catch { - // not up yet - } - await new Promise((r) => setTimeout(r, 40)); - } - throw new Error( - this.external - ? `External chorus-relay not reachable at ${this.host}:${this.port}. Is it running on the host?` - : `chorus-relay did not become ready on port ${this.port}. ` + - `Is the binary available? (CHORUS_RELAY_BIN or cargo build -p chorus-relay --release)` - ); - } - - private connectHost(): Promise { - return new Promise((resolve, reject) => { - const ws = new WebSocket(this.hostWsUrl()); - this.ws = ws; - - const timer = setTimeout(() => reject(new Error("host control connect timeout")), 5000); - - ws.onopen = () => { - this.send({ type: "host.auth", token: this.hostToken }); - }; - - ws.onmessage = (ev) => { - let msg: RelayToHost; - try { - msg = decodeRelayToHost(String(ev.data)); - } catch { - return; - } - this.handleHostMessage(msg, () => { - clearTimeout(timer); - resolve(); - }); - }; - - ws.onerror = () => { - clearTimeout(timer); - reject(new Error("host control WebSocket error")); - }; - - ws.onclose = () => { - this.ws = null; - this.running = false; - }; - }); - } - - private handleHostMessage(msg: RelayToHost, onReady?: () => void): void { - switch (msg.type) { - case "host.ready": - onReady?.(); - break; - - case "token.issued": { - const { type: _t, ...token } = msg; - this.pendingToken?.resolve(token); - this.pendingToken = null; - break; - } - - case "collab.input": - this.onInjectInput?.(msg.content, msg.userId, msg.displayName)?.catch(console.error); - break; - - case "chat.message": - this.onChatMessage?.(msg.message.displayName, msg.message.content); - break; - - case "user.typing": - this.onTyping?.(msg.displayName); - break; - - case "user.pending": - this.onUserPending?.(msg.user); - break; - - case "user.joined": - this.clients += 1; - this.onUserJoined?.(msg.user); - break; - - case "user.left": - this.clients = Math.max(0, this.clients - 1); - this.onUserLeft?.(msg.userId); - break; - - case "user.list": - this.clients = msg.users.filter((u) => u.status === "active").length; - break; - - case "status": - this.clients = msg.clients; - break; - - case "error": - this.pendingToken?.reject(new Error(msg.message)); - this.pendingToken = null; - break; - } - } - - private send(msg: HostToRelay): void { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; - this.ws.send(encodeHostMessage(msg)); - } - - issueToken(sessionId: string, role: UserRole = "edit", ttlMs?: number): Promise { - return new Promise((resolve, reject) => { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - reject(new Error("relay host control not connected")); - return; - } - this.pendingToken = { resolve, reject }; - this.send({ type: "token.issue", sessionId, role, ttlMs }); - setTimeout(() => { - if (this.pendingToken) { - this.pendingToken.reject(new Error("token.issue timed out")); - this.pendingToken = null; - } - }, 5000); - }); - } - - setSessionPolicy(opts: { - requireApproval?: boolean; - repoRemote?: string | null; - allowedEmailDomain?: string | null; - additionalRepoRemotePrefixes?: string[] | null; - repoRemoteRewrites?: { from: string; to: string }[] | null; - }): void { - this.send({ - type: "session.policy", - requireApproval: opts.requireApproval, - repoRemote: opts.repoRemote === null ? "" : opts.repoRemote, - allowedEmailDomain: - opts.allowedEmailDomain === null ? "" : opts.allowedEmailDomain, - additionalRepoRemotePrefixes: opts.additionalRepoRemotePrefixes ?? undefined, - repoRemoteRewrites: opts.repoRemoteRewrites ?? undefined, - }); - } - - approveUser(userId: string): void { - this.send({ type: "host.approve", userId }); - } - - denyUser(userId: string): void { - this.send({ type: "host.deny", userId }); - } - - kickUser(userId: string): void { - this.send({ type: "host.kick", userId }); - } - - pushEvent(event: SessionEvent): void { - this.send({ type: "session.event", event }); - } - - sendChat(displayName: string | undefined, content: string): void { - this.send({ type: "chat.send", content, displayName }); - } - - async stop(): Promise { - // Only tear down session state on relays we own. External relays stay up - // so container agents can reconnect across test runs. - if (!this.external) { - this.send({ type: "host.close" }); - } - try { - this.ws?.close(); - } catch { - // ignore - } - this.ws = null; - - const child = this.child; - this.child = null; - this.running = false; - this.clients = 0; - - if (child && child.exitCode === null && child.signalCode === null) { - await new Promise((resolve) => { - const finish = () => resolve(); - child.once("exit", finish); - try { - child.kill("SIGTERM"); - } catch { - finish(); - return; - } - setTimeout(() => { - if (child.exitCode === null && child.signalCode === null) { - try { - child.kill("SIGKILL"); - } catch { - finish(); - } - } - }, 1000).unref?.(); - }); - } - - if (!this.external) { - await this.waitUntilStopped(); - } - } - - private async waitUntilStopped(timeoutMs = 2000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const res = await fetch(this.statusUrl(), { signal: AbortSignal.timeout(250) }); - if (!res.ok) return; - } catch { - return; - } - await new Promise((r) => setTimeout(r, 20)); - } - } - - get isRunning(): boolean { - return this.running; - } - - get clientCount(): number { - return this.clients; - } - - getPort(): number { - return this.port; - } - - getHost(): string { - return this.host; - } - - getBind(): string { - return this.bind; - } - - getAllowedCidrs(): string[] { - return [...this.allowedCidrs]; - } - - isExternal(): boolean { - return this.external; - } -} +export { + RelayServer, + relayOptionsFromEnv, + type RelayServerOptions, + type RelayNetworkOptions, +} from "@chorus/client"; diff --git a/packages/vscode/README.md b/packages/vscode/README.md new file mode 100644 index 0000000..6fc2f81 --- /dev/null +++ b/packages/vscode/README.md @@ -0,0 +1,102 @@ +# Chorus VS Code extension + +VS Code adapter for [Chorus](../../README.md) — share or join a live collaborative AI session over `chorus-relay`. + +The relay is the core; this extension is a thin client on the same `/host` + `/ws` protocol as the OpenCode terminal plugin (`@chorus/client` + `@chorus/shared`). + +## What it does + +| Command | Behavior | +|---|---| +| **Chorus: Share Session** | Spawns/attaches `chorus-relay`, sets session policy, issues a join token, copies `/chorus-join …` to the clipboard | +| **Chorus: Join Session** | Connects to `/ws` with token + host (+ optional display name / email) | +| **Chorus: Approve Joiner** / **Deny Joiner** | Host admits or rejects pending joiners (when `chorus.requireApproval` is on) | +| **Chorus: Send Prompt to Host** | Joiner `collab.input` into the shared session | +| **Chorus: Publish Host Message** | Host `session.event` fan-out to joiners | +| **Chorus: Send Chat Message** | Side-channel chat (not LLM history) | +| **Chorus: Leave / Stop / Status** | Disconnect / tear down / inspect state | + +The **Chorus** activity-bar panel shows the mirrored transcript and a compose box. + +## Cross-adapter relay + +Both adapters speak the same wire protocol. Mix and match: + +| Host | Joiner | Notes | +|---|---|---| +| OpenCode (terminal) | OpenCode | Primary path — full LLM loop + transcript mirror | +| VS Code | OpenCode | VS Code hosts relay; terminal joiner sends `collab.input` | +| OpenCode | VS Code | Terminal hosts relay; VS Code joiner sends `collab.input` and receives session events | +| VS Code | VS Code | Same relay semantics; neither side drives an LLM unless paired with OpenCode | + +When **sharing from VS Code**, collaborator prompts appear in the panel/notifications. Publish host/AI lines manually (**Chorus: Publish Host Message**) or pair with an OpenCode host that owns the model. + +When **joining an OpenCode-hosted session**, prompts you send are real `collab.input` and the host transcript streams into the panel. + +Verify cross-adapter behavior without a GUI: + +```sh +# from repo root +bun run test:vscode-relay-e2e +``` + +## Session access control + +Matches the OpenCode plugin: + +- **Required display name** on join (empty names rejected) +- **Host approval** — `chorus.requireApproval` (default `true`); pending joiners trigger Approve/Deny notifications +- **Email domain gate** — set `chorus.allowedEmailDomain` when sharing; joiners set `chorus.email` +- **Repo gate** — when the workspace has a git `origin`, joiners must present the same remote + +## Prerequisites + +```sh +# from repo root +cargo build -p chorus-relay --release +bun install +bun run build:ts +``` + +Ensure `chorus-relay` is on `PATH`, or set `chorus.relayBin` / `CHORUS_RELAY_BIN`. + +## Install (dev) + +1. Build this package: `bun run --filter chorus build` +2. In VS Code: **Extensions: Install from Location…** → select `packages/vscode` + (or use the [VS Code Extension Development Host](https://code.visualstudio.com/api/get-started/your-first-extension) with this folder as the extension root) +3. Command Palette → **Chorus: Share Session** / **Join Session** + +## Settings + +| Setting | Default | Meaning | +|---|---|---| +| `chorus.port` | `7742` | Relay listen port when sharing | +| `chorus.displayName` | OS user | Name shown to peers (required non-empty) | +| `chorus.requireApproval` | `true` | Hold joiners in pending until Approve/Deny | +| `chorus.email` | — | Joiner email sent on auth (for email domain gate) | +| `chorus.allowedEmailDomain` | — | When sharing, require joiners at this domain | +| `chorus.relayBin` | — | Path to `chorus-relay` | +| `chorus.publicHost` | LAN IP:port | Advertised join host | + +Env vars from the root README (`CHORUS_RELAY_HOST`, `CHORUS_HOST_TOKEN`, `CHORUS_EXTERNAL_RELAY`, network allowlists, …) still apply for Docker/external relays. + +## Testing + +```sh +bun run test:vscode-e2e # email gate, pending approve, collab.input +bun run test:vscode-relay-e2e # three-env gate + VS Code ↔ terminal cross-adapter +``` + +Multi-machine three-env roles: set `CHORUS_E2E_ROLE` to `host-vscode`, `joiner-vscode`, or `disallowed-joiner` (see root [README](../../README.md#adapter--relay-e2e-no-gui)). + +## Layout + +``` +packages/vscode/ + src/extension.ts # activate + commands + src/controller.ts # JoinClient + RelayServer orchestration + src/sessionView.ts # sidebar webview + src/format.ts # transcript line formatting + src/git.ts # repo remote detection for repo gate +``` diff --git a/packages/vscode/dist/controller.d.ts b/packages/vscode/dist/controller.d.ts new file mode 100644 index 0000000..974d717 --- /dev/null +++ b/packages/vscode/dist/controller.d.ts @@ -0,0 +1,63 @@ +import * as vscode from "vscode"; +import { type JoinState } from "@chorus/client"; +import type { ConnectedUser, UserRole } from "@chorus/shared"; +export type ChorusMode = "idle" | "sharing" | "joined" | "pending"; +export type TranscriptLine = { + id: string; + text: string; + at: number; + kind: "session" | "chat" | "system"; +}; +/** + * Host/joiner controller for the VS Code adapter. + * Speaks the same `/host` + `/ws` contracts as the OpenCode plugin, including + * session access control (approval, required name, optional repo gate). + */ +export declare class ChorusController { + private readonly output; + private readonly statusBar; + private mode; + private relay; + private joinClient; + private sessionId; + private pendingUsers; + private readonly transcript; + private readonly _onDidChange; + readonly onDidChange: vscode.Event; + constructor(output: vscode.OutputChannel, statusBar: vscode.StatusBarItem); + getMode(): ChorusMode; + getTranscript(): readonly TranscriptLine[]; + getPendingUsers(): readonly ConnectedUser[]; + getJoinState(): JoinState | null; + getShareSummary(): { + sharing: boolean; + clients?: number; + port?: number; + host?: string; + external?: boolean; + pending?: number; + }; + private cfg; + private displayName; + private defaultPort; + private requireApprovalDefault; + private publicJoinHost; + private append; + private appendSystem; + private appendSession; + private refreshStatus; + private ensureRelayHandlers; + share(role?: UserRole, requireApproval?: boolean): Promise; + approveUser(userId: string): void; + denyUser(userId: string): void; + join(token: string, host: string, name?: string): Promise; + leave(): Promise; + stop(): Promise; + sendChat(message: string): void; + /** Joiner → host collab.input */ + sendPrompt(content: string): void; + /** Host publishes a user/assistant line onto the shared transcript. */ + publishHostMessage(content: string, type?: "user" | "assistant"): void; + statusText(): string; + dispose(): void; +} diff --git a/packages/vscode/dist/controller.js b/packages/vscode/dist/controller.js new file mode 100644 index 0000000..42a5e2d --- /dev/null +++ b/packages/vscode/dist/controller.js @@ -0,0 +1,365 @@ +import { networkInterfaces } from "node:os"; +import * as vscode from "vscode"; +import { JoinClient, RelayServer, relayOptionsFromEnv, } from "@chorus/client"; +import { normalizeDisplayName } from "@chorus/shared"; +import { formatSessionLine, newEventId } from "./format.js"; +import { detectRepoRemote } from "./git.js"; +function getLanIp() { + const nets = networkInterfaces(); + for (const iface of Object.values(nets)) { + for (const net of iface ?? []) { + if (net.family === "IPv4" && !net.internal) + return net.address; + } + } + return "localhost"; +} +/** + * Host/joiner controller for the VS Code adapter. + * Speaks the same `/host` + `/ws` contracts as the OpenCode plugin, including + * session access control (approval, required name, optional repo gate). + */ +export class ChorusController { + output; + statusBar; + mode = "idle"; + relay = null; + joinClient = null; + sessionId = `vscode-${Date.now().toString(36)}`; + pendingUsers = []; + transcript = []; + _onDidChange = new vscode.EventEmitter(); + onDidChange = this._onDidChange.event; + constructor(output, statusBar) { + this.output = output; + this.statusBar = statusBar; + this.refreshStatus(); + } + getMode() { + return this.mode; + } + getTranscript() { + return this.transcript; + } + getPendingUsers() { + return this.pendingUsers; + } + getJoinState() { + return this.joinClient?.getState() ?? null; + } + getShareSummary() { + if (!this.relay || this.mode !== "sharing") + return { sharing: false }; + return { + sharing: true, + clients: this.relay.clientCount, + port: this.relay.getPort(), + host: this.relay.getHost(), + external: this.relay.isExternal(), + pending: this.pendingUsers.length, + }; + } + cfg() { + return vscode.workspace.getConfiguration("chorus"); + } + displayName() { + const configured = this.cfg().get("displayName"); + const normalized = normalizeDisplayName(configured); + if (normalized) + return normalized; + const fallback = normalizeDisplayName(process.env["USER"] ?? process.env["USERNAME"] ?? "vscode"); + if (!fallback) + throw new Error("A non-empty display name is required (set chorus.displayName)."); + return fallback; + } + defaultPort() { + return this.cfg().get("port") ?? 7742; + } + requireApprovalDefault() { + return this.cfg().get("requireApproval") !== false; + } + publicJoinHost(port, relayHost, external) { + const fromSettings = this.cfg().get("publicHost")?.trim(); + if (fromSettings) + return fromSettings; + if (process.env["CHORUS_PUBLIC_HOST"]) + return process.env["CHORUS_PUBLIC_HOST"]; + if (external && relayHost && relayHost !== "127.0.0.1") { + return `${relayHost}:${port}`; + } + return `${getLanIp()}:${port}`; + } + append(line) { + this.transcript.push(line); + if (this.transcript.length > 500) + this.transcript.shift(); + this.output.appendLine(line.text); + this._onDidChange.fire(); + } + appendSystem(text) { + this.append({ id: newEventId(), text, at: Date.now(), kind: "system" }); + } + appendSession(event) { + const text = formatSessionLine(event); + if (!text) + return; + this.append({ id: event.id, text, at: event.timestamp, kind: "session" }); + } + refreshStatus() { + if (this.mode === "sharing" && this.relay) { + const pending = this.pendingUsers.length; + this.statusBar.text = pending + ? `$(broadcast) Chorus sharing :${this.relay.getPort()} (${pending} pending)` + : `$(broadcast) Chorus sharing :${this.relay.getPort()} (${this.relay.clientCount})`; + this.statusBar.tooltip = "Chorus is sharing — click for status"; + } + else if (this.mode === "pending" && this.joinClient) { + this.statusBar.text = "$(watch) Chorus waiting for host approval"; + this.statusBar.tooltip = "Connected but pending host approval"; + } + else if (this.mode === "joined" && this.joinClient) { + const st = this.joinClient.getState(); + this.statusBar.text = `$(organization) Chorus joined (${st.users.length})`; + this.statusBar.tooltip = "Chorus joined — click for status"; + } + else { + this.statusBar.text = "$(circle-slash) Chorus"; + this.statusBar.tooltip = "Chorus idle — Share or Join a session"; + } + this.statusBar.command = "chorus.status"; + this._onDidChange.fire(); + } + ensureRelayHandlers(relay) { + relay.setInputHandler(async (content, userId, displayName) => { + const label = displayName ?? userId.slice(0, 8); + const labeled = `[${label}]: ${content}`; + const event = { + id: newEventId(), + sessionId: this.sessionId, + type: "user", + payload: labeled, + timestamp: Date.now(), + }; + this.appendSession(event); + this.relay?.pushEvent(event); + void vscode.window.showInformationMessage(`Chorus prompt from ${label}: ${content}`); + }); + relay.setChatHandler((name, content) => { + const text = `💬 [${name ?? "guest"}]: ${content}`; + this.append({ id: newEventId(), text, at: Date.now(), kind: "chat" }); + void vscode.window.showInformationMessage(text); + }); + relay.setTypingHandler((name) => { + this.statusBar.text = `$(edit) ${name ?? "someone"} typing…`; + setTimeout(() => this.refreshStatus(), 2000).unref?.(); + }); + relay.setUserPendingHandler((user) => { + this.pendingUsers = [...this.pendingUsers.filter((u) => u.userId !== user.userId), user]; + this.appendSystem(`Join request from ${user.displayName} (${user.role}) — ${user.userId}`); + this.refreshStatus(); + void vscode.window + .showInformationMessage(`Chorus: ${user.displayName} wants to join (${user.role}).`, "Approve", "Deny") + .then((choice) => { + if (choice === "Approve") + this.approveUser(user.userId); + if (choice === "Deny") + this.denyUser(user.userId); + }); + }); + } + async share(role = "edit", requireApproval) { + if (this.mode === "joined" || this.mode === "pending") { + await this.leave(); + } + const relayBin = this.cfg().get("relayBin")?.trim(); + if (relayBin) + process.env["CHORUS_RELAY_BIN"] = relayBin; + const { port, opts } = relayOptionsFromEnv(this.defaultPort()); + if (!this.relay) { + this.relay = new RelayServer(port, opts); + this.ensureRelayHandlers(this.relay); + } + if (!this.relay.isRunning) { + await this.relay.start(); + } + const approval = requireApproval ?? this.requireApprovalDefault(); + const repoRemote = detectRepoRemote(); + this.relay.setSessionPolicy({ + requireApproval: approval, + repoRemote: repoRemote ?? "", + allowedEmailDomain: this.cfg().get("allowedEmailDomain")?.trim() || undefined, + }); + this.pendingUsers = []; + this.mode = "sharing"; + const token = await this.relay.issueToken(this.sessionId, role); + const joinHost = this.publicJoinHost(this.relay.getPort(), this.relay.getHost(), this.relay.isExternal()); + const joinCommand = `/chorus-join token="${token.token}" host="${joinHost}"`; + this.appendSystem(this.relay.isExternal() + ? `Attached to external relay ${this.relay.getHost()}:${this.relay.getPort()}` + : `chorus-relay started on port ${this.relay.getPort()}`); + this.appendSystem(approval + ? "Joiners wait for your approval (Chorus: Approve Joiner / Deny Joiner)." + : "Auto-admit is on (requireApproval=false)."); + if (repoRemote) { + this.appendSystem(`Repo gate on: joiners must share origin ${repoRemote}.`); + } + this.appendSystem(`Share ready (${role}). Collaborator command:\n${joinCommand}`); + this.refreshStatus(); + await vscode.env.clipboard.writeText(joinCommand); + void vscode.window + .showInformationMessage(`Chorus sharing — join command copied to clipboard.`, "Copy again") + .then((choice) => { + if (choice === "Copy again") + void vscode.env.clipboard.writeText(joinCommand); + }); + return joinCommand; + } + approveUser(userId) { + if (!this.relay || this.mode !== "sharing") { + throw new Error("Share a session first to approve joiners."); + } + this.relay.approveUser(userId); + this.pendingUsers = this.pendingUsers.filter((u) => u.userId !== userId); + this.appendSystem(`Approved ${userId}`); + this.refreshStatus(); + } + denyUser(userId) { + if (!this.relay || this.mode !== "sharing") { + throw new Error("Share a session first to deny joiners."); + } + this.relay.denyUser(userId); + this.pendingUsers = this.pendingUsers.filter((u) => u.userId !== userId); + this.appendSystem(`Denied ${userId}`); + this.refreshStatus(); + } + async join(token, host, name) { + if (this.mode === "sharing") { + throw new Error("Stop sharing (Chorus: Stop Sharing) before joining another session."); + } + if (this.joinClient) { + this.joinClient.disconnect(); + this.joinClient = null; + } + const displayName = normalizeDisplayName(name) ?? this.displayName(); + const repoRemote = detectRepoRemote(); + const email = this.cfg().get("email")?.trim() || undefined; + const jc = new JoinClient(`ws://${host}/ws`, token, displayName, repoRemote, email); + jc.setChatHandler((msgName, content) => { + const text = `💬 [${msgName ?? "host"}]: ${content}`; + this.append({ id: newEventId(), text, at: Date.now(), kind: "chat" }); + void vscode.window.showInformationMessage(text); + }); + jc.setTypingHandler((typingName) => { + this.statusBar.text = `$(edit) ${typingName ?? "someone"} typing…`; + setTimeout(() => this.refreshStatus(), 2000).unref?.(); + }); + jc.setEventHandler((event) => this.appendSession(event)); + jc.setPendingHandler((userId) => { + this.mode = "pending"; + this.appendSystem(`Waiting for host approval (userId=${userId})`); + this.refreshStatus(); + void vscode.window.showInformationMessage("Chorus: waiting for the host to approve your join."); + }); + jc.setApprovedHandler(() => { + this.mode = "joined"; + this.appendSystem("Host approved — you are in the session."); + this.refreshStatus(); + void vscode.window.showInformationMessage("Chorus: host approved your join."); + }); + await jc.connect(); + for (const event of jc.getState().recentEvents) { + this.appendSession(event); + } + this.joinClient = jc; + const st = jc.getState().status; + this.mode = st === "pending" ? "pending" : "joined"; + this.appendSystem(st === "pending" + ? `Connected to ${host} as ${displayName} — pending approval` + : `Joined ${host} as ${displayName}`); + this.refreshStatus(); + } + async leave() { + this.joinClient?.disconnect(); + this.joinClient = null; + if (this.mode === "joined" || this.mode === "pending") + this.mode = "idle"; + this.appendSystem("Left shared session"); + this.refreshStatus(); + } + async stop() { + this.mode = "idle"; + this.pendingUsers = []; + const relay = this.relay; + this.relay = null; + if (relay) + await relay.stop(); + this.appendSystem("Stopped sharing"); + this.refreshStatus(); + } + sendChat(message) { + if (this.mode === "sharing" && this.relay) { + this.relay.sendChat(this.displayName(), message); + this.append({ + id: newEventId(), + text: `💬 [${this.displayName()}]: ${message}`, + at: Date.now(), + kind: "chat", + }); + return; + } + if (this.mode === "joined" && this.joinClient?.getState().status === "connected") { + this.joinClient.sendTyping(); + this.joinClient.sendChat(message); + this.append({ + id: newEventId(), + text: `💬 [${this.displayName()}]: ${message}`, + at: Date.now(), + kind: "chat", + }); + return; + } + if (this.mode === "pending") { + throw new Error("Still waiting for host approval — chat is blocked until then."); + } + throw new Error("Not currently sharing or joined."); + } + /** Joiner → host collab.input */ + sendPrompt(content) { + if (!this.joinClient) { + throw new Error("Join a session first to send prompts to the host."); + } + if (this.joinClient.getState().status !== "connected") { + throw new Error("Cannot send prompts while pending host approval."); + } + this.joinClient.sendInput(content); + this.appendSystem(`→ sent prompt to host (${content.slice(0, 80)}${content.length > 80 ? "…" : ""})`); + } + /** Host publishes a user/assistant line onto the shared transcript. */ + publishHostMessage(content, type = "user") { + if (this.mode !== "sharing" || !this.relay) { + throw new Error("Share a session first to publish host messages."); + } + const event = { + id: newEventId(), + sessionId: this.sessionId, + type, + payload: content, + timestamp: Date.now(), + }; + this.relay.pushEvent(event); + this.appendSession(event); + } + statusText() { + const share = this.getShareSummary(); + const join = this.joinClient + ? { joined: true, ...this.joinClient.getState() } + : { joined: false }; + return JSON.stringify({ mode: this.mode, pendingUsers: this.pendingUsers, ...share, ...join }, null, 2); + } + dispose() { + this.joinClient?.disconnect(); + void this.relay?.stop(); + this._onDidChange.dispose(); + } +} +//# sourceMappingURL=controller.js.map \ No newline at end of file diff --git a/packages/vscode/dist/controller.js.map b/packages/vscode/dist/controller.js.map new file mode 100644 index 0000000..9326958 --- /dev/null +++ b/packages/vscode/dist/controller.js.map @@ -0,0 +1 @@ +{"version":3,"file":"controller.js","sourceRoot":"","sources":["../src/controller.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/vscode/dist/extension.d.ts b/packages/vscode/dist/extension.d.ts new file mode 100644 index 0000000..c838f76 --- /dev/null +++ b/packages/vscode/dist/extension.d.ts @@ -0,0 +1,3 @@ +import * as vscode from "vscode"; +export declare function activate(context: vscode.ExtensionContext): void; +export declare function deactivate(): void; diff --git a/packages/vscode/dist/extension.js b/packages/vscode/dist/extension.js new file mode 100644 index 0000000..88da4e9 --- /dev/null +++ b/packages/vscode/dist/extension.js @@ -0,0 +1,184 @@ +import * as vscode from "vscode"; +import { ChorusController } from "./controller.js"; +import { SessionViewProvider } from "./sessionView.js"; +export function activate(context) { + const output = vscode.window.createOutputChannel("Chorus"); + const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); + statusBar.show(); + const controller = new ChorusController(output, statusBar); + const viewProvider = new SessionViewProvider(controller); + context.subscriptions.push(output, statusBar, controller, vscode.window.registerWebviewViewProvider(SessionViewProvider.viewType, viewProvider)); + context.subscriptions.push(vscode.commands.registerCommand("chorus.share", async () => { + try { + const rolePick = await vscode.window.showQuickPick([ + { label: "edit", description: "Can send prompts (default)" }, + { label: "view", description: "Read-only" }, + { label: "admin", description: "Full control" }, + ], { title: "Chorus share role for join token" }); + const role = (rolePick?.label ?? "edit"); + const joinCommand = await controller.share(role); + output.show(true); + void vscode.commands.executeCommand("chorus.session.focus"); + return joinCommand; + } + catch (err) { + void vscode.window.showErrorMessage(`Chorus share failed: ${String(err)}`); + } + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.join", async () => { + try { + const token = await vscode.window.showInputBox({ + title: "Chorus join token", + prompt: "Paste the token from the host's share command", + ignoreFocusOut: true, + }); + if (!token) + return; + const host = await vscode.window.showInputBox({ + title: "Chorus relay host", + prompt: "host:port (e.g. 192.168.1.5:7742)", + ignoreFocusOut: true, + value: "127.0.0.1:7742", + }); + if (!host) + return; + const name = await vscode.window.showInputBox({ + title: "Display name", + prompt: "Required — shown to collaborators (cannot be empty)", + ignoreFocusOut: true, + value: vscode.workspace.getConfiguration("chorus").get("displayName") || "", + }); + if (name !== undefined && !name.trim()) { + void vscode.window.showErrorMessage("Display name is required."); + return; + } + await controller.join(token.trim(), host.trim(), name?.trim() || undefined); + output.show(true); + const mode = controller.getMode(); + void vscode.window.showInformationMessage(mode === "pending" ? "Connected — waiting for host approval" : "Joined Chorus session"); + } + catch (err) { + void vscode.window.showErrorMessage(`Chorus join failed: ${String(err)}`); + } + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.approve", async () => { + try { + const pending = controller.getPendingUsers(); + if (!pending.length) { + const userId = await vscode.window.showInputBox({ + title: "Approve joiner", + prompt: "Pending user id", + ignoreFocusOut: true, + }); + if (!userId) + return; + controller.approveUser(userId.trim()); + return; + } + const pick = await vscode.window.showQuickPick(pending.map((u) => ({ + label: u.displayName, + description: `${u.role} · ${u.userId}`, + userId: u.userId, + })), { title: "Approve joiner" }); + if (!pick) + return; + controller.approveUser(pick.userId); + } + catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.deny", async () => { + try { + const pending = controller.getPendingUsers(); + if (!pending.length) { + const userId = await vscode.window.showInputBox({ + title: "Deny joiner", + prompt: "Pending user id", + ignoreFocusOut: true, + }); + if (!userId) + return; + controller.denyUser(userId.trim()); + return; + } + const pick = await vscode.window.showQuickPick(pending.map((u) => ({ + label: u.displayName, + description: `${u.role} · ${u.userId}`, + userId: u.userId, + })), { title: "Deny joiner" }); + if (!pick) + return; + controller.denyUser(pick.userId); + } + catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.leave", async () => { + await controller.leave(); + void vscode.window.showInformationMessage("Left Chorus session"); + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.stop", async () => { + await controller.stop(); + void vscode.window.showInformationMessage("Stopped Chorus sharing"); + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.chat", async () => { + try { + const message = await vscode.window.showInputBox({ + title: "Chorus chat", + prompt: "Side-channel message (does not enter the LLM transcript)", + ignoreFocusOut: true, + }); + if (!message) + return; + controller.sendChat(message); + } + catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.sendPrompt", async () => { + try { + const content = await vscode.window.showInputBox({ + title: "Send prompt to Chorus host", + prompt: "Forwarded as collab.input into the shared session", + ignoreFocusOut: true, + }); + if (!content) + return; + controller.sendPrompt(content); + } + catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.publishHostMessage", async () => { + try { + const content = await vscode.window.showInputBox({ + title: "Publish host message", + prompt: "Pushed to all joiners as a session user event", + ignoreFocusOut: true, + }); + if (!content) + return; + controller.publishHostMessage(content, "user"); + } + catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.status", async () => { + const text = controller.statusText(); + output.appendLine(text); + output.show(true); + await vscode.window.showInformationMessage(`Chorus mode: ${controller.getMode()}`); + })); + context.subscriptions.push(vscode.commands.registerCommand("chorus.openSession", async () => { + await vscode.commands.executeCommand("chorus.session.focus"); + })); +} +export function deactivate() { + // disposables handled via subscriptions +} +//# sourceMappingURL=extension.js.map \ No newline at end of file diff --git a/packages/vscode/dist/extension.js.map b/packages/vscode/dist/extension.js.map new file mode 100644 index 0000000..5d7db3b --- /dev/null +++ b/packages/vscode/dist/extension.js.map @@ -0,0 +1 @@ +{"version":3,"file":"extension.js","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/vscode/dist/format.d.ts b/packages/vscode/dist/format.d.ts new file mode 100644 index 0000000..60b1469 --- /dev/null +++ b/packages/vscode/dist/format.d.ts @@ -0,0 +1,4 @@ +import type { SessionEvent } from "@chorus/shared"; +/** Format a shared session event for the Chorus panel / notifications. */ +export declare function formatSessionLine(event: SessionEvent): string | null; +export declare function newEventId(): string; diff --git a/packages/vscode/dist/format.js b/packages/vscode/dist/format.js new file mode 100644 index 0000000..cb3b2d0 --- /dev/null +++ b/packages/vscode/dist/format.js @@ -0,0 +1,17 @@ +const LABELED_LINE = /^\[[^\]]+\]:\s/; +/** Format a shared session event for the Chorus panel / notifications. */ +export function formatSessionLine(event) { + const payload = typeof event.payload === "string" ? event.payload : JSON.stringify(event.payload); + if (event.type === "user") { + if (LABELED_LINE.test(payload)) + return payload; + return `[Host]: ${payload}`; + } + if (event.type === "assistant") + return `[AI]: ${payload}`; + return `[${event.type}]: ${payload}`; +} +export function newEventId() { + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} +//# sourceMappingURL=format.js.map \ No newline at end of file diff --git a/packages/vscode/dist/format.js.map b/packages/vscode/dist/format.js.map new file mode 100644 index 0000000..2f15aad --- /dev/null +++ b/packages/vscode/dist/format.js.map @@ -0,0 +1 @@ +{"version":3,"file":"format.js","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":"AAEA,MAAM,YAAY,GAAG,gBAAgB,CAAC;AAEtC,0EAA0E;AAC1E,MAAM,UAAU,iBAAiB,CAAC,KAAmB;IACnD,MAAM,OAAO,GACX,OAAO,KAAK,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IACpF,IAAI,KAAK,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;QAC1B,IAAI,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC;YAAE,OAAO,OAAO,CAAC;QAC/C,OAAO,WAAW,OAAO,EAAE,CAAC;IAC9B,CAAC;IACD,IAAI,KAAK,CAAC,IAAI,KAAK,WAAW;QAAE,OAAO,SAAS,OAAO,EAAE,CAAC;IAC1D,OAAO,IAAI,KAAK,CAAC,IAAI,MAAM,OAAO,EAAE,CAAC;AACvC,CAAC;AAED,MAAM,UAAU,UAAU;IACxB,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC;AACpE,CAAC"} \ No newline at end of file diff --git a/packages/vscode/dist/git.d.ts b/packages/vscode/dist/git.d.ts new file mode 100644 index 0000000..fab62e9 --- /dev/null +++ b/packages/vscode/dist/git.d.ts @@ -0,0 +1,2 @@ +/** Best-effort `git remote get-url origin` for the active workspace folder. */ +export declare function detectRepoRemote(): string | undefined; diff --git a/packages/vscode/dist/git.js b/packages/vscode/dist/git.js new file mode 100644 index 0000000..b01187b --- /dev/null +++ b/packages/vscode/dist/git.js @@ -0,0 +1,21 @@ +import { execFileSync } from "node:child_process"; +import * as vscode from "vscode"; +/** Best-effort `git remote get-url origin` for the active workspace folder. */ +export function detectRepoRemote() { + const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!folder) + return undefined; + try { + const out = execFileSync("git", ["remote", "get-url", "origin"], { + cwd: folder, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 3000, + }).trim(); + return out || undefined; + } + catch { + return undefined; + } +} +//# sourceMappingURL=git.js.map \ No newline at end of file diff --git a/packages/vscode/dist/git.js.map b/packages/vscode/dist/git.js.map new file mode 100644 index 0000000..749d169 --- /dev/null +++ b/packages/vscode/dist/git.js.map @@ -0,0 +1 @@ +{"version":3,"file":"git.js","sourceRoot":"","sources":["../src/git.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AAEjC,+EAA+E;AAC/E,MAAM,UAAU,gBAAgB;IAC9B,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,gBAAgB,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC;IAClE,IAAI,CAAC,MAAM;QAAE,OAAO,SAAS,CAAC;IAC9B,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,YAAY,CAAC,KAAK,EAAE,CAAC,QAAQ,EAAE,SAAS,EAAE,QAAQ,CAAC,EAAE;YAC/D,GAAG,EAAE,MAAM;YACX,QAAQ,EAAE,MAAM;YAChB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC;YACnC,OAAO,EAAE,IAAI;SACd,CAAC,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,GAAG,IAAI,SAAS,CAAC;IAC1B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC"} \ No newline at end of file diff --git a/packages/vscode/dist/sessionView.d.ts b/packages/vscode/dist/sessionView.d.ts new file mode 100644 index 0000000..d3fed7b --- /dev/null +++ b/packages/vscode/dist/sessionView.d.ts @@ -0,0 +1,10 @@ +import * as vscode from "vscode"; +import type { ChorusController } from "./controller.js"; +export declare class SessionViewProvider implements vscode.WebviewViewProvider { + private readonly controller; + static readonly viewType = "chorus.session"; + private view?; + constructor(controller: ChorusController); + resolveWebviewView(webviewView: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken): void; + private render; +} diff --git a/packages/vscode/dist/sessionView.js b/packages/vscode/dist/sessionView.js new file mode 100644 index 0000000..f65ea67 --- /dev/null +++ b/packages/vscode/dist/sessionView.js @@ -0,0 +1,130 @@ +import * as vscode from "vscode"; +function escapeHtml(s) { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} +function lineClass(line) { + if (line.kind === "chat") + return "chat"; + if (line.kind === "system") + return "system"; + if (line.text.startsWith("[AI]:")) + return "ai"; + if (line.text.startsWith("[Host]:")) + return "host"; + return "user"; +} +export class SessionViewProvider { + controller; + static viewType = "chorus.session"; + view; + constructor(controller) { + this.controller = controller; + controller.onDidChange(() => this.render()); + } + resolveWebviewView(webviewView, _context, _token) { + this.view = webviewView; + webviewView.webview.options = { enableScripts: true }; + webviewView.webview.onDidReceiveMessage((msg) => { + if (msg?.type === "chat" && typeof msg.text === "string") { + try { + this.controller.sendChat(msg.text); + } + catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + } + if (msg?.type === "prompt" && typeof msg.text === "string") { + try { + this.controller.sendPrompt(msg.text); + } + catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + } + if (msg?.type === "publish" && typeof msg.text === "string") { + try { + this.controller.publishHostMessage(msg.text, "user"); + } + catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + } + }); + this.render(); + } + render() { + if (!this.view) + return; + const mode = this.controller.getMode(); + const lines = this.controller.getTranscript(); + const body = lines + .map((l) => `
${escapeHtml(l.text)}
`) + .join(""); + this.view.webview.html = ` + + + + + + +
Mode: ${escapeHtml(mode)}${mode === "pending" ? " — waiting for host approval" : ""}
+
${body || "
Idle — Share or Join a Chorus session.
"}
+
+
+ + +
+
+ +
+
+ + +`; + } +} +//# sourceMappingURL=sessionView.js.map \ No newline at end of file diff --git a/packages/vscode/dist/sessionView.js.map b/packages/vscode/dist/sessionView.js.map new file mode 100644 index 0000000..6674579 --- /dev/null +++ b/packages/vscode/dist/sessionView.js.map @@ -0,0 +1 @@ +{"version":3,"file":"sessionView.js","sourceRoot":"","sources":["../src/sessionView.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/vscode/media/chorus.svg b/packages/vscode/media/chorus.svg new file mode 100644 index 0000000..dfa18ef --- /dev/null +++ b/packages/vscode/media/chorus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/vscode/package.json b/packages/vscode/package.json new file mode 100644 index 0000000..bfb3cfe --- /dev/null +++ b/packages/vscode/package.json @@ -0,0 +1,162 @@ +{ + "name": "chorus", + "displayName": "Chorus", + "description": "Pair-program a live Chorus AI session from VS Code — share or join over chorus-relay.", + "version": "2.1.0", + "publisher": "chorus", + "private": true, + "license": "MIT", + "type": "module", + "engines": { + "vscode": "^1.90.0" + }, + "categories": ["Chat", "Other"], + "activationEvents": [ + "onCommand:chorus.share", + "onCommand:chorus.join", + "onCommand:chorus.leave", + "onCommand:chorus.stop", + "onCommand:chorus.chat", + "onCommand:chorus.status", + "onCommand:chorus.sendPrompt", + "onCommand:chorus.publishHostMessage", + "onCommand:chorus.approve", + "onCommand:chorus.deny", + "onView:chorus.session" + ], + "main": "./dist/extension.js", + "contributes": { + "commands": [ + { + "command": "chorus.share", + "title": "Chorus: Share Session", + "category": "Chorus" + }, + { + "command": "chorus.join", + "title": "Chorus: Join Session", + "category": "Chorus" + }, + { + "command": "chorus.leave", + "title": "Chorus: Leave Session", + "category": "Chorus" + }, + { + "command": "chorus.stop", + "title": "Chorus: Stop Sharing", + "category": "Chorus" + }, + { + "command": "chorus.chat", + "title": "Chorus: Send Chat Message", + "category": "Chorus" + }, + { + "command": "chorus.status", + "title": "Chorus: Show Status", + "category": "Chorus" + }, + { + "command": "chorus.sendPrompt", + "title": "Chorus: Send Prompt to Host", + "category": "Chorus" + }, + { + "command": "chorus.publishHostMessage", + "title": "Chorus: Publish Host Message", + "category": "Chorus" + }, + { + "command": "chorus.openSession", + "title": "Chorus: Open Session Panel", + "category": "Chorus" + }, + { + "command": "chorus.approve", + "title": "Chorus: Approve Joiner", + "category": "Chorus" + }, + { + "command": "chorus.deny", + "title": "Chorus: Deny Joiner", + "category": "Chorus" + } + ], + "configuration": { + "title": "Chorus", + "properties": { + "chorus.port": { + "type": "number", + "default": 7742, + "description": "Default chorus-relay listen port when sharing." + }, + "chorus.displayName": { + "type": "string", + "default": "", + "description": "Display name shown to collaborators (required non-empty; defaults to OS username)." + }, + "chorus.requireApproval": { + "type": "boolean", + "default": true, + "description": "When sharing, hold joiners in pending until Approve/Deny (matches OpenCode plugin default)." + }, + "chorus.email": { + "type": "string", + "default": "", + "description": "Joiner email sent on auth when the host enabled an allowedEmailDomain gate." + }, + "chorus.allowedEmailDomain": { + "type": "string", + "default": "", + "description": "When sharing, require joiners to authenticate with an email at this domain." + }, + "chorus.relayBin": { + "type": "string", + "default": "", + "description": "Path to chorus-relay binary (overrides CHORUS_RELAY_BIN / PATH)." + }, + "chorus.publicHost": { + "type": "string", + "default": "", + "description": "Host:port advertised in join commands (overrides CHORUS_PUBLIC_HOST)." + } + } + }, + "viewsContainers": { + "activitybar": [ + { + "id": "chorus", + "title": "Chorus", + "icon": "media/chorus.svg" + } + ] + }, + "views": { + "chorus": [ + { + "type": "webview", + "id": "chorus.session", + "name": "Session" + } + ] + } + }, + "scripts": { + "build": "tsc -p ./", + "typecheck": "tsc -p ./ --noEmit", + "watch": "tsc -p ./ -w", + "test": "bun test test/format.test.ts", + "clean": "rm -rf dist", + "package": "npm run build" + }, + "dependencies": { + "@chorus/client": "workspace:*", + "@chorus/shared": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.7", + "@types/vscode": "^1.90.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/vscode/src/controller.ts b/packages/vscode/src/controller.ts new file mode 100644 index 0000000..7c1d943 --- /dev/null +++ b/packages/vscode/src/controller.ts @@ -0,0 +1,429 @@ +import { networkInterfaces } from "node:os"; +import * as vscode from "vscode"; +import { + JoinClient, + RelayServer, + relayOptionsFromEnv, + type JoinState, +} from "@chorus/client"; +import type { ConnectedUser, SessionEvent, UserRole } from "@chorus/shared"; +import { normalizeDisplayName } from "@chorus/shared"; +import { formatSessionLine, newEventId } from "./format.js"; +import { detectRepoRemote } from "./git.js"; + +export type ChorusMode = "idle" | "sharing" | "joined" | "pending"; + +export type TranscriptLine = { + id: string; + text: string; + at: number; + kind: "session" | "chat" | "system"; +}; + +function getLanIp(): string { + const nets = networkInterfaces(); + for (const iface of Object.values(nets)) { + for (const net of iface ?? []) { + if (net.family === "IPv4" && !net.internal) return net.address; + } + } + return "localhost"; +} + +/** + * Host/joiner controller for the VS Code adapter. + * Speaks the same `/host` + `/ws` contracts as the OpenCode plugin, including + * session access control (approval, required name, optional repo gate). + */ +export class ChorusController { + private mode: ChorusMode = "idle"; + private relay: RelayServer | null = null; + private joinClient: JoinClient | null = null; + private sessionId = `vscode-${Date.now().toString(36)}`; + private pendingUsers: ConnectedUser[] = []; + private readonly transcript: TranscriptLine[] = []; + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChange = this._onDidChange.event; + + constructor( + private readonly output: vscode.OutputChannel, + private readonly statusBar: vscode.StatusBarItem + ) { + this.refreshStatus(); + } + + getMode(): ChorusMode { + return this.mode; + } + + getTranscript(): readonly TranscriptLine[] { + return this.transcript; + } + + getPendingUsers(): readonly ConnectedUser[] { + return this.pendingUsers; + } + + getJoinState(): JoinState | null { + return this.joinClient?.getState() ?? null; + } + + getShareSummary(): { + sharing: boolean; + clients?: number; + port?: number; + host?: string; + external?: boolean; + pending?: number; + } { + if (!this.relay || this.mode !== "sharing") return { sharing: false }; + return { + sharing: true, + clients: this.relay.clientCount, + port: this.relay.getPort(), + host: this.relay.getHost(), + external: this.relay.isExternal(), + pending: this.pendingUsers.length, + }; + } + + private cfg() { + return vscode.workspace.getConfiguration("chorus"); + } + + private displayName(): string { + const configured = this.cfg().get("displayName"); + const normalized = normalizeDisplayName(configured); + if (normalized) return normalized; + const fallback = normalizeDisplayName(process.env["USER"] ?? process.env["USERNAME"] ?? "vscode"); + if (!fallback) throw new Error("A non-empty display name is required (set chorus.displayName)."); + return fallback; + } + + private defaultPort(): number { + return this.cfg().get("port") ?? 7742; + } + + private requireApprovalDefault(): boolean { + return this.cfg().get("requireApproval") !== false; + } + + private publicJoinHost(port: number, relayHost: string, external: boolean): string { + const fromSettings = this.cfg().get("publicHost")?.trim(); + if (fromSettings) return fromSettings; + if (process.env["CHORUS_PUBLIC_HOST"]) return process.env["CHORUS_PUBLIC_HOST"]; + if (external && relayHost && relayHost !== "127.0.0.1") { + return `${relayHost}:${port}`; + } + return `${getLanIp()}:${port}`; + } + + private append(line: TranscriptLine): void { + this.transcript.push(line); + if (this.transcript.length > 500) this.transcript.shift(); + this.output.appendLine(line.text); + this._onDidChange.fire(); + } + + private appendSystem(text: string): void { + this.append({ id: newEventId(), text, at: Date.now(), kind: "system" }); + } + + private appendSession(event: SessionEvent): void { + const text = formatSessionLine(event); + if (!text) return; + this.append({ id: event.id, text, at: event.timestamp, kind: "session" }); + } + + private refreshStatus(): void { + if (this.mode === "sharing" && this.relay) { + const pending = this.pendingUsers.length; + this.statusBar.text = pending + ? `$(broadcast) Chorus sharing :${this.relay.getPort()} (${pending} pending)` + : `$(broadcast) Chorus sharing :${this.relay.getPort()} (${this.relay.clientCount})`; + this.statusBar.tooltip = "Chorus is sharing — click for status"; + } else if (this.mode === "pending" && this.joinClient) { + this.statusBar.text = "$(watch) Chorus waiting for host approval"; + this.statusBar.tooltip = "Connected but pending host approval"; + } else if (this.mode === "joined" && this.joinClient) { + const st = this.joinClient.getState(); + this.statusBar.text = `$(organization) Chorus joined (${st.users.length})`; + this.statusBar.tooltip = "Chorus joined — click for status"; + } else { + this.statusBar.text = "$(circle-slash) Chorus"; + this.statusBar.tooltip = "Chorus idle — Share or Join a session"; + } + this.statusBar.command = "chorus.status"; + this._onDidChange.fire(); + } + + private ensureRelayHandlers(relay: RelayServer): void { + relay.setInputHandler(async (content, userId, displayName) => { + const label = displayName ?? userId.slice(0, 8); + const labeled = `[${label}]: ${content}`; + const event: SessionEvent = { + id: newEventId(), + sessionId: this.sessionId, + type: "user", + payload: labeled, + timestamp: Date.now(), + }; + this.appendSession(event); + this.relay?.pushEvent(event); + void vscode.window.showInformationMessage(`Chorus prompt from ${label}: ${content}`); + }); + relay.setChatHandler((name, content) => { + const text = `💬 [${name ?? "guest"}]: ${content}`; + this.append({ id: newEventId(), text, at: Date.now(), kind: "chat" }); + void vscode.window.showInformationMessage(text); + }); + relay.setTypingHandler((name) => { + this.statusBar.text = `$(edit) ${name ?? "someone"} typing…`; + setTimeout(() => this.refreshStatus(), 2000).unref?.(); + }); + relay.setUserPendingHandler((user) => { + this.pendingUsers = [...this.pendingUsers.filter((u) => u.userId !== user.userId), user]; + this.appendSystem(`Join request from ${user.displayName} (${user.role}) — ${user.userId}`); + this.refreshStatus(); + void vscode.window + .showInformationMessage( + `Chorus: ${user.displayName} wants to join (${user.role}).`, + "Approve", + "Deny" + ) + .then((choice) => { + if (choice === "Approve") this.approveUser(user.userId); + if (choice === "Deny") this.denyUser(user.userId); + }); + }); + } + + async share(role: UserRole = "edit", requireApproval?: boolean): Promise { + if (this.mode === "joined" || this.mode === "pending") { + await this.leave(); + } + + const relayBin = this.cfg().get("relayBin")?.trim(); + if (relayBin) process.env["CHORUS_RELAY_BIN"] = relayBin; + + const { port, opts } = relayOptionsFromEnv(this.defaultPort()); + if (!this.relay) { + this.relay = new RelayServer(port, opts); + this.ensureRelayHandlers(this.relay); + } + + if (!this.relay.isRunning) { + await this.relay.start(); + } + + const approval = requireApproval ?? this.requireApprovalDefault(); + const repoRemote = detectRepoRemote(); + this.relay.setSessionPolicy({ + requireApproval: approval, + repoRemote: repoRemote ?? "", + allowedEmailDomain: this.cfg().get("allowedEmailDomain")?.trim() || undefined, + }); + this.pendingUsers = []; + + this.mode = "sharing"; + const token = await this.relay.issueToken(this.sessionId, role); + const joinHost = this.publicJoinHost( + this.relay.getPort(), + this.relay.getHost(), + this.relay.isExternal() + ); + const joinCommand = `/chorus-join token="${token.token}" host="${joinHost}"`; + this.appendSystem( + this.relay.isExternal() + ? `Attached to external relay ${this.relay.getHost()}:${this.relay.getPort()}` + : `chorus-relay started on port ${this.relay.getPort()}` + ); + this.appendSystem( + approval + ? "Joiners wait for your approval (Chorus: Approve Joiner / Deny Joiner)." + : "Auto-admit is on (requireApproval=false)." + ); + if (repoRemote) { + this.appendSystem(`Repo gate on: joiners must share origin ${repoRemote}.`); + } + this.appendSystem(`Share ready (${role}). Collaborator command:\n${joinCommand}`); + this.refreshStatus(); + + await vscode.env.clipboard.writeText(joinCommand); + void vscode.window + .showInformationMessage(`Chorus sharing — join command copied to clipboard.`, "Copy again") + .then((choice) => { + if (choice === "Copy again") void vscode.env.clipboard.writeText(joinCommand); + }); + + return joinCommand; + } + + approveUser(userId: string): void { + if (!this.relay || this.mode !== "sharing") { + throw new Error("Share a session first to approve joiners."); + } + this.relay.approveUser(userId); + this.pendingUsers = this.pendingUsers.filter((u) => u.userId !== userId); + this.appendSystem(`Approved ${userId}`); + this.refreshStatus(); + } + + denyUser(userId: string): void { + if (!this.relay || this.mode !== "sharing") { + throw new Error("Share a session first to deny joiners."); + } + this.relay.denyUser(userId); + this.pendingUsers = this.pendingUsers.filter((u) => u.userId !== userId); + this.appendSystem(`Denied ${userId}`); + this.refreshStatus(); + } + + async join(token: string, host: string, name?: string): Promise { + if (this.mode === "sharing") { + throw new Error("Stop sharing (Chorus: Stop Sharing) before joining another session."); + } + if (this.joinClient) { + this.joinClient.disconnect(); + this.joinClient = null; + } + + const displayName = normalizeDisplayName(name) ?? this.displayName(); + const repoRemote = detectRepoRemote(); + const email = this.cfg().get("email")?.trim() || undefined; + const jc = new JoinClient(`ws://${host}/ws`, token, displayName, repoRemote, email); + + jc.setChatHandler((msgName, content) => { + const text = `💬 [${msgName ?? "host"}]: ${content}`; + this.append({ id: newEventId(), text, at: Date.now(), kind: "chat" }); + void vscode.window.showInformationMessage(text); + }); + jc.setTypingHandler((typingName) => { + this.statusBar.text = `$(edit) ${typingName ?? "someone"} typing…`; + setTimeout(() => this.refreshStatus(), 2000).unref?.(); + }); + jc.setEventHandler((event) => this.appendSession(event)); + jc.setPendingHandler((userId) => { + this.mode = "pending"; + this.appendSystem(`Waiting for host approval (userId=${userId})`); + this.refreshStatus(); + void vscode.window.showInformationMessage("Chorus: waiting for the host to approve your join."); + }); + jc.setApprovedHandler(() => { + this.mode = "joined"; + this.appendSystem("Host approved — you are in the session."); + this.refreshStatus(); + void vscode.window.showInformationMessage("Chorus: host approved your join."); + }); + + await jc.connect(); + + for (const event of jc.getState().recentEvents) { + this.appendSession(event); + } + + this.joinClient = jc; + const st = jc.getState().status; + this.mode = st === "pending" ? "pending" : "joined"; + this.appendSystem( + st === "pending" + ? `Connected to ${host} as ${displayName} — pending approval` + : `Joined ${host} as ${displayName}` + ); + this.refreshStatus(); + } + + async leave(): Promise { + this.joinClient?.disconnect(); + this.joinClient = null; + if (this.mode === "joined" || this.mode === "pending") this.mode = "idle"; + this.appendSystem("Left shared session"); + this.refreshStatus(); + } + + async stop(): Promise { + this.mode = "idle"; + this.pendingUsers = []; + const relay = this.relay; + this.relay = null; + if (relay) await relay.stop(); + this.appendSystem("Stopped sharing"); + this.refreshStatus(); + } + + sendChat(message: string): void { + if (this.mode === "sharing" && this.relay) { + this.relay.sendChat(this.displayName(), message); + this.append({ + id: newEventId(), + text: `💬 [${this.displayName()}]: ${message}`, + at: Date.now(), + kind: "chat", + }); + return; + } + if (this.mode === "joined" && this.joinClient?.getState().status === "connected") { + this.joinClient.sendTyping(); + this.joinClient.sendChat(message); + this.append({ + id: newEventId(), + text: `💬 [${this.displayName()}]: ${message}`, + at: Date.now(), + kind: "chat", + }); + return; + } + if (this.mode === "pending") { + throw new Error("Still waiting for host approval — chat is blocked until then."); + } + throw new Error("Not currently sharing or joined."); + } + + /** Joiner → host collab.input */ + sendPrompt(content: string): void { + if (!this.joinClient) { + throw new Error("Join a session first to send prompts to the host."); + } + if (this.joinClient.getState().status !== "connected") { + throw new Error("Cannot send prompts while pending host approval."); + } + this.joinClient.sendInput(content); + this.appendSystem( + `→ sent prompt to host (${content.slice(0, 80)}${content.length > 80 ? "…" : ""})` + ); + } + + /** Host publishes a user/assistant line onto the shared transcript. */ + publishHostMessage(content: string, type: "user" | "assistant" = "user"): void { + if (this.mode !== "sharing" || !this.relay) { + throw new Error("Share a session first to publish host messages."); + } + const event: SessionEvent = { + id: newEventId(), + sessionId: this.sessionId, + type, + payload: content, + timestamp: Date.now(), + }; + this.relay.pushEvent(event); + this.appendSession(event); + } + + statusText(): string { + const share = this.getShareSummary(); + const join = this.joinClient + ? { joined: true, ...this.joinClient.getState() } + : { joined: false }; + return JSON.stringify( + { mode: this.mode, pendingUsers: this.pendingUsers, ...share, ...join }, + null, + 2 + ); + } + + dispose(): void { + this.joinClient?.disconnect(); + void this.relay?.stop(); + this._onDidChange.dispose(); + } +} diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts new file mode 100644 index 0000000..fe56c82 --- /dev/null +++ b/packages/vscode/src/extension.ts @@ -0,0 +1,224 @@ +import * as vscode from "vscode"; +import type { UserRole } from "@chorus/shared"; +import { ChorusController } from "./controller.js"; +import { SessionViewProvider } from "./sessionView.js"; + +export function activate(context: vscode.ExtensionContext): void { + const output = vscode.window.createOutputChannel("Chorus"); + const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); + statusBar.show(); + + const controller = new ChorusController(output, statusBar); + const viewProvider = new SessionViewProvider(controller); + + context.subscriptions.push( + output, + statusBar, + controller, + vscode.window.registerWebviewViewProvider(SessionViewProvider.viewType, viewProvider) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.share", async () => { + try { + const rolePick = await vscode.window.showQuickPick( + [ + { label: "edit", description: "Can send prompts (default)" }, + { label: "view", description: "Read-only" }, + { label: "admin", description: "Full control" }, + ], + { title: "Chorus share role for join token" } + ); + const role = (rolePick?.label ?? "edit") as UserRole; + const joinCommand = await controller.share(role); + output.show(true); + void vscode.commands.executeCommand("chorus.session.focus"); + return joinCommand; + } catch (err) { + void vscode.window.showErrorMessage(`Chorus share failed: ${String(err)}`); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.join", async () => { + try { + const token = await vscode.window.showInputBox({ + title: "Chorus join token", + prompt: "Paste the token from the host's share command", + ignoreFocusOut: true, + }); + if (!token) return; + + const host = await vscode.window.showInputBox({ + title: "Chorus relay host", + prompt: "host:port (e.g. 192.168.1.5:7742)", + ignoreFocusOut: true, + value: "127.0.0.1:7742", + }); + if (!host) return; + + const name = await vscode.window.showInputBox({ + title: "Display name", + prompt: "Required — shown to collaborators (cannot be empty)", + ignoreFocusOut: true, + value: vscode.workspace.getConfiguration("chorus").get("displayName") || "", + }); + if (name !== undefined && !name.trim()) { + void vscode.window.showErrorMessage("Display name is required."); + return; + } + + await controller.join(token.trim(), host.trim(), name?.trim() || undefined); + output.show(true); + const mode = controller.getMode(); + void vscode.window.showInformationMessage( + mode === "pending" ? "Connected — waiting for host approval" : "Joined Chorus session" + ); + } catch (err) { + void vscode.window.showErrorMessage(`Chorus join failed: ${String(err)}`); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.approve", async () => { + try { + const pending = controller.getPendingUsers(); + if (!pending.length) { + const userId = await vscode.window.showInputBox({ + title: "Approve joiner", + prompt: "Pending user id", + ignoreFocusOut: true, + }); + if (!userId) return; + controller.approveUser(userId.trim()); + return; + } + const pick = await vscode.window.showQuickPick( + pending.map((u) => ({ + label: u.displayName, + description: `${u.role} · ${u.userId}`, + userId: u.userId, + })), + { title: "Approve joiner" } + ); + if (!pick) return; + controller.approveUser(pick.userId); + } catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.deny", async () => { + try { + const pending = controller.getPendingUsers(); + if (!pending.length) { + const userId = await vscode.window.showInputBox({ + title: "Deny joiner", + prompt: "Pending user id", + ignoreFocusOut: true, + }); + if (!userId) return; + controller.denyUser(userId.trim()); + return; + } + const pick = await vscode.window.showQuickPick( + pending.map((u) => ({ + label: u.displayName, + description: `${u.role} · ${u.userId}`, + userId: u.userId, + })), + { title: "Deny joiner" } + ); + if (!pick) return; + controller.denyUser(pick.userId); + } catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.leave", async () => { + await controller.leave(); + void vscode.window.showInformationMessage("Left Chorus session"); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.stop", async () => { + await controller.stop(); + void vscode.window.showInformationMessage("Stopped Chorus sharing"); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.chat", async () => { + try { + const message = await vscode.window.showInputBox({ + title: "Chorus chat", + prompt: "Side-channel message (does not enter the LLM transcript)", + ignoreFocusOut: true, + }); + if (!message) return; + controller.sendChat(message); + } catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.sendPrompt", async () => { + try { + const content = await vscode.window.showInputBox({ + title: "Send prompt to Chorus host", + prompt: "Forwarded as collab.input into the shared session", + ignoreFocusOut: true, + }); + if (!content) return; + controller.sendPrompt(content); + } catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.publishHostMessage", async () => { + try { + const content = await vscode.window.showInputBox({ + title: "Publish host message", + prompt: "Pushed to all joiners as a session user event", + ignoreFocusOut: true, + }); + if (!content) return; + controller.publishHostMessage(content, "user"); + } catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.status", async () => { + const text = controller.statusText(); + output.appendLine(text); + output.show(true); + await vscode.window.showInformationMessage(`Chorus mode: ${controller.getMode()}`); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("chorus.openSession", async () => { + await vscode.commands.executeCommand("chorus.session.focus"); + }) + ); +} + +export function deactivate(): void { + // disposables handled via subscriptions +} diff --git a/packages/vscode/src/format.ts b/packages/vscode/src/format.ts new file mode 100644 index 0000000..2082ae5 --- /dev/null +++ b/packages/vscode/src/format.ts @@ -0,0 +1,19 @@ +import type { SessionEvent } from "@chorus/shared"; + +const LABELED_LINE = /^\[[^\]]+\]:\s/; + +/** Format a shared session event for the Chorus panel / notifications. */ +export function formatSessionLine(event: SessionEvent): string | null { + const payload = + typeof event.payload === "string" ? event.payload : JSON.stringify(event.payload); + if (event.type === "user") { + if (LABELED_LINE.test(payload)) return payload; + return `[Host]: ${payload}`; + } + if (event.type === "assistant") return `[AI]: ${payload}`; + return `[${event.type}]: ${payload}`; +} + +export function newEventId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} diff --git a/packages/vscode/src/git.ts b/packages/vscode/src/git.ts new file mode 100644 index 0000000..266eca7 --- /dev/null +++ b/packages/vscode/src/git.ts @@ -0,0 +1,19 @@ +import { execFileSync } from "node:child_process"; +import * as vscode from "vscode"; + +/** Best-effort `git remote get-url origin` for the active workspace folder. */ +export function detectRepoRemote(): string | undefined { + const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!folder) return undefined; + try { + const out = execFileSync("git", ["remote", "get-url", "origin"], { + cwd: folder, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 3000, + }).trim(); + return out || undefined; + } catch { + return undefined; + } +} diff --git a/packages/vscode/src/sessionView.ts b/packages/vscode/src/sessionView.ts new file mode 100644 index 0000000..8d08451 --- /dev/null +++ b/packages/vscode/src/sessionView.ts @@ -0,0 +1,136 @@ +import * as vscode from "vscode"; +import type { ChorusController, TranscriptLine } from "./controller.js"; + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function lineClass(line: TranscriptLine): string { + if (line.kind === "chat") return "chat"; + if (line.kind === "system") return "system"; + if (line.text.startsWith("[AI]:")) return "ai"; + if (line.text.startsWith("[Host]:")) return "host"; + return "user"; +} + +export class SessionViewProvider implements vscode.WebviewViewProvider { + public static readonly viewType = "chorus.session"; + private view?: vscode.WebviewView; + + constructor(private readonly controller: ChorusController) { + controller.onDidChange(() => this.render()); + } + + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken + ): void { + this.view = webviewView; + webviewView.webview.options = { enableScripts: true }; + webviewView.webview.onDidReceiveMessage((msg) => { + if (msg?.type === "chat" && typeof msg.text === "string") { + try { + this.controller.sendChat(msg.text); + } catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + } + if (msg?.type === "prompt" && typeof msg.text === "string") { + try { + this.controller.sendPrompt(msg.text); + } catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + } + if (msg?.type === "publish" && typeof msg.text === "string") { + try { + this.controller.publishHostMessage(msg.text, "user"); + } catch (err) { + void vscode.window.showErrorMessage(String(err)); + } + } + }); + this.render(); + } + + private render(): void { + if (!this.view) return; + const mode = this.controller.getMode(); + const lines = this.controller.getTranscript(); + const body = lines + .map( + (l) => + `
${escapeHtml(l.text)}
` + ) + .join(""); + + this.view.webview.html = ` + + + + + + +
Mode: ${escapeHtml(mode)}${ + mode === "pending" ? " — waiting for host approval" : "" + }
+
${body || "
Idle — Share or Join a Chorus session.
"}
+
+
+ + +
+
+ +
+
+ + +`; + } +} diff --git a/packages/vscode/test/format.test.ts b/packages/vscode/test/format.test.ts new file mode 100644 index 0000000..2dc4efc --- /dev/null +++ b/packages/vscode/test/format.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "bun:test"; +import { formatSessionLine } from "../src/format.ts"; + +describe("formatSessionLine", () => { + it("labels plain user events as Host", () => { + expect( + formatSessionLine({ + id: "1", + sessionId: "s", + type: "user", + payload: "hello", + timestamp: 1, + }) + ).toBe("[Host]: hello"); + }); + + it("keeps already-labeled collaborator lines", () => { + expect( + formatSessionLine({ + id: "1", + sessionId: "s", + type: "user", + payload: "[Ada]: refactor", + timestamp: 1, + }) + ).toBe("[Ada]: refactor"); + }); + + it("labels assistant events as AI", () => { + expect( + formatSessionLine({ + id: "1", + sessionId: "s", + type: "assistant", + payload: "sure", + timestamp: 1, + }) + ).toBe("[AI]: sure"); + }); +}); diff --git a/packages/vscode/tsconfig.json b/packages/vscode/tsconfig.json new file mode 100644 index 0000000..3d6bd83 --- /dev/null +++ b/packages/vscode/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true, + "declaration": true, + "types": ["node", "vscode"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/scripts/security-e2e.ts b/scripts/security-e2e.ts index a10229e..0ea9fd4 100644 --- a/scripts/security-e2e.ts +++ b/scripts/security-e2e.ts @@ -9,7 +9,7 @@ import { readFileSync, existsSync } from "node:fs"; import { join as pathJoin, resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { JoinClient } from "../packages/plugin/src/join/index.js"; +import { JoinClient } from "../packages/client/src/index.ts"; import { detectRepoRemote } from "../packages/plugin/src/git.js"; const REPO = resolve(dirname(fileURLToPath(import.meta.url)), ".."); diff --git a/scripts/vscode-e2e.ts b/scripts/vscode-e2e.ts new file mode 100644 index 0000000..0b0f241 --- /dev/null +++ b/scripts/vscode-e2e.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env bun +/** + * VS Code adapter integration: same JoinClient + RelayServer path the + * extension uses (share policy, pending admit, email gate, collab.input). + * Does not require a VS Code GUI. + */ +import { JoinClient, RelayServer } from "../packages/client/src/index.ts"; + +const PORT = parseInt(process.env["CHORUS_VSCODE_E2E_PORT"] ?? "18742", 10); + +async function expectReject(p: Promise, label: string): Promise { + try { + await p; + throw new Error(`expected ${label} to fail`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/expected .+ to fail/.test(msg)) throw err; + console.log(` ${label}: ${msg}`); + } +} + +async function waitUntil(fn: () => boolean, label: string, ms = 8000): Promise { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if (fn()) return; + await new Promise((r) => setTimeout(r, 40)); + } + throw new Error(`timeout: ${label}`); +} + +async function main() { + const relay = new RelayServer(PORT); + await relay.start(); + console.log(`✓ relay on :${PORT}`); + + try { + relay.setSessionPolicy({ + requireApproval: true, + allowedEmailDomain: "chorus.test", + }); + + const token = (await relay.issueToken("vscode-e2e", "edit")).token; + + console.log("→ reject wrong email"); + const bad = new JoinClient(`ws://127.0.0.1:${PORT}/ws`, token, "Eve", undefined, "eve@other.com"); + await expectReject(bad.connect(), "wrong-email"); + + const pendingIds: string[] = []; + relay.setUserPendingHandler((u) => pendingIds.push(u.userId)); + const received: string[] = []; + relay.setInputHandler(async (content) => { + received.push(content); + }); + + console.log("→ join with matching email (pending)"); + const jc = new JoinClient( + `ws://127.0.0.1:${PORT}/ws`, + token, + "VscodeJoiner", + undefined, + "dev@chorus.test" + ); + await jc.connect(); + if (jc.getState().status !== "pending") { + throw new Error(`expected pending, got ${jc.getState().status}`); + } + await waitUntil(() => pendingIds.length > 0, "host saw pending"); + console.log(`✓ pending userId=${pendingIds[0]}`); + + jc.sendInput("should-not-land"); + await new Promise((r) => setTimeout(r, 120)); + if (received.length !== 0) throw new Error("collab.input leaked while pending"); + + console.log("→ approve"); + relay.approveUser(pendingIds[0]!); + await waitUntil(() => jc.getState().status === "connected", "joiner admitted"); + console.log("✓ admitted"); + + jc.sendInput("from-vscode-adapter"); + await waitUntil(() => received.includes("from-vscode-adapter"), "collab.input"); + console.log("✓ collab.input after approve"); + + jc.disconnect(); + console.log("\nVS Code adapter e2e passed."); + } finally { + await relay.stop(); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/vscode-relay-e2e.ts b/scripts/vscode-relay-e2e.ts new file mode 100644 index 0000000..4c307cb --- /dev/null +++ b/scripts/vscode-relay-e2e.ts @@ -0,0 +1,309 @@ +#!/usr/bin/env bun +/** + * Cross-adapter relay e2e — exercises the same `@chorus/client` stack the VS Code + * extension and OpenCode terminal plugin share. + * + * Three cloud-environment roles (set CHORUS_E2E_ROLE): + * host-vscode — env1: VS Code-style host (RelayServer + email/approval policy) + * joiner-vscode — env2: VS Code-style joiner (JoinClient, approved, collab.input) + * disallowed-joiner — env3: join attempt rejected (wrong email domain) + * + * Default role `local` runs all scenarios in one process: + * 1. Three-env gate (host vscode + joiner vscode + disallowed joiner) + * 2. VS Code host relay joined by terminal (OpenCode) JoinClient + * 3. Terminal host relay joined by VS Code JoinClient + * + * Coordination file: CHORUS_E2E_STATE (default /tmp/chorus-vscode-relay-e2e.json) + */ +import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { dirname } from "node:path"; +import { JoinClient, RelayServer } from "../packages/client/src/index.ts"; + +type E2eState = { + port: number; + token: string; + host: string; + allowedEmailDomain: string; + pendingUserId?: string; + phase: "host-ready" | "joiner-done" | "disallowed-done" | "complete"; +}; + +const STATE_PATH = process.env["CHORUS_E2E_STATE"] ?? "/tmp/chorus-vscode-relay-e2e.json"; +const ROLE = process.env["CHORUS_E2E_ROLE"] ?? "local"; +const PORT = parseInt(process.env["CHORUS_VSCODE_RELAY_E2E_PORT"] ?? "18743", 10); +const EMAIL_DOMAIN = "chorus.test"; + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +async function waitUntil(fn: () => boolean, label: string, ms = 12_000): Promise { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if (fn()) return; + await sleep(40); + } + throw new Error(`timeout: ${label}`); +} + +async function expectReject(p: Promise, label: string): Promise { + try { + await p; + throw new Error(`expected ${label} to fail`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/expected .+ to fail/.test(msg)) throw err; + console.log(` ${label}: ${msg.slice(0, 120)}`); + } +} + +function readState(): E2eState { + if (!existsSync(STATE_PATH)) { + throw new Error(`Missing state file ${STATE_PATH} — run host-vscode first`); + } + return JSON.parse(readFileSync(STATE_PATH, "utf8")) as E2eState; +} + +function writeState(state: E2eState): void { + mkdirSync(dirname(STATE_PATH), { recursive: true }); + writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); +} + +/** VS Code extension share path: RelayServer + session policy + token issue. */ +async function vscodeHostShare(relay: RelayServer): Promise<{ token: string; port: number }> { + relay.setSessionPolicy({ + requireApproval: true, + allowedEmailDomain: EMAIL_DOMAIN, + }); + const token = (await relay.issueToken("vscode-relay-e2e", "edit")).token; + return { token, port: relay.getPort() }; +} + +async function runHostVscode(): Promise { + const relay = new RelayServer(PORT); + await relay.start(); + console.log(`[env1 host-vscode] relay listening on 127.0.0.1:${PORT}`); + + const { token, port } = await vscodeHostShare(relay); + writeState({ + port, + token, + host: "127.0.0.1", + allowedEmailDomain: EMAIL_DOMAIN, + phase: "host-ready", + }); + console.log(`[env1 host-vscode] token issued — waiting for joiners (state → ${STATE_PATH})`); + + await waitUntil(() => { + const s = readState(); + return s.phase === "complete"; + }, "all joiner phases"); + + await relay.stop(); + console.log("[env1 host-vscode] complete"); +} + +async function runJoinerVscode(): Promise { + const state = readState(); + if (state.phase !== "host-ready" && state.phase !== "joiner-done") { + await waitUntil(() => readState().phase === "host-ready", "host-ready"); + } + const { port, token } = readState(); + + console.log(`[env2 joiner-vscode] connecting to ws://127.0.0.1:${port}/ws`); + const jc = new JoinClient( + `ws://127.0.0.1:${port}/ws`, + token, + "VscodeJoiner", + undefined, + `dev@${EMAIL_DOMAIN}` + ); + await jc.connect(); + if (jc.getState().status !== "pending") { + throw new Error(`expected pending, got ${jc.getState().status}`); + } + console.log(`[env2 joiner-vscode] pending userId=${jc.getState().userId}`); + + writeState({ ...readState(), pendingUserId: jc.getState().userId, phase: "host-ready" }); + + // Host-side approve happens in local orchestrator or manual step; poll until connected. + await waitUntil(() => jc.getState().status === "connected", "host approved joiner", 20_000); + console.log("[env2 joiner-vscode] admitted"); + + jc.sendInput("prompt-from-vscode-joiner"); + await sleep(200); + jc.disconnect(); + + writeState({ ...readState(), phase: "joiner-done" }); + console.log("[env2 joiner-vscode] complete"); +} + +async function runDisallowedJoiner(): Promise { + await waitUntil(() => { + const s = existsSync(STATE_PATH) ? readState() : null; + return Boolean(s && s.phase === "host-ready"); + }, "host-ready for disallowed attempt"); + + const { port, token } = readState(); + console.log("[env3 disallowed-joiner] attempting wrong-email join"); + const bad = new JoinClient( + `ws://127.0.0.1:${port}/ws`, + token, + "BlockedUser", + undefined, + "intruder@other.test" + ); + await expectReject(bad.connect(), "wrong-email-domain"); + writeState({ ...readState(), phase: "disallowed-done" }); + console.log("[env3 disallowed-joiner] rejected as expected"); +} + +async function runLocal(): Promise { + console.log("=== Scenario A: three-env gate (host vscode / joiner vscode / disallowed) ==="); + const relay = new RelayServer(PORT); + await relay.start(); + + const pendingIds: string[] = []; + const hostInputs: string[] = []; + relay.setUserPendingHandler((u) => pendingIds.push(u.userId)); + relay.setInputHandler(async (content) => { + hostInputs.push(content); + }); + + try { + const { token } = await vscodeHostShare(relay); + const wsUrl = `ws://127.0.0.1:${PORT}/ws`; + + console.log("→ env3 disallowed joiner (wrong email)"); + await expectReject( + new JoinClient(wsUrl, token, "Intruder", undefined, "bad@other.test").connect(), + "disallowed-email" + ); + console.log("✓ disallowed joiner rejected"); + + console.log("→ env2 joiner vscode (matching email, pending)"); + const vscodeJoiner = new JoinClient( + wsUrl, + token, + "VscodeJoiner", + undefined, + `alice@${EMAIL_DOMAIN}` + ); + await vscodeJoiner.connect(); + if (vscodeJoiner.getState().status !== "pending") { + throw new Error(`expected pending, got ${vscodeJoiner.getState().status}`); + } + await waitUntil(() => pendingIds.length > 0, "env1 host saw pending"); + console.log(`✓ env2 pending userId=${pendingIds[0]}`); + + relay.approveUser(pendingIds[0]!); + await waitUntil(() => vscodeJoiner.getState().status === "connected", "env2 admitted"); + vscodeJoiner.sendInput("from-vscode-joiner"); + await waitUntil(() => hostInputs.includes("from-vscode-joiner"), "collab.input from vscode joiner"); + console.log("✓ env2 collab.input delivered to env1 host"); + vscodeJoiner.disconnect(); + } finally { + await relay.stop(); + } + + console.log("\n=== Scenario B: VS Code host ← terminal joiner ==="); + { + const relay = new RelayServer(PORT + 1); + await relay.start(); + relay.setSessionPolicy({ requireApproval: false }); + const terminalInputs: string[] = []; + relay.setInputHandler(async (c) => terminalInputs.push(c)); + try { + const token = (await relay.issueToken("terminal-join", "edit")).token; + const terminalJoiner = new JoinClient(`ws://127.0.0.1:${PORT + 1}/ws`, token, "TerminalUser"); + await terminalJoiner.connect(); + terminalJoiner.sendInput("from-terminal-joiner"); + await waitUntil(() => terminalInputs.includes("from-terminal-joiner"), "terminal→vscode-host"); + console.log("✓ terminal joiner reached VS Code-style host relay"); + terminalJoiner.disconnect(); + } finally { + await relay.stop(); + } + } + + console.log("\n=== Scenario C: terminal host → VS Code joiner ==="); + { + const relay = new RelayServer(PORT + 2); + await relay.start(); + relay.setSessionPolicy({ requireApproval: false }); + const terminalHostInputs: string[] = []; + relay.setInputHandler(async (c) => terminalHostInputs.push(c)); + try { + const token = (await relay.issueToken("terminal-host", "edit")).token; + const vscodeJoiner = new JoinClient(`ws://127.0.0.1:${PORT + 2}/ws`, token, "VscodeJoiner"); + await vscodeJoiner.connect(); + vscodeJoiner.sendInput("from-vscode-to-terminal-host"); + await waitUntil( + () => terminalHostInputs.includes("from-vscode-to-terminal-host"), + "vscode→terminal-host" + ); + console.log("✓ VS Code joiner reached terminal-style host relay"); + vscodeJoiner.disconnect(); + } finally { + await relay.stop(); + } + } + + console.log("\n✓ vscode-relay-e2e passed (all scenarios)"); +} + +async function runDistributedOrchestrator(): Promise { + // When roles run on separate machines, host must approve after joiner-vscode connects. + if (ROLE === "host-vscode") { + const relay = new RelayServer(PORT); + await relay.start(); + + const pendingIds: string[] = []; + const hostInputs: string[] = []; + relay.setUserPendingHandler((u) => pendingIds.push(u.userId)); + relay.setInputHandler(async (content) => hostInputs.push(content)); + + const { token, port } = await vscodeHostShare(relay); + writeState({ + port, + token, + host: process.env["CHORUS_PUBLIC_HOST"]?.split(":")[0] ?? "127.0.0.1", + allowedEmailDomain: EMAIL_DOMAIN, + phase: "host-ready", + }); + + await waitUntil(() => readState().pendingUserId, "joiner pending"); + const userId = readState().pendingUserId!; + relay.approveUser(userId); + await waitUntil(() => hostInputs.length > 0, "collab.input from joiner"); + await waitUntil(() => readState().phase === "disallowed-done", "disallowed joiner phase"); + writeState({ ...readState(), phase: "complete" }); + await relay.stop(); + return; + } + + if (ROLE === "joiner-vscode") { + await runJoinerVscode(); + return; + } + + if (ROLE === "disallowed-joiner") { + await runDisallowedJoiner(); + return; + } + + throw new Error(`Unknown CHORUS_E2E_ROLE=${ROLE}`); +} + +async function main() { + if (ROLE === "local") { + await runLocal(); + return; + } + await runDistributedOrchestrator(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});