codehost 0.20.5 → 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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.21.0](https://github.com/snomiao/codehost/compare/v0.20.5...v0.21.0) (2026-06-13)
2
+
3
+
4
+ ### Features
5
+
6
+ * room roster, host approval/kick, `codehost open`; rename viewer→client ([f3a2a82](https://github.com/snomiao/codehost/commit/f3a2a8265aa8b1adf95ad764fb6d30d9561c6e3c))
7
+
1
8
  ## [0.20.5](https://github.com/snomiao/codehost/compare/v0.20.4...v0.20.5) (2026-06-12)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.20.5",
3
+ "version": "0.21.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,212 @@
1
+ import { createInterface, type Interface } from "node:readline";
2
+
3
+ /**
4
+ * Host-side admission policy for clients (connecting browsers — each gets a full
5
+ * VS Code session, so this is a real gate, not just UX).
6
+ * - "auto": admit everyone with the token (default; the token is the gate).
7
+ * - "confirm": hold each new client until the host approves it at the terminal,
8
+ * unless its label matches a pre-approved `--allow` pattern.
9
+ */
10
+ export type ApprovePolicy = "auto" | "confirm";
11
+
12
+ export interface ApproverOptions {
13
+ policy: ApprovePolicy;
14
+ /** Case-insensitive label substrings auto-approved even under "confirm". */
15
+ allow: string[];
16
+ /** Tear down a live client's connection (kick). */
17
+ kick: (clientId: string) => void;
18
+ /** Emit a "pending approval" hint to a client that's now waiting on a human. */
19
+ notifyPending: (clientId: string) => void;
20
+ /** Whether stdin is an interactive terminal (defaults to process.stdin.isTTY). */
21
+ isTTY?: boolean;
22
+ /** Injected input/output for tests; default to the real process streams. */
23
+ input?: NodeJS.ReadableStream;
24
+ output?: NodeJS.WritableStream;
25
+ log?: (msg: string) => void;
26
+ }
27
+
28
+ interface Pending {
29
+ clientId: string;
30
+ label: string;
31
+ resolve: (ok: boolean) => void;
32
+ }
33
+
34
+ /**
35
+ * Decides whether to admit clients and drives an interactive terminal console
36
+ * for approve / deny / kick / list. Self-contained (no WebRTC deps) so the
37
+ * daemon stays thin and this stays unit-testable.
38
+ */
39
+ export class Approver {
40
+ private opts: ApproverOptions;
41
+ private isTTY: boolean;
42
+ private out: NodeJS.WritableStream;
43
+ private log: (msg: string) => void;
44
+
45
+ /** Labels approved with "a(lways)" this session — auto-admit on sight. */
46
+ private sessionAllow = new Set<string>();
47
+ /** Live clients (admitted + connected), for `list` / `kick`. */
48
+ private active = new Map<string, string>();
49
+ /** Approvals waiting on the host; the head is the one being asked. */
50
+ private queue: Pending[] = [];
51
+ private asking = false;
52
+ private rl: Interface | null = null;
53
+
54
+ constructor(opts: ApproverOptions) {
55
+ this.opts = opts;
56
+ this.isTTY = opts.isTTY ?? Boolean(process.stdin.isTTY);
57
+ this.out = opts.output ?? process.stdout;
58
+ this.log = opts.log ?? ((m) => console.log(m));
59
+ }
60
+
61
+ /** Announce the active policy once at startup. */
62
+ banner(): void {
63
+ if (this.opts.policy === "auto") return;
64
+ if (this.isTTY) {
65
+ this.log("[codehost] approval: confirm — new clients wait for your OK. Commands: list · kick <n>");
66
+ } else {
67
+ this.log(
68
+ "[codehost] approval: confirm, but no terminal is attached — clients can't be approved " +
69
+ "interactively and will be denied. Use --approve auto or --allow <label> for a daemon.",
70
+ );
71
+ }
72
+ if (this.opts.allow.length) this.log(`[codehost] auto-approving labels matching: ${this.opts.allow.join(", ")}`);
73
+ }
74
+
75
+ /** Resolve true to admit `clientId`, false to deny. Called once per offer. */
76
+ admit(clientId: string, label: string): Promise<boolean> {
77
+ if (this.opts.policy === "auto" || this.preApproved(label)) return Promise.resolve(true);
78
+
79
+ // Confirm mode but no terminal to ask at: deny safely.
80
+ if (!this.isTTY) {
81
+ this.log(`[codehost] denied "${label}" (${short(clientId)}): confirm mode needs a terminal`);
82
+ return Promise.resolve(false);
83
+ }
84
+
85
+ this.opts.notifyPending(clientId);
86
+ return new Promise<boolean>((resolve) => {
87
+ this.queue.push({ clientId, label, resolve });
88
+ this.pumpQueue();
89
+ });
90
+ }
91
+
92
+ /** Mark a client live once its tunnel is bridged (for the console roster). */
93
+ onConnected(clientId: string, label: string): void {
94
+ this.active.set(clientId, label);
95
+ }
96
+
97
+ /** Drop a client from the roster and resolve any in-flight approval as denied. */
98
+ onDisconnected(clientId: string): void {
99
+ this.active.delete(clientId);
100
+ const idx = this.queue.findIndex((p) => p.clientId === clientId);
101
+ if (idx >= 0) {
102
+ const [p] = this.queue.splice(idx, 1);
103
+ p.resolve(false);
104
+ }
105
+ }
106
+
107
+ private preApproved(label: string): boolean {
108
+ if (this.sessionAllow.has(label)) return true;
109
+ const l = label.toLowerCase();
110
+ return this.opts.allow.some((p) => p && l.includes(p.toLowerCase()));
111
+ }
112
+
113
+ // ---- interactive console ----
114
+
115
+ /** Start the readline console (no-op unless confirm + TTY). */
116
+ start(): void {
117
+ if (this.opts.policy !== "confirm" || !this.isTTY || this.rl) return;
118
+ this.rl = createInterface({ input: this.opts.input ?? process.stdin, output: this.out });
119
+ this.rl.on("line", (line) => this.onLine(line.trim()));
120
+ }
121
+
122
+ stop(): void {
123
+ this.rl?.close();
124
+ this.rl = null;
125
+ }
126
+
127
+ private pumpQueue(): void {
128
+ if (this.asking || this.queue.length === 0) return;
129
+ this.asking = true;
130
+ const { label, clientId } = this.queue[0];
131
+ this.out.write(`\n[codehost] "${label}" (${short(clientId)}) wants to connect. Approve? [y/N/a=always] `);
132
+ }
133
+
134
+ private onLine(line: string): void {
135
+ if (this.asking) {
136
+ this.answer(line);
137
+ return;
138
+ }
139
+ this.command(line);
140
+ }
141
+
142
+ private answer(line: string): void {
143
+ const pending = this.queue.shift();
144
+ this.asking = false;
145
+ if (!pending) return;
146
+ const a = line.toLowerCase();
147
+ const always = a === "a" || a === "always";
148
+ const yes = always || a === "y" || a === "yes";
149
+ if (always) this.sessionAllow.add(pending.label);
150
+ this.log(`[codehost] ${yes ? "approved" : "denied"} "${pending.label}" (${short(pending.clientId)})`);
151
+ pending.resolve(yes);
152
+ this.pumpQueue();
153
+ }
154
+
155
+ private command(line: string): void {
156
+ if (!line) return;
157
+ const [cmd, ...rest] = line.split(/\s+/);
158
+ const arg = rest.join(" ");
159
+ switch (cmd) {
160
+ case "l":
161
+ case "ls":
162
+ case "list":
163
+ this.printList();
164
+ break;
165
+ case "k":
166
+ case "kick":
167
+ this.doKick(arg);
168
+ break;
169
+ case "h":
170
+ case "help":
171
+ case "?":
172
+ this.log("[codehost] commands: list · kick <n|id> · help");
173
+ break;
174
+ default:
175
+ this.log(`[codehost] unknown command "${cmd}" — try: list · kick <n|id>`);
176
+ }
177
+ }
178
+
179
+ private printList(): void {
180
+ const entries = [...this.active.entries()];
181
+ if (entries.length === 0) {
182
+ this.log("[codehost] no clients connected");
183
+ return;
184
+ }
185
+ this.log("[codehost] connected clients:");
186
+ entries.forEach(([id, label], i) => this.log(` ${i + 1}. ${label} (${short(id)})`));
187
+ }
188
+
189
+ private doKick(arg: string): void {
190
+ const id = this.resolveClient(arg);
191
+ if (!id) {
192
+ this.log(`[codehost] no connected client matches "${arg}" — see: list`);
193
+ return;
194
+ }
195
+ const label = this.active.get(id) ?? "client";
196
+ this.log(`[codehost] kicking "${label}" (${short(id)})`);
197
+ this.opts.kick(id);
198
+ }
199
+
200
+ /** Resolve a `kick` argument: 1-based index from `list`, or a peerId prefix. */
201
+ private resolveClient(arg: string): string | null {
202
+ if (!arg) return null;
203
+ const entries = [...this.active.keys()];
204
+ const n = Number(arg);
205
+ if (Number.isInteger(n) && n >= 1 && n <= entries.length) return entries[n - 1];
206
+ return entries.find((id) => id.startsWith(arg) || short(id) === arg) ?? null;
207
+ }
208
+ }
209
+
210
+ function short(id: string): string {
211
+ return id.slice(0, 8);
212
+ }
@@ -2,6 +2,7 @@ import { hostname } from "node:os";
2
2
  import { resolve } from "node:path";
3
3
  import type { CommandModule } from "yargs";
4
4
  import type { PeerMeta } from "../../shared/signaling";
5
+ import type { ApprovePolicy } from "../approver";
5
6
  import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
6
7
  import { ensureHostId } from "../config";
7
8
  import { launchServeDaemon } from "../daemonize";
@@ -21,6 +22,8 @@ interface DevArgs {
21
22
  daemon: boolean;
22
23
  standalone: boolean;
23
24
  port?: number;
25
+ approve: string;
26
+ allow: string[];
24
27
  }
25
28
 
26
29
  export const devCommand: CommandModule<{}, DevArgs> = {
@@ -63,6 +66,18 @@ export const devCommand: CommandModule<{}, DevArgs> = {
63
66
  .option("port", {
64
67
  describe: "Fixed port for the local VS Code server (default: ephemeral)",
65
68
  type: "number",
69
+ })
70
+ .option("approve", {
71
+ describe: "Client admission: 'auto' (anyone with the token) or 'confirm' (approve each at the terminal)",
72
+ type: "string",
73
+ choices: ["auto", "confirm"],
74
+ default: "auto",
75
+ })
76
+ .option("allow", {
77
+ describe: "Under --approve confirm, auto-approve clients whose label matches (repeatable)",
78
+ type: "string",
79
+ array: true,
80
+ default: [],
66
81
  }) as any,
67
82
  handler: async (argv) => {
68
83
  argv.token = argv.token.trim();
@@ -107,6 +122,8 @@ export const devCommand: CommandModule<{}, DevArgs> = {
107
122
  name: argv.name,
108
123
  port: argv.port,
109
124
  host,
125
+ approve: argv.approve,
126
+ allow: argv.allow,
110
127
  });
111
128
  if (ok) announceConnect(argv.token);
112
129
  process.exit(ok ? 0 : 1);
@@ -132,6 +149,8 @@ export const devCommand: CommandModule<{}, DevArgs> = {
132
149
  signal: argv.signal,
133
150
  meta,
134
151
  label: `serving ${dir}`,
152
+ approve: argv.approve as ApprovePolicy,
153
+ allow: argv.allow,
135
154
  launch: async (basePath) => {
136
155
  const v = await launchVscode({ dir, basePath, port: argv.port });
137
156
  return { port: v.port, stop: v.stop };
@@ -0,0 +1,95 @@
1
+ import { resolve } from "node:path";
2
+ import type { CommandModule } from "yargs";
3
+ import { readConfig } from "../config";
4
+ import { repoIdentity } from "../git";
5
+ import { GITHUB_HOST, gitUrlToPath, shareableDeepLink } from "../../shared/repo";
6
+ import { PAGE_URL, openBrowser } from "../open-url";
7
+
8
+ interface OpenArgs {
9
+ target?: string;
10
+ token?: string;
11
+ anon: boolean;
12
+ page: string;
13
+ print: boolean;
14
+ }
15
+
16
+ export const openCommand: CommandModule<{}, OpenArgs> = {
17
+ command: "open [target]",
18
+ describe: "Open a codehost deep link in the browser (defaults to the repo in the current directory)",
19
+ builder: (y) =>
20
+ y
21
+ .positional("target", {
22
+ describe:
23
+ "What to open: a repo (owner/repo, a git URL, or gh/git/host/dev path) or a full URL. Omit to use the git repo in the current directory.",
24
+ type: "string",
25
+ })
26
+ .option("token", {
27
+ alias: "t",
28
+ describe: "Token to embed for auto-connect (defaults to the saved room token)",
29
+ type: "string",
30
+ })
31
+ .option("anon", {
32
+ describe: "Open without embedding a token",
33
+ type: "boolean",
34
+ default: false,
35
+ })
36
+ .option("page", { describe: "Page URL to open against", type: "string", default: PAGE_URL })
37
+ .option("print", {
38
+ describe: "Print the URL instead of opening a browser",
39
+ type: "boolean",
40
+ default: false,
41
+ }) as any,
42
+ handler: async (argv) => {
43
+ const url = buildUrl(argv);
44
+ console.log(`[codehost] ${url}`);
45
+ if (argv.print) return;
46
+ openBrowser(url);
47
+ },
48
+ };
49
+
50
+ /**
51
+ * Resolve the URL to open. A full non-git URL is used verbatim; otherwise we
52
+ * build a codehost deep-link path (reusing the same helpers the page resolves
53
+ * with) and, unless `--anon`, append the room token in the `#t=<token>` fragment
54
+ * so the page auto-connects (the fragment stays in the browser). With no target
55
+ * we derive the path from the git repo in the current directory.
56
+ */
57
+ function buildUrl(argv: OpenArgs): string {
58
+ const target = argv.target?.trim();
59
+
60
+ // A full non-repo URL is opened as-is. (A repo URL like
61
+ // https://github.com/o/r is turned into a deep link below.)
62
+ if (target && /^https?:\/\//i.test(target) && !gitUrlToPath(target)) return target;
63
+
64
+ const path = target ? pathFromTarget(target) : pathFromCwd();
65
+ const base = `${argv.page.replace(/\/+$/, "")}${path}`;
66
+
67
+ const token = argv.anon ? undefined : (argv.token?.trim() || readConfig().token);
68
+ return token ? `${base}#t=${encodeURIComponent(token)}` : base;
69
+ }
70
+
71
+ /** Map a positional target to a deep-link path (always starts with `/`). */
72
+ function pathFromTarget(target: string): string {
73
+ const clean = target.replace(/^\/+/, "");
74
+ // Already a known deep-link shape — pass through.
75
+ if (/^(gh|git|host|dev)\//.test(clean)) return `/${clean}`;
76
+ // A git URL / scp / host/owner/repo (dotted host), incl. /tree/<branch>.
77
+ const fromUrl = gitUrlToPath(target);
78
+ if (fromUrl) return fromUrl;
79
+ // Bare `owner/repo[/tree/branch]` -> GitHub deep link.
80
+ const m = clean.match(/^([^/]+)\/([^/]+)(?:\/tree\/(.+))?$/);
81
+ if (m) return shareableDeepLink({ repo: `${GITHUB_HOST}/${m[1]}/${m[2]}`, branch: m[3] }) ?? "/";
82
+ // Anything else (e.g. a folder path) -> legacy host-agnostic folder mount.
83
+ return `/dev/${clean}`;
84
+ }
85
+
86
+ /** Derive a deep-link path from the git repo in the current directory. */
87
+ function pathFromCwd(): string {
88
+ const { repo, branch } = repoIdentity(resolve(process.cwd()));
89
+ const path = repo ? shareableDeepLink({ repo, branch }) : null;
90
+ if (!path) {
91
+ console.error("[codehost] no git repo found here; opening the codehost home page");
92
+ return "/";
93
+ }
94
+ return path;
95
+ }
@@ -3,6 +3,7 @@ import { homedir, hostname } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import type { CommandModule } from "yargs";
5
5
  import type { PeerMeta } from "../../shared/signaling";
6
+ import type { ApprovePolicy } from "../approver";
6
7
  import { DEFAULT_LAYOUT, GITHUB_HOST, toPosixPath } from "../../shared/repo";
7
8
  import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
8
9
  import { defaultRoot, ensureHostId } from "../config";
@@ -66,6 +67,8 @@ interface ServeArgs {
66
67
  signal: string;
67
68
  daemon: boolean;
68
69
  port?: number;
70
+ approve: string;
71
+ allow: string[];
69
72
  }
70
73
 
71
74
  export const serveCommand: CommandModule<{}, ServeArgs> = {
@@ -102,6 +105,18 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
102
105
  .option("port", {
103
106
  describe: "Fixed port for the local VS Code server (default: ephemeral)",
104
107
  type: "number",
108
+ })
109
+ .option("approve", {
110
+ describe: "Client admission: 'auto' (anyone with the token) or 'confirm' (approve each at the terminal)",
111
+ type: "string",
112
+ choices: ["auto", "confirm"],
113
+ default: "auto",
114
+ })
115
+ .option("allow", {
116
+ describe: "Under --approve confirm, auto-approve clients whose label matches (repeatable)",
117
+ type: "string",
118
+ array: true,
119
+ default: [],
105
120
  }) as any,
106
121
  handler: async (argv) => {
107
122
  argv.token = argv.token.trim();
@@ -135,6 +150,8 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
135
150
  name: argv.name,
136
151
  port: argv.port,
137
152
  host,
153
+ approve: argv.approve,
154
+ allow: argv.allow,
138
155
  });
139
156
  if (ok) announceConnect(argv.token);
140
157
  process.exit(ok ? 0 : 1);
@@ -208,6 +225,8 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
208
225
  metaRefreshMs: 3_000,
209
226
  watchFiles: [workspacesFile()],
210
227
  plugins,
228
+ approve: argv.approve as ApprovePolicy,
229
+ allow: argv.allow,
211
230
  label: `serving workspace root ${dir}`,
212
231
  provision: { homeDir: dir, host: GITHUB_HOST },
213
232
  launch: async (basePath) => {
@@ -19,6 +19,8 @@ interface SetupArgs {
19
19
  name?: string;
20
20
  signal: string;
21
21
  port?: number;
22
+ approve: string;
23
+ allow: string[];
22
24
  }
23
25
 
24
26
  export const setupCommand: CommandModule<{}, SetupArgs> = {
@@ -42,7 +44,19 @@ export const setupCommand: CommandModule<{}, SetupArgs> = {
42
44
  })
43
45
  .option("name", { describe: "Display name for this server (defaults to hostname)", type: "string" })
44
46
  .option("signal", { describe: "Signaling server URL", type: "string", default: DEFAULT_SIGNAL_URL })
45
- .option("port", { describe: "Fixed port for the local VS Code server", type: "number" }) as any,
47
+ .option("port", { describe: "Fixed port for the local VS Code server", type: "number" })
48
+ .option("approve", {
49
+ describe: "Client admission: 'auto' (anyone with the token) or 'confirm' (approve via --allow; the daemon has no terminal)",
50
+ type: "string",
51
+ choices: ["auto", "confirm"],
52
+ default: "auto",
53
+ })
54
+ .option("allow", {
55
+ describe: "Under --approve confirm, auto-approve clients whose label matches (repeatable)",
56
+ type: "string",
57
+ array: true,
58
+ default: [],
59
+ }) as any,
46
60
  handler: async (argv) => {
47
61
  // Explicit dir > a git cwd (serve THIS repo) > remembered root > ~/ws —
48
62
  // so a bare `codehost setup` (e.g. from the installer) lands on a sane
@@ -99,6 +113,8 @@ export const setupCommand: CommandModule<{}, SetupArgs> = {
99
113
  name: argv.name,
100
114
  port: argv.port,
101
115
  host,
116
+ approve: argv.approve,
117
+ allow: argv.allow,
102
118
  });
103
119
  if (!ok) process.exit(1);
104
120
 
@@ -20,6 +20,10 @@ export interface ServeDaemonOptions {
20
20
  port?: number;
21
21
  /** Hostname, used as a label fallback. */
22
22
  host: string;
23
+ /** Client admission policy ("auto" | "confirm"); propagated to the daemon. */
24
+ approve?: string;
25
+ /** Pre-approved client label patterns, propagated to the daemon. */
26
+ allow?: string[];
23
27
  }
24
28
 
25
29
  export interface ServeDaemonResult {
@@ -79,6 +83,10 @@ function buildForegroundArgv(opts: ServeDaemonOptions): string[] {
79
83
  // never collapse into register-with-the-host-daemon-and-exit (oxmgr would
80
84
  // see an instant exit and thrash).
81
85
  if ((opts.command ?? "serve") === "dev") parts.push("--standalone");
86
+ // A backgrounded daemon has no terminal, so "confirm" can only admit clients
87
+ // that match an --allow pattern; we still propagate it so that works.
88
+ if (opts.approve && opts.approve !== "auto") parts.push("--approve", opts.approve);
89
+ for (const pat of opts.allow ?? []) parts.push("--allow", pat);
82
90
  return parts;
83
91
  }
84
92
 
package/src/cli/index.ts CHANGED
@@ -6,6 +6,7 @@ import { initCommand } from "./commands/init";
6
6
  import { serveCommand } from "./commands/serve";
7
7
  import { devCommand } from "./commands/dev";
8
8
  import { exposeCommand } from "./commands/expose";
9
+ import { openCommand } from "./commands/open";
9
10
  import { listCommand } from "./commands/list";
10
11
  import { stopCommand } from "./commands/stop";
11
12
  import { updateCommand } from "./commands/update";
@@ -19,6 +20,7 @@ yargs(hideBin(process.argv))
19
20
  .command(serveCommand)
20
21
  .command(devCommand)
21
22
  .command(exposeCommand)
23
+ .command(openCommand)
22
24
  .command(listCommand)
23
25
  .command(stopCommand)
24
26
  .command(updateCommand)
@@ -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,19 @@ 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";
@@ -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. */
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
- import type { AgentInfo, PeerInfo, WorkspaceInfo } from "../shared/signaling";
2
+ import { type AgentInfo, type PeerInfo, type WorkspaceInfo, CLIENT_WIRE_ROLE, isClientRole } from "../shared/signaling";
3
3
  import { TOKEN_REQUIREMENTS, validateToken } from "../shared/token";
4
4
  import { SignalingClient } from "../shared/signaling-client";
5
5
  import type { RtcSignal } from "../shared/rtc";
@@ -26,11 +26,43 @@ import { deriveTags, matchQuery, shortRoomLabel, tagKey } from "../shared/tags";
26
26
 
27
27
  const TOKEN_KEY = "codehost.token";
28
28
 
29
- type ConnState = "idle" | "connecting" | "provisioning" | "connected" | "failed";
29
+ type ConnState = "idle" | "connecting" | "pending" | "provisioning" | "connected" | "failed" | "denied";
30
30
 
31
31
  /** A server discovered in a specific room (its token routes the signaling). */
32
32
  type RoomedServer = { server: PeerInfo; room: string };
33
33
 
34
+ /**
35
+ * A short "Browser · OS" label this page advertises in the room roster, so the
36
+ * host and other clients can tell devices apart (and spot a stranger that
37
+ * shouldn't have the token). Best-effort UA sniff; falls back to "browser".
38
+ */
39
+ function clientLabel(): string {
40
+ const ua = navigator.userAgent;
41
+ const browser = /Edg\//.test(ua) ? "Edge"
42
+ : /OPR\//.test(ua) ? "Opera"
43
+ : /Firefox\//.test(ua) ? "Firefox"
44
+ : /Chrome\//.test(ua) ? "Chrome"
45
+ : /Safari\//.test(ua) ? "Safari"
46
+ : "browser";
47
+ const os = /Mac OS X/.test(ua) ? "macOS"
48
+ : /Windows/.test(ua) ? "Windows"
49
+ : /Android/.test(ua) ? "Android"
50
+ : /(iPhone|iPad|iPod)/.test(ua) ? "iOS"
51
+ : /Linux/.test(ua) ? "Linux"
52
+ : "";
53
+ return os ? `${browser} · ${os}` : browser;
54
+ }
55
+
56
+ /** Coarse "Ns/Nm/Nh" from a worker-clock join time and the room's clock. */
57
+ function relTime(since?: number, now?: number): string | null {
58
+ if (!since || !now) return null;
59
+ const secs = Math.max(0, Math.round((now - since) / 1000));
60
+ if (secs < 60) return `${secs}s`;
61
+ const mins = Math.round(secs / 60);
62
+ if (mins < 60) return `${mins}m`;
63
+ return `${Math.round(mins / 60)}h`;
64
+ }
65
+
34
66
  /**
35
67
  * Read a room token handed in the URL fragment as `#t=<token>` (what the CLI
36
68
  * prints/opens after `setup`/`serve`). The page is static, so the fragment
@@ -98,7 +130,7 @@ function findRoomForDeepLink(dl: DeepLink, tokens: string[], timeoutMs = 6000):
98
130
  const client = new SignalingClient({
99
131
  url: getSignalUrl(),
100
132
  token: tok,
101
- role: "viewer",
133
+ role: CLIENT_WIRE_ROLE,
102
134
  onPeers: (peers) => {
103
135
  const servers = peers.filter((p) => p.role === "server");
104
136
  const res =
@@ -124,7 +156,9 @@ function findRoomForDeepLink(dl: DeepLink, tokens: string[], timeoutMs = 6000):
124
156
  */
125
157
  function RoomClient(props: {
126
158
  token: string;
159
+ label: string;
127
160
  onPeers: (peers: PeerInfo[]) => void;
161
+ onRoster: (clients: PeerInfo[], now?: number) => void;
128
162
  onStatus: (open: boolean) => void;
129
163
  onSignal: (from: string, data: unknown) => void;
130
164
  registerSender: (send: ((to: string, data: unknown) => void) | null) => void;
@@ -133,15 +167,23 @@ function RoomClient(props: {
133
167
  // not on every parent re-render (which would needlessly churn the WebSocket).
134
168
  const cb = useRef(props);
135
169
  cb.current = props;
136
- const { token } = props;
170
+ const { token, label } = props;
137
171
  useEffect(() => {
138
172
  const client = new SignalingClient({
139
173
  url: getSignalUrl(),
140
174
  token,
141
- role: "viewer",
175
+ // Connecting role. CLIENT_WIRE_ROLE is still the legacy "viewer" during the
176
+ // accept-both transition; servers match it via isClientRole either way.
177
+ role: CLIENT_WIRE_ROLE,
178
+ // Advertise a label so this tab shows up named in the room roster.
179
+ meta: { name: label },
142
180
  onOpen: () => cb.current.onStatus(true),
143
181
  onClose: () => cb.current.onStatus(false),
144
- onPeers: (peers) => cb.current.onPeers(peers.filter((p) => p.role === "server")),
182
+ onPeers: (peers, now) => {
183
+ cb.current.onPeers(peers.filter((p) => p.role === "server"));
184
+ // Other clients in the room (not us) — surfaced as the roster.
185
+ cb.current.onRoster(peers.filter((p) => isClientRole(p.role) && p.peerId !== client.peerId), now);
186
+ },
145
187
  onSignal: (from, data) => cb.current.onSignal(from, data),
146
188
  });
147
189
  cb.current.registerSender((to, data) => client.sendSignal(to, data));
@@ -170,6 +212,12 @@ export function Discovery() {
170
212
  // Per-room discovery state, merged into one workspace list below.
171
213
  const [serversByRoom, setServersByRoom] = useState<Record<string, PeerInfo[]>>({});
172
214
  const [roomOpen, setRoomOpen] = useState<Record<string, boolean>>({});
215
+ // Other clients (browsers) per room, for the "In this room" roster, plus the
216
+ // worker clock from the latest peers message for relative join times.
217
+ const [clientsByRoom, setClientsByRoom] = useState<Record<string, PeerInfo[]>>({});
218
+ const [roomNow, setRoomNow] = useState<number | undefined>(undefined);
219
+ // This tab's roster label, computed once.
220
+ const labelRef = useRef(clientLabel());
173
221
 
174
222
  // Token input = "join another room": validated, then appended to the set.
175
223
  // Never pre-filled with a saved token — it's a bearer secret.
@@ -204,6 +252,12 @@ export function Discovery() {
204
252
  const activePeerRef = useRef<string | null>(null);
205
253
  const activeRoomRef = useRef<string | null>(null);
206
254
  const sendersRef = useRef<Map<string, (to: string, data: unknown) => void>>(new Map());
255
+ // Admission control: the host can hold ("pending") or reject ("denied") us.
256
+ // `deniedRef` stops a trailing pc state change from overwriting the denied UI;
257
+ // the timer/reject refs let a control signal extend or abort the dial attempt.
258
+ const deniedRef = useRef(false);
259
+ const dialTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
260
+ const dialRejectRef = useRef<((e: Error) => void) | null>(null);
207
261
  // Whether the live connection pushed a history entry (so Disconnect/Back can
208
262
  // pop it back to the list).
209
263
  const pushedRef = useRef(false);
@@ -387,6 +441,7 @@ export function Discovery() {
387
441
  const send = sendersRef.current.get(room);
388
442
  if (!send) return;
389
443
  dialingRef.current = true; // synchronous gate against concurrent triggers
444
+ deniedRef.current = false;
390
445
  let didPush = false;
391
446
  try {
392
447
  // Clear any prior connection's broker state first: after an RTC drop the
@@ -435,23 +490,25 @@ export function Discovery() {
435
490
  const rtc = new RtcClient({
436
491
  sendSignal: (data: RtcSignal) => send(server.peerId, data),
437
492
  onState: (state) => {
438
- if (state === "failed" || state === "disconnected") setConnState("failed");
493
+ if ((state === "failed" || state === "disconnected") && !deniedRef.current) setConnState("failed");
439
494
  },
440
495
  onOpen: (channel) => {
441
- clearTimeout(timer);
496
+ if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
442
497
  resolve({ channel, bulk: rtc.bulkChannel });
443
498
  },
444
499
  onClose: () => setConnState((s) => (s === "connected" ? "idle" : s)),
445
500
  });
446
501
  rtcRef.current = rtc;
502
+ dialRejectRef.current = reject;
447
503
  // Don't hang forever dialing a peer that never answers (e.g. a stale
448
- // server still listed in the room): fail the attempt after 15s.
449
- const timer = setTimeout(() => {
504
+ // server still listed in the room): fail the attempt after 15s. A
505
+ // "pending" admission signal swaps this for a longer approval window.
506
+ dialTimerRef.current = setTimeout(() => {
450
507
  rtc.close();
451
508
  reject(new Error("connection timed out"));
452
509
  }, 15000);
453
510
  rtc.start().catch((err) => {
454
- clearTimeout(timer);
511
+ if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
455
512
  reject(err);
456
513
  });
457
514
  });
@@ -495,7 +552,7 @@ export function Discovery() {
495
552
  setResolving(null);
496
553
  recordConnect(server, room, openFolder);
497
554
  } catch {
498
- setConnState("failed");
555
+ setConnState(deniedRef.current ? "denied" : "failed");
499
556
  // Undo the optimistic history entry we pushed. revertingRef makes the
500
557
  // resulting popstate a no-op so the "failed" card stays on the list.
501
558
  if (didPush) {
@@ -717,6 +774,12 @@ export function Discovery() {
717
774
  const onlineRooms = tokens.filter((t) => roomOpen[t]).length;
718
775
  const activeServer = allServers.find((x) => x.server.peerId === activePeerId)?.server;
719
776
 
777
+ // Other clients (browsers) across all joined rooms, deduped by peerId — the
778
+ // "In this room" roster, so you can spot a device that shouldn't hold a token.
779
+ const otherClients = Object.values(clientsByRoom)
780
+ .flat()
781
+ .filter((c, i, all) => all.findIndex((x) => x.peerId === c.peerId) === i);
782
+
720
783
  // Annotate each server with its mnemonic fake-tags (incl. its room label), then
721
784
  // filter. The room token is hashed to a short label — never rendered raw.
722
785
  const tagged = allServers.map(({ server: s, room }) => ({
@@ -764,10 +827,39 @@ export function Discovery() {
764
827
  <RoomClient
765
828
  key={t}
766
829
  token={t}
830
+ label={labelRef.current}
767
831
  onPeers={(peers) => setServersByRoom((m) => ({ ...m, [t]: peers }))}
832
+ onRoster={(clients, now) => {
833
+ setClientsByRoom((m) => ({ ...m, [t]: clients }));
834
+ if (now) setRoomNow(now);
835
+ }}
768
836
  onStatus={(open) => setRoomOpen((m) => ({ ...m, [t]: open }))}
769
837
  onSignal={(from, data) => {
770
- if (from === activePeerRef.current) void rtcRef.current?.handleSignal(data);
838
+ if (from !== activePeerRef.current) return;
839
+ const kind = (data as { kind?: string } | null)?.kind;
840
+ if (kind === "pending") {
841
+ // Host is reviewing us — show "waiting" and stop the short dial timer
842
+ // from failing the attempt while a human decides.
843
+ setConnState("pending");
844
+ if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
845
+ dialTimerRef.current = setTimeout(() => {
846
+ rtcRef.current?.close();
847
+ dialRejectRef.current?.(new Error("approval timed out"));
848
+ }, 120000);
849
+ return;
850
+ }
851
+ if (kind === "denied") {
852
+ // Denied while dialing, or kicked after connecting — set state directly
853
+ // so it covers both (the dial promise may already be settled).
854
+ deniedRef.current = true;
855
+ if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
856
+ rtcRef.current?.close();
857
+ setIframeSrc(null);
858
+ setConnState("denied");
859
+ dialRejectRef.current?.(new Error("host denied the connection"));
860
+ return;
861
+ }
862
+ void rtcRef.current?.handleSignal(data);
771
863
  }}
772
864
  registerSender={(send) => {
773
865
  if (send) sendersRef.current.set(t, send);
@@ -1078,18 +1170,20 @@ export function Discovery() {
1078
1170
  )}
1079
1171
  <div style={styles.idLine}>peer {s.peerId.slice(0, 8)}</div>
1080
1172
  {isActive && (
1081
- <div style={styles.echo}>
1173
+ <div style={connState === "denied" ? styles.echoBad : styles.echo}>
1082
1174
  {connState === "connecting" && "negotiating WebRTC…"}
1175
+ {connState === "pending" && "waiting for the host to approve you…"}
1083
1176
  {connState === "failed" && "connection failed"}
1177
+ {connState === "denied" && "the host denied this connection"}
1084
1178
  </div>
1085
1179
  )}
1086
1180
  </div>
1087
1181
  <button
1088
1182
  style={styles.connectBtn}
1089
1183
  onClick={() => connectTo(s, room)}
1090
- disabled={isActive && connState === "connecting"}
1184
+ disabled={isActive && (connState === "connecting" || connState === "pending")}
1091
1185
  >
1092
- {isActive && connState === "connecting" ? "…" : "Connect"}
1186
+ {isActive && (connState === "connecting" || connState === "pending") ? "…" : "Connect"}
1093
1187
  </button>
1094
1188
  </li>
1095
1189
  );
@@ -1101,6 +1195,36 @@ export function Discovery() {
1101
1195
  <p style={styles.dim}>No workspace matches your filter.</p>
1102
1196
  )}
1103
1197
  </div>
1198
+
1199
+ {tokens.length > 0 && (
1200
+ <section style={styles.rosterSection}>
1201
+ <div style={styles.rosterHead}>
1202
+ In this room · you{otherClients.length > 0 ? ` + ${otherClients.length} other ${otherClients.length === 1 ? "client" : "clients"}` : ""}
1203
+ </div>
1204
+ <ul style={styles.list}>
1205
+ <li style={styles.personRow}>
1206
+ <span style={styles.personDot}>●</span>
1207
+ <span style={styles.personName}>{labelRef.current}</span>
1208
+ <span style={styles.dim}>you</span>
1209
+ </li>
1210
+ {otherClients.map((c) => {
1211
+ const age = relTime(c.since, roomNow);
1212
+ return (
1213
+ <li key={c.peerId} style={styles.personRow}>
1214
+ <span style={{ ...styles.personDot, color: "#dcb67a" }}>●</span>
1215
+ <span style={styles.personName}>{c.meta?.name ?? c.peerId.slice(0, 8)}</span>
1216
+ {age && <span style={styles.dim}>connected {age} ago</span>}
1217
+ </li>
1218
+ );
1219
+ })}
1220
+ </ul>
1221
+ <p style={styles.rosterHint}>
1222
+ Anyone holding a room's token can appear here and gets a full VS Code session
1223
+ (terminal + file write). See a device you don't recognize? Rotate the token with{" "}
1224
+ <code style={styles.code}>codehost setup --new-token</code>.
1225
+ </p>
1226
+ </section>
1227
+ )}
1104
1228
  </main>
1105
1229
  </div>
1106
1230
  </>
@@ -1185,6 +1309,13 @@ const styles: Record<string, React.CSSProperties> = {
1185
1309
  cardSub: { display: "flex", gap: 12, fontSize: 12, color: "#888", marginTop: 2 },
1186
1310
  cwd: { fontFamily: "monospace" },
1187
1311
  echo: { marginTop: 6, fontSize: 12, color: "#4ec9b0", fontFamily: "monospace" },
1312
+ echoBad: { marginTop: 6, fontSize: 12, color: "#f48771", fontFamily: "monospace" },
1313
+ rosterSection: { marginTop: 28 },
1314
+ rosterHead: { fontSize: 14, color: "#aaa", fontWeight: 600, margin: "0 0 12px" },
1315
+ rosterHint: { margin: "10px 0 0", fontSize: 12, color: "#888" },
1316
+ personRow: { display: "flex", alignItems: "center", gap: 10, background: "#252525", border: "1px solid #3d3d3d", borderRadius: 8, padding: "8px 14px", fontSize: 13 },
1317
+ personDot: { color: "#4ec9b0", fontSize: 10 },
1318
+ personName: { color: "#eee", flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
1188
1319
  connectBtn: { background: "#0e639c", border: "none", color: "#fff", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
1189
1320
  shareBtn: { background: "transparent", border: "1px solid #3d3d3d", color: "#ccc", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
1190
1321
  provLog: { flex: 1, margin: 0, padding: "14px 18px", overflow: "auto", background: "#1e1e1e", color: "#ccc", fontFamily: "monospace", fontSize: 12.5, lineHeight: 1.5, whiteSpace: "pre-wrap" },
@@ -1,5 +1,5 @@
1
1
  import { SignalingClient } from "../shared/signaling-client";
2
- import type { PeerInfo } from "../shared/signaling";
2
+ import { type PeerInfo, CLIENT_WIRE_ROLE } from "../shared/signaling";
3
3
  import type { RtcSignal } from "../shared/rtc";
4
4
  import { RtcClient } from "./rtc-client";
5
5
  import { TunnelClient } from "./tunnel-client";
@@ -44,7 +44,7 @@ export class CodehostRoom {
44
44
  this.signaling = new SignalingClient({
45
45
  url: opts.signalUrl ?? DEFAULT_SIGNAL_URL,
46
46
  token: opts.token,
47
- role: "viewer",
47
+ role: CLIENT_WIRE_ROLE,
48
48
  onOpen: () => opts.onStatus?.(true),
49
49
  onClose: () => opts.onStatus?.(false),
50
50
  onPeers: (peers) => {
package/worker/room.ts CHANGED
@@ -10,6 +10,8 @@ interface Attachment {
10
10
  peerId: string;
11
11
  role: Role;
12
12
  meta: PeerMeta | null;
13
+ /** Wall-clock ms when this socket joined (sent `hello`); for the room roster. */
14
+ since: number;
13
15
  /** Wall-clock ms of the last message from this socket (hello / ping / signal). */
14
16
  lastSeen: number;
15
17
  }
@@ -57,11 +59,13 @@ export class Room implements DurableObject {
57
59
  }
58
60
 
59
61
  if (msg.type === "hello") {
62
+ const now = Date.now();
60
63
  const att: Attachment = {
61
64
  peerId: msg.peerId,
62
65
  role: msg.role,
63
66
  meta: msg.meta ?? null,
64
- lastSeen: Date.now(),
67
+ since: now,
68
+ lastSeen: now,
65
69
  };
66
70
  ws.serializeAttachment(att);
67
71
  this.send(ws, { type: "welcome", peerId: msg.peerId });
@@ -165,13 +169,13 @@ export class Room implements DurableObject {
165
169
  const peers: PeerInfo[] = [];
166
170
  for (const ws of this.state.getWebSockets()) {
167
171
  const att = this.attachment(ws);
168
- if (att) peers.push({ peerId: att.peerId, role: att.role, meta: att.meta });
172
+ if (att) peers.push({ peerId: att.peerId, role: att.role, meta: att.meta, since: att.since });
169
173
  }
170
174
  return peers;
171
175
  }
172
176
 
173
177
  private broadcastPeers(): void {
174
- const message: ServerMessage = { type: "peers", peers: this.peerList() };
178
+ const message: ServerMessage = { type: "peers", peers: this.peerList(), now: Date.now() };
175
179
  const payload = JSON.stringify(message);
176
180
  for (const ws of this.state.getWebSockets()) {
177
181
  try {