codehost 0.20.4 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -69,27 +69,39 @@ function nativeLoadError(cause: unknown): Error {
69
69
  }
70
70
 
71
71
  export interface RtcDaemonOptions {
72
- /** Relay a signal to a viewer peer via the signaling channel. */
72
+ /** Relay a signal to a client peer via the signaling channel. */
73
73
  sendSignal: (to: string, data: RtcSignal) => void;
74
- /** Called when a viewer's data channel opens. */
75
- onChannel: (viewerId: string, channel: DataChannel) => void;
74
+ /** Called when a client's data channel opens. */
75
+ onChannel: (clientId: string, channel: DataChannel) => void;
76
+ /**
77
+ * Admission gate. Resolve true to answer the client's offer, false to deny.
78
+ * Until it resolves we buffer the answer + local ICE so a pending client never
79
+ * completes a connection. Omitted → admit everyone (default).
80
+ */
81
+ admit?: (clientId: string) => Promise<boolean>;
82
+ /** Called once when a client's connection is torn down (drop / deny / kick). */
83
+ onClose?: (clientId: string) => void;
76
84
  }
77
85
 
78
- interface ViewerConn {
86
+ interface ClientConn {
79
87
  pc: PeerConnectionT;
88
+ /** True once the host has admitted this client; gates outbound answer/ICE. */
89
+ admitted: boolean;
90
+ /** Outbound signals withheld while admission is pending. */
91
+ buffered: RtcSignal[];
80
92
  }
81
93
 
82
94
  /**
83
- * Daemon-side WebRTC manager. The browser (viewer) is the offerer; for each
84
- * viewer that sends an offer we create an answering PeerConnection and surface
95
+ * Daemon-side WebRTC manager. The browser (client) is the offerer; for each
96
+ * client that sends an offer we create an answering PeerConnection and surface
85
97
  * its data channel. STUN-only.
86
98
  */
87
99
  export class RtcDaemon {
88
- private viewers = new Map<string, ViewerConn>();
100
+ private clients = new Map<string, ClientConn>();
89
101
 
90
102
  constructor(private opts: RtcDaemonOptions) {}
91
103
 
92
- /** Route an inbound signaling payload from a viewer. */
104
+ /** Route an inbound signaling payload from a client. */
93
105
  handleSignal(from: string, data: unknown): void {
94
106
  const sig = data as RtcSignal;
95
107
  if (!sig || typeof sig !== "object") return;
@@ -97,7 +109,7 @@ export class RtcDaemon {
97
109
  if (sig.kind === "offer") {
98
110
  this.acceptOffer(from, sig.sdp);
99
111
  } else if (sig.kind === "candidate") {
100
- const conn = this.viewers.get(from);
112
+ const conn = this.clients.get(from);
101
113
  if (conn) {
102
114
  try {
103
115
  conn.pc.addRemoteCandidate(sig.candidate, sig.mid);
@@ -108,60 +120,88 @@ export class RtcDaemon {
108
120
  }
109
121
  }
110
122
 
111
- private acceptOffer(viewerId: string, sdp: string): void {
112
- // Replace any prior connection for this viewer (e.g. page reload).
113
- this.dropViewer(viewerId);
123
+ /** Close a specific client's connection (host kick). */
124
+ close(clientId: string): void {
125
+ this.dropClient(clientId);
126
+ }
127
+
128
+ private acceptOffer(clientId: string, sdp: string): void {
129
+ // Replace any prior connection for this client (e.g. page reload).
130
+ this.dropClient(clientId);
114
131
 
115
- const pc = new ndc.PeerConnection(`viewer-${viewerId.slice(0, 8)}`, {
132
+ const pc = new ndc.PeerConnection(`client-${clientId.slice(0, 8)}`, {
116
133
  iceServers: ICE_SERVERS,
117
134
  });
118
- this.viewers.set(viewerId, { pc });
135
+ const conn: ClientConn = { pc, admitted: false, buffered: [] };
136
+ this.clients.set(clientId, conn);
137
+
138
+ // Withhold the answer + our ICE until the host admits this client; we still
139
+ // accept the offer + remote candidates so ICE is ready to flush instantly.
140
+ const emit = (sig: RtcSignal) => {
141
+ if (conn.admitted) this.opts.sendSignal(clientId, sig);
142
+ else conn.buffered.push(sig);
143
+ };
119
144
 
120
145
  pc.onLocalDescription((localSdp, type) => {
121
- this.opts.sendSignal(viewerId, {
122
- kind: type as "answer",
123
- type: type as "answer",
124
- sdp: localSdp,
125
- });
146
+ emit({ kind: type as "answer", type: type as "answer", sdp: localSdp });
126
147
  });
127
148
 
128
149
  pc.onLocalCandidate((candidate, mid) => {
129
- this.opts.sendSignal(viewerId, { kind: "candidate", candidate, mid });
150
+ emit({ kind: "candidate", candidate, mid });
130
151
  });
131
152
 
132
153
  pc.onStateChange((state) => {
133
- console.log(`[rtc] ${viewerId.slice(0, 8)} state: ${state}`);
154
+ console.log(`[rtc] ${clientId.slice(0, 8)} state: ${state}`);
134
155
  if (state === "disconnected" || state === "failed" || state === "closed") {
135
- this.dropViewer(viewerId);
156
+ this.dropClient(clientId);
136
157
  }
137
158
  });
138
159
 
139
160
  pc.onDataChannel((dc) => {
140
- console.log(`[rtc] ${viewerId.slice(0, 8)} channel "${dc.getLabel()}" open`);
141
- this.opts.onChannel(viewerId, dc);
161
+ console.log(`[rtc] ${clientId.slice(0, 8)} channel "${dc.getLabel()}" open`);
162
+ this.opts.onChannel(clientId, dc);
142
163
  });
143
164
 
144
165
  try {
145
166
  pc.setRemoteDescription(sdp, "offer");
146
167
  } catch (err) {
147
- console.error(`[rtc] setRemoteDescription failed for ${viewerId.slice(0, 8)}:`, err);
148
- this.dropViewer(viewerId);
168
+ console.error(`[rtc] setRemoteDescription failed for ${clientId.slice(0, 8)}:`, err);
169
+ this.dropClient(clientId);
170
+ return;
149
171
  }
172
+
173
+ const admit = this.opts.admit ? this.opts.admit(clientId) : Promise.resolve(true);
174
+ admit
175
+ .then((ok) => {
176
+ // The client may have reloaded/dropped while we waited — only act if
177
+ // this exact connection is still current.
178
+ if (this.clients.get(clientId) !== conn) return;
179
+ if (!ok) {
180
+ this.opts.sendSignal(clientId, { kind: "denied" });
181
+ this.dropClient(clientId);
182
+ return;
183
+ }
184
+ conn.admitted = true;
185
+ for (const sig of conn.buffered) this.opts.sendSignal(clientId, sig);
186
+ conn.buffered = [];
187
+ })
188
+ .catch(() => this.dropClient(clientId));
150
189
  }
151
190
 
152
- private dropViewer(viewerId: string): void {
153
- const conn = this.viewers.get(viewerId);
191
+ private dropClient(clientId: string): void {
192
+ const conn = this.clients.get(clientId);
154
193
  if (!conn) return;
155
- this.viewers.delete(viewerId);
194
+ this.clients.delete(clientId);
156
195
  try {
157
196
  conn.pc.close();
158
197
  } catch {
159
198
  // ignore
160
199
  }
200
+ this.opts.onClose?.(clientId);
161
201
  }
162
202
 
163
203
  closeAll(): void {
164
- for (const id of [...this.viewers.keys()]) this.dropViewer(id);
204
+ for (const id of [...this.clients.keys()]) this.dropClient(id);
165
205
  try {
166
206
  ndc.cleanup();
167
207
  } catch {
@@ -1,7 +1,8 @@
1
1
  import { watch } from "node:fs";
2
2
  import { basename, dirname } from "node:path";
3
- import { type PeerMeta, newPeerId } from "../shared/signaling";
3
+ import { type PeerMeta, isClientRole, newPeerId } from "../shared/signaling";
4
4
  import { SignalingClient } from "../shared/signaling-client";
5
+ import { Approver, type ApprovePolicy } from "./approver";
5
6
  import { RtcDaemon } from "./rtc-daemon";
6
7
  import { type LocalRequest, Tunnel } from "./tunnel";
7
8
  import { handleProvision, isProvisionPath, type ProvisionDeps } from "./provision-server";
@@ -46,6 +47,10 @@ export interface RunServerOptions {
46
47
  * host workspace registry). Watched via their parent directory so the file
47
48
  * may not exist yet. */
48
49
  watchFiles?: string[];
50
+ /** Client admission policy (default "auto"). */
51
+ approve?: ApprovePolicy;
52
+ /** Label substrings auto-approved under the "confirm" policy. */
53
+ allow?: string[];
49
54
  }
50
55
 
51
56
  /** How often a daemon re-enumerates its workspaces (manual clones show up). */
@@ -53,7 +58,7 @@ const META_REFRESH_MS = 60_000;
53
58
 
54
59
  /**
55
60
  * Foreground server loop shared by `serve`, `dev`, and `expose`: register in the
56
- * signaling room with the given meta and bridge each viewer's data channel to a
61
+ * signaling room with the given meta and bridge each client's data channel to a
57
62
  * local server (VS Code for serve/dev, an arbitrary port for expose). Never
58
63
  * resolves.
59
64
  */
@@ -68,6 +73,23 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
68
73
  const target = await opts.launch(basePath);
69
74
 
70
75
  let rtc: RtcDaemon;
76
+
77
+ // Track client labels from the room roster so approval prompts and the bridge
78
+ // log name *who* connected (a leaked-token tell), not just an opaque peerId.
79
+ const clientNames = new Map<string, string>();
80
+ const labelFor = (clientId: string) => clientNames.get(clientId) ?? "unknown client";
81
+
82
+ const approver = new Approver({
83
+ policy: opts.approve ?? "auto",
84
+ allow: opts.allow ?? [],
85
+ kick: (clientId) => {
86
+ // Tell the client why it's being cut off, then tear down the connection.
87
+ client.sendSignal(clientId, { kind: "denied" });
88
+ rtc.close(clientId);
89
+ },
90
+ notifyPending: (clientId) => client.sendSignal(clientId, { kind: "pending" }),
91
+ });
92
+
71
93
  const client = new SignalingClient({
72
94
  url: opts.signal,
73
95
  token: opts.token,
@@ -82,6 +104,12 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
82
104
  const detail = info ? ` (code ${info.code}${info.reason ? ` "${info.reason}"` : ""}, up ${info.ms}ms)` : "";
83
105
  console.log(`[codehost] disconnected from signaling${detail}, reconnecting…`);
84
106
  },
107
+ onPeers: (peers) => {
108
+ clientNames.clear();
109
+ for (const p of peers) {
110
+ if (isClientRole(p.role) && p.meta?.name) clientNames.set(p.peerId, p.meta.name);
111
+ }
112
+ },
85
113
  onSignal: (from, data) => rtc.handleSignal(from, data),
86
114
  });
87
115
 
@@ -107,14 +135,30 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
107
135
  }
108
136
  : undefined;
109
137
 
138
+ // A client opens two data channels (interactive + bulk), so onChannel fires
139
+ // twice per connection — log/register "connected" only on the first.
140
+ const bridged = new Set<string>();
110
141
  rtc = new RtcDaemon({
111
142
  sendSignal: (to, data) => client.sendSignal(to, data),
112
- onChannel: (viewerId, channel) => {
113
- console.log(`[codehost] viewer ${viewerId.slice(0, 8)} connected; bridging to :${target.port}`);
143
+ admit: (clientId) => approver.admit(clientId, labelFor(clientId)),
144
+ onChannel: (clientId, channel) => {
145
+ if (!bridged.has(clientId)) {
146
+ bridged.add(clientId);
147
+ const who = labelFor(clientId);
148
+ console.log(`[codehost] ${who} (${clientId.slice(0, 8)}) connected; bridging to :${target.port}`);
149
+ approver.onConnected(clientId, who);
150
+ }
114
151
  new Tunnel(channel, target.port, target.stripBasePath, onLocal);
115
152
  },
153
+ onClose: (clientId) => {
154
+ bridged.delete(clientId);
155
+ approver.onDisconnected(clientId);
156
+ },
116
157
  });
117
158
 
159
+ approver.banner();
160
+ approver.start();
161
+
118
162
  client.connect();
119
163
  if (opts.refreshMeta) {
120
164
  setInterval(refreshMeta, opts.metaRefreshMs ?? META_REFRESH_MS);
@@ -144,6 +188,7 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
144
188
 
145
189
  const shutdown = () => {
146
190
  console.log("\n[codehost] shutting down");
191
+ approver.stop();
147
192
  rtc.closeAll();
148
193
  client.close();
149
194
  target.stop?.();
@@ -219,7 +219,7 @@ export function resolveRepoTarget(
219
219
  .sort(
220
220
  (a, b) =>
221
221
  Number(prefers(b.meta, prefer)) - Number(prefers(a.meta, prefer)) ||
222
- (b.meta?.cwd.length ?? 0) - (a.meta?.cwd.length ?? 0),
222
+ (b.meta?.cwd?.length ?? 0) - (a.meta?.cwd?.length ?? 0),
223
223
  );
224
224
 
225
225
  // A root that *enumerated* a matching checkout knows it exists on disk —
@@ -233,7 +233,7 @@ export function resolveRepoTarget(
233
233
  }
234
234
 
235
235
  const root = roots[0];
236
- if (root && root.meta) {
236
+ if (root && root.meta?.cwd) {
237
237
  const folder = `${trimSlash(root.meta.cwd)}/${fillLayout(root.meta.layout || DEFAULT_LAYOUT, target)}`;
238
238
  return { peerId: root.peerId, folder };
239
239
  }
@@ -250,7 +250,7 @@ export function resolveRepoTarget(
250
250
  export function resolveDevTarget(servers: PeerInfo[], target: DevTarget): Resolution | null {
251
251
  const want = stripEnds(target.path);
252
252
  const hostOk = (meta: PeerMeta) => !target.host || meta.host === target.host;
253
- const hit = servers.find((s) => s.meta && stripEnds(s.meta.cwd) === want && hostOk(s.meta));
253
+ const hit = servers.find((s) => s.meta?.cwd && stripEnds(s.meta.cwd) === want && hostOk(s.meta));
254
254
  if (hit) return { peerId: hit.peerId };
255
255
  for (const s of servers) {
256
256
  if (!s.meta || !hostOk(s.meta)) continue;
package/src/shared/rtc.ts CHANGED
@@ -20,7 +20,27 @@ export interface CandidateSignal {
20
20
  mid: string;
21
21
  }
22
22
 
23
- export type RtcSignal = SdpSignal | CandidateSignal;
23
+ /**
24
+ * Host-side admission control, relayed opaquely like any other signal. The
25
+ * daemon sends "pending" while a client awaits the host's approval and "denied"
26
+ * if the host rejects (or kicks) it — so the client can show why it's waiting or
27
+ * was cut off, instead of a silent hang. Approval needs no signal: the daemon
28
+ * just answers the offer. Old daemons never send these; old clients ignore an
29
+ * unknown `kind`, so this is backward compatible both ways.
30
+ */
31
+ export interface ControlSignal {
32
+ kind: "pending" | "denied";
33
+ }
34
+
35
+ export type RtcSignal = SdpSignal | CandidateSignal | ControlSignal;
24
36
 
25
37
  /** Label used for the control/tunnel data channel. */
26
38
  export const CHANNEL_LABEL = "codehost";
39
+ /**
40
+ * Second data channel for bulk HTTP bodies. Separate channel = separate SCTP
41
+ * stream, so multi-MB asset downloads no longer head-of-line block the
42
+ * interactive WS traffic (VS Code remote protocol, terminal) on
43
+ * CHANNEL_LABEL. The daemon spins up one Tunnel per incoming channel, so old
44
+ * daemons handle this unmodified; old browsers simply never open it.
45
+ */
46
+ export const BULK_CHANNEL_LABEL = "codehost-bulk";
@@ -14,7 +14,7 @@ export interface SignalingClientOptions {
14
14
  role: Role;
15
15
  meta?: PeerMeta;
16
16
  peerId?: string;
17
- onPeers?: (peers: PeerInfo[]) => void;
17
+ onPeers?: (peers: PeerInfo[], now?: number) => void;
18
18
  onSignal?: (from: string, data: unknown) => void;
19
19
  onOpen?: () => void;
20
20
  /** Called on every socket close. `info` carries the WebSocket close code,
@@ -192,7 +192,7 @@ export class SignalingClient {
192
192
  } catch {
193
193
  return;
194
194
  }
195
- if (msg.type === "peers") this.opts.onPeers?.(msg.peers);
195
+ if (msg.type === "peers") this.opts.onPeers?.(msg.peers, msg.now);
196
196
  else if (msg.type === "signal") this.opts.onSignal?.(msg.from, msg.data);
197
197
  };
198
198
 
@@ -2,7 +2,26 @@
2
2
  // Worker / Durable Object. A "room" is keyed by the user's token; every member
3
3
  // of a room can see the others and exchange WebRTC SDP/ICE via the relay.
4
4
 
5
- export type Role = "server" | "viewer";
5
+ /**
6
+ * Room roles. A connecting browser is a "client"; "viewer" is the legacy wire
7
+ * value for the same role, kept so older daemons/pages still interop (the term
8
+ * understated the access — a connected client gets the host's full VS Code:
9
+ * terminal + file write). Backward-compat plan ("accept both, emit old"):
10
+ * receivers treat client and viewer alike via `isClientRole`, and new code
11
+ * still EMITS the legacy `CLIENT_WIRE_ROLE` ("viewer") until daemons have rolled
12
+ * forward; a later release flips the emit to "client".
13
+ */
14
+ export type Role = "server" | "client" | "viewer";
15
+
16
+ /** The connecting-role value new code emits. Still the legacy "viewer" during
17
+ * the accept-both transition; flip to "client" once daemons recognize it. */
18
+ export const CLIENT_WIRE_ROLE: Role = "viewer";
19
+
20
+ /** True for either spelling of the connecting (browser) role. Use everywhere a
21
+ * receiver decides "is this peer a client" so both old and new peers match. */
22
+ export function isClientRole(role: Role): boolean {
23
+ return role === "client" || role === "viewer";
24
+ }
6
25
 
7
26
  /** One live agent CLI session on the daemon's machine (sourced from agent-yes's
8
27
  * registry) — advertised so clients can see which agents run where. Interact
@@ -36,14 +55,19 @@ export interface WorkspaceInfo {
36
55
  config?: boolean;
37
56
  }
38
57
 
39
- /** Metadata a `codehost serve`/`dev` daemon advertises about itself. */
58
+ /**
59
+ * Metadata a room member advertises. Servers (`codehost serve`/`dev`) fill the
60
+ * workspace fields; a client (the codehost.dev page) sends just `name` as its
61
+ * roster label and leaves the server-only fields unset — hence everything but
62
+ * `name` is optional.
63
+ */
40
64
  export interface PeerMeta {
41
- /** Human label, defaults to hostname. */
65
+ /** Human label. Server: defaults to hostname. Client: a browser/OS label. */
42
66
  name: string;
43
- /** Directory the VS Code instance is serving (the repo dir, or the root). */
44
- cwd: string;
45
- /** Hostname of the machine running the daemon. */
46
- host: string;
67
+ /** Server only: directory the VS Code instance is serving (repo dir or root). */
68
+ cwd?: string;
69
+ /** Server only: hostname of the machine running the daemon. */
70
+ host?: string;
47
71
  /**
48
72
  * Stable machine identity (UUID persisted in ~/.codehost/config.json). All
49
73
  * daemons on one machine share it, unlike the per-process peerId, so clients
@@ -84,6 +108,10 @@ export interface PeerInfo {
84
108
  peerId: string;
85
109
  role: Role;
86
110
  meta: PeerMeta | null;
111
+ /** Worker-stamped join time (ms). Compare against `PeersMessage.now`, which
112
+ * is on the same clock, for a roster "connected N ago" without clock skew.
113
+ * Absent from older workers. */
114
+ since?: number;
87
115
  }
88
116
 
89
117
  // ---- Client -> Server ----
@@ -133,6 +161,10 @@ export interface WelcomeMessage {
133
161
  export interface PeersMessage {
134
162
  type: "peers";
135
163
  peers: PeerInfo[];
164
+ /** Worker wall-clock (ms) at send time — same clock as `PeerInfo.since`, so a
165
+ * client renders relative join ages without trusting its own clock. Absent
166
+ * from older workers. */
167
+ now?: number;
136
168
  }
137
169
 
138
170
  /** A signal relayed from another peer. */
@@ -15,9 +15,10 @@ import { TunnelClient, type TunnelLike, type TunnelWsHandlers, type TunnelWsHand
15
15
 
16
16
  type AnyMsg = Record<string, any>;
17
17
 
18
- /** Creates the RTCPeerConnection for a peer and resolves with its open channel.
18
+ /** Creates the RTCPeerConnection for a peer and resolves with its open
19
+ * interactive channel plus the (possibly still-connecting) bulk lane.
19
20
  * Provided by the UI (discovery.tsx) and invoked only when this tab is owner. */
20
- export type Establish = () => Promise<RTCDataChannel>;
21
+ export type Establish = () => Promise<{ channel: RTCDataChannel; bulk: RTCDataChannel | null }>;
21
22
 
22
23
  class ConnBroker {
23
24
  private port: MessagePort | null = null;
@@ -110,8 +111,8 @@ class ConnBroker {
110
111
  if (!establish) return;
111
112
  this.establishing.add(peerId);
112
113
  try {
113
- const channel = await establish();
114
- this.locals.set(peerId, new TunnelClient(channel));
114
+ const { channel, bulk } = await establish();
115
+ this.locals.set(peerId, new TunnelClient(channel, bulk));
115
116
  this.post({ t: "ready", peerId });
116
117
  this.resolveReady(peerId);
117
118
  } catch (err) {