codehost 0.15.0 → 0.17.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/src/cli/tunnel.ts CHANGED
@@ -8,7 +8,6 @@ import {
8
8
  encodeFrame,
9
9
  encodeJson,
10
10
  payloadJson,
11
- payloadText,
12
11
  wsMessageFrames,
13
12
  } from "../shared/protocol";
14
13
 
@@ -33,6 +32,19 @@ interface HttpStream {
33
32
  body: Uint8Array[];
34
33
  }
35
34
 
35
+ /** A tunneled request offered to the daemon's local routes before proxying. */
36
+ export interface LocalRequest {
37
+ method: string;
38
+ /** Raw tunneled path incl. query (no /vs/<peerId> stripping applied). */
39
+ path: string;
40
+ headers: Headers;
41
+ body?: Uint8Array;
42
+ }
43
+
44
+ /** Serve `/__codehost/*` requests in-daemon (provisioning, plugins). Return
45
+ * null/undefined to fall through to the local-server proxy. */
46
+ export type LocalHandler = (req: LocalRequest) => Promise<Response> | null | undefined;
47
+
36
48
  /**
37
49
  * Bridges one WebRTC data channel to a local `code serve-web` instance.
38
50
  * Multiplexes concurrent HTTP requests and WebSocket connections by streamId.
@@ -54,6 +66,9 @@ export class Tunnel {
54
66
  * doesn't know it, so we strip `/vs/<peerId>` before proxying.
55
67
  */
56
68
  private stripPrefix?: string,
69
+ /** Serves `/__codehost/*` requests locally (provisioning, plugins) instead
70
+ * of forwarding to the local server. Wired only for `serve` (not `expose`). */
71
+ private onLocal?: LocalHandler,
57
72
  ) {
58
73
  this.origin = `http://127.0.0.1:${vscodePort}`;
59
74
  this.wsOrigin = `ws://127.0.0.1:${vscodePort}`;
@@ -134,12 +149,15 @@ export class Tunnel {
134
149
  const body = hasBody ? concat(stream.body) : undefined;
135
150
 
136
151
  try {
137
- const res = await fetch(this.origin + this.localPath(path), {
138
- method,
139
- headers: reqHeaders,
140
- body: body as BodyInit | undefined,
141
- redirect: "manual",
142
- });
152
+ const local = this.onLocal?.({ method, path, headers: reqHeaders, body });
153
+ const res = local
154
+ ? await local
155
+ : await fetch(this.origin + this.localPath(path), {
156
+ method,
157
+ headers: reqHeaders,
158
+ body: body as BodyInit | undefined,
159
+ redirect: "manual",
160
+ });
143
161
 
144
162
  const resHeaders: Record<string, string> = {};
145
163
  res.headers.forEach((v, k) => {
@@ -49,10 +49,17 @@ export async function resolveCodeBinary(opts: { force?: boolean } = {}): Promise
49
49
  return override;
50
50
  }
51
51
 
52
- // Prefer a user-owned `code` on PATH — but only if it actually runs. A broken
53
- // or stub `code` should fall through to a managed install/upgrade.
52
+ // Prefer a user-owned `code` on PATH — but only if it actually runs AND can
53
+ // `serve-web`. The desktop app's bin/code wrapper passes --version yet treats
54
+ // `serve-web` as a file path ("Ignoring option 'host': not supported") and
55
+ // exits 0 without ever listening — so probe the subcommand, not just the
56
+ // binary (observed on macOS VS Code 1.123: serve started, then "VS Code
57
+ // server exited (code 0) before becoming ready").
54
58
  const system = Bun.which("code");
55
- if (system && runsOk(system)) return system;
59
+ if (system && runsOk(system)) {
60
+ if (supportsServeWeb(system)) return system;
61
+ console.warn(`[codehost] system code (${system}) can't serve-web — using a managed VS Code CLI instead`);
62
+ }
56
63
 
57
64
  return ensureManagedBinary(opts.force ?? false);
58
65
  }
@@ -119,6 +126,16 @@ function runsOk(bin: string): boolean {
119
126
  return r.status === 0;
120
127
  }
121
128
 
129
+ /** True if `<bin> serve-web --help` is a real subcommand: clean exit, no
130
+ * desktop-wrapper "Ignoring option" complaints, and help text that actually
131
+ * mentions serve-web. (Run with --help so nothing starts listening.) */
132
+ function supportsServeWeb(bin: string): boolean {
133
+ const r = spawnSync(bin, ["serve-web", "--help"], { encoding: "utf8", timeout: 10_000 });
134
+ if (r.status !== 0) return false;
135
+ const out = `${r.stdout ?? ""}${r.stderr ?? ""}`;
136
+ return !/ignoring option/i.test(out) && /serve-web/i.test(out);
137
+ }
138
+
122
139
  function isMusl(): boolean {
123
140
  if (existsSync("/etc/alpine-release")) return true;
124
141
  // `ldd --version` mentions "musl" on musl systems; ignore failures.
@@ -0,0 +1,65 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DEFAULT_LAYOUT } from "../shared/repo";
6
+ import { enumerateWorkspaces } from "./workspaces";
7
+
8
+ function makeRoot(): string {
9
+ return mkdtempSync(join(tmpdir(), "codehost-ws-"));
10
+ }
11
+
12
+ /** Create a checkout dir; .git is a dir by default, a file when `worktree`. */
13
+ function checkout(root: string, rel: string, worktree = false): void {
14
+ const dir = join(root, rel);
15
+ mkdirSync(dir, { recursive: true });
16
+ if (worktree) writeFileSync(join(dir, ".git"), "gitdir: elsewhere");
17
+ else mkdirSync(join(dir, ".git"));
18
+ }
19
+
20
+ describe("enumerateWorkspaces", () => {
21
+ test("walks the default layout and reports repo identity + branch", () => {
22
+ const root = makeRoot();
23
+ checkout(root, "snomiao/codehost/tree/main");
24
+ checkout(root, "symval/symval/tree/dev", true); // worktree-style .git file
25
+ mkdirSync(join(root, "snomiao/empty/tree/main"), { recursive: true }); // no .git
26
+
27
+ const found = enumerateWorkspaces(root, DEFAULT_LAYOUT);
28
+ expect(found).toHaveLength(2);
29
+ expect(found).toContainEqual({
30
+ path: join(root, "snomiao/codehost/tree/main"),
31
+ repo: "github.com/snomiao/codehost",
32
+ branch: "main",
33
+ });
34
+ expect(found).toContainEqual({
35
+ path: join(root, "symval/symval/tree/dev"),
36
+ repo: "github.com/symval/symval",
37
+ branch: "dev",
38
+ });
39
+ });
40
+
41
+ test("literal segments must exist; placeholders match one level", () => {
42
+ const root = makeRoot();
43
+ checkout(root, "ws/snomiao/codehost"); // layout ws/{owner}/{repo}
44
+ checkout(root, "other/snomiao/codehost"); // doesn't match the literal "ws"
45
+
46
+ const found = enumerateWorkspaces(root, "ws/{owner}/{repo}");
47
+ expect(found).toHaveLength(1);
48
+ expect(found[0].repo).toBe("github.com/snomiao/codehost");
49
+ expect(found[0].branch).toBeUndefined();
50
+ });
51
+
52
+ test("skips dot-directories and tolerates a missing root", () => {
53
+ const root = makeRoot();
54
+ checkout(root, ".hidden/codehost/tree/main");
55
+ expect(enumerateWorkspaces(root, DEFAULT_LAYOUT)).toHaveLength(0);
56
+ expect(enumerateWorkspaces(join(root, "nope"), DEFAULT_LAYOUT)).toHaveLength(0);
57
+ });
58
+
59
+ test("uses the given git host in repo identity", () => {
60
+ const root = makeRoot();
61
+ checkout(root, "group/proj/tree/main");
62
+ const found = enumerateWorkspaces(root, DEFAULT_LAYOUT, "gitlab.com");
63
+ expect(found[0].repo).toBe("gitlab.com/group/proj");
64
+ });
65
+ });
@@ -0,0 +1,80 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { GITHUB_HOST, toPosixPath } from "../shared/repo";
4
+ import type { WorkspaceInfo } from "../shared/signaling";
5
+
6
+ // Enumerate the checkouts that exist under a root daemon's home by walking the
7
+ // workspace layout template (e.g. "{owner}/{repo}/tree/{branch}") one segment
8
+ // at a time: a literal segment must exist, a placeholder segment matches one
9
+ // directory level. A leaf only counts as a workspace when it holds a `.git`
10
+ // (directory, or file for a worktree). Feeds PeerMeta.workspaces.
11
+
12
+ /** Cap the advertised list (meta rides the signaling room broadcast). */
13
+ const MAX_WORKSPACES = 200;
14
+ /** Cap the intermediate walk so a huge home dir can't blow up enumeration. */
15
+ const MAX_FRONTIER = 1000;
16
+
17
+ export function enumerateWorkspaces(rootDir: string, layout: string, host = GITHUB_HOST): WorkspaceInfo[] {
18
+ let frontier = [{ dir: rootDir, vars: {} as Record<string, string> }];
19
+ for (const seg of layout.split("/").filter(Boolean)) {
20
+ const names = [...seg.matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
21
+ const next: typeof frontier = [];
22
+ for (const f of frontier) {
23
+ if (next.length >= MAX_FRONTIER) break;
24
+ if (names.length === 0) {
25
+ const dir = join(f.dir, seg);
26
+ if (isDir(dir)) next.push({ dir, vars: f.vars });
27
+ continue;
28
+ }
29
+ const re = segmentPattern(seg);
30
+ for (const name of listDirs(f.dir)) {
31
+ if (next.length >= MAX_FRONTIER) break;
32
+ if (name.startsWith(".")) continue;
33
+ const m = re.exec(name);
34
+ if (!m) continue;
35
+ const vars = { ...f.vars };
36
+ names.forEach((n, i) => (vars[n] = m[i + 1]));
37
+ next.push({ dir: join(f.dir, name), vars });
38
+ }
39
+ }
40
+ frontier = next;
41
+ if (frontier.length === 0) return [];
42
+ }
43
+
44
+ const out: WorkspaceInfo[] = [];
45
+ for (const f of frontier) {
46
+ if (out.length >= MAX_WORKSPACES) break;
47
+ if (!existsSync(join(f.dir, ".git"))) continue;
48
+ const { owner, repo, branch } = f.vars;
49
+ out.push({
50
+ path: toPosixPath(f.dir),
51
+ ...(owner && repo ? { repo: `${host}/${owner}/${repo}` } : {}),
52
+ ...(branch ? { branch } : {}),
53
+ });
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /** A layout segment as a matcher: literals exact, `{name}`s capture. */
59
+ function segmentPattern(seg: string): RegExp {
60
+ const escaped = seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
61
+ return new RegExp(`^${escaped.replace(/\\\{\w+\\\}/g, "([^/]+)")}$`);
62
+ }
63
+
64
+ function isDir(p: string): boolean {
65
+ try {
66
+ return statSync(p).isDirectory();
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ function listDirs(p: string): string[] {
73
+ try {
74
+ return readdirSync(p, { withFileTypes: true })
75
+ .filter((e) => e.isDirectory() || e.isSymbolicLink())
76
+ .map((e) => e.name);
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { repoAllowed, resolveWorkspacePath, validateProvisionTarget } from "./provision";
3
+
4
+ describe("validateProvisionTarget — the injection boundary", () => {
5
+ test("accepts normal identities", () => {
6
+ const r = validateProvisionTarget("snomiao", "codehost", "main");
7
+ expect(r.ok).toBe(true);
8
+ if (r.ok) expect(r.target).toEqual({ owner: "snomiao", repo: "codehost", branch: "main" });
9
+ });
10
+
11
+ test("accepts branch with slashes and hyphens", () => {
12
+ const r = validateProvisionTarget("snomiao", "codehost", "feat/some-thing");
13
+ expect(r.ok).toBe(true);
14
+ });
15
+
16
+ test("empty branch defaults to main", () => {
17
+ const r = validateProvisionTarget("snomiao", "codehost", "");
18
+ expect(r.ok && r.target.branch).toBe("main");
19
+ });
20
+
21
+ // The whole point: these must NOT pass.
22
+ test("rejects path traversal in owner/repo", () => {
23
+ expect(validateProvisionTarget("..", "codehost", "main").ok).toBe(false);
24
+ expect(validateProvisionTarget(".", "codehost", "main").ok).toBe(false);
25
+ expect(validateProvisionTarget("snomiao", "..", "main").ok).toBe(false);
26
+ expect(validateProvisionTarget("a/b", "codehost", "main").ok).toBe(false); // slash
27
+ expect(validateProvisionTarget(".ssh", "codehost", "main").ok).toBe(false); // leading dot
28
+ });
29
+
30
+ test("rejects traversal / leading-dash / junk in branch", () => {
31
+ expect(validateProvisionTarget("o", "r", "a/../../etc").ok).toBe(false);
32
+ expect(validateProvisionTarget("o", "r", "..").ok).toBe(false);
33
+ expect(validateProvisionTarget("o", "r", "-x").ok).toBe(false); // option injection
34
+ expect(validateProvisionTarget("o", "r", "feat/-x").ok).toBe(false);
35
+ expect(validateProvisionTarget("o", "r", "a b").ok).toBe(false); // whitespace
36
+ expect(validateProvisionTarget("o", "r", "a;rm -rf").ok).toBe(false); // shell meta
37
+ expect(validateProvisionTarget("o", "r", "a$(id)").ok).toBe(false);
38
+ expect(validateProvisionTarget("o", "r", "a`id`").ok).toBe(false);
39
+ });
40
+ });
41
+
42
+ describe("resolveWorkspacePath — daemon-authoritative, cannot escape home", () => {
43
+ test("fills the default layout under home", () => {
44
+ const t = { owner: "snomiao", repo: "codehost", branch: "main" };
45
+ expect(resolveWorkspacePath("/Users/sno/ws", "", t)).toBe("/Users/sno/ws/snomiao/codehost/tree/main");
46
+ });
47
+
48
+ test("honors a custom layout template (e.g. config.yaml ws/ home model)", () => {
49
+ const t = { owner: "snomiao", repo: "codehost", branch: "feat/x" };
50
+ expect(resolveWorkspacePath("/home/me", "ws/{owner}/{repo}/tree/{branch}", t)).toBe(
51
+ "/home/me/ws/snomiao/codehost/tree/feat/x",
52
+ );
53
+ });
54
+
55
+ test("a validated target can never produce a path above home", () => {
56
+ // Only validated targets reach here; confirm the segments are inert.
57
+ const v = validateProvisionTarget("snomiao", "codehost", "main");
58
+ expect(v.ok).toBe(true);
59
+ if (v.ok) {
60
+ const p = resolveWorkspacePath("/Users/sno/ws", "{owner}/{repo}/tree/{branch}", v.target);
61
+ expect(p.startsWith("/Users/sno/ws/")).toBe(true);
62
+ expect(p.includes("/../")).toBe(false);
63
+ }
64
+ });
65
+ });
66
+
67
+ describe("repoAllowed", () => {
68
+ test("empty/absent allowlist allows all", () => {
69
+ expect(repoAllowed("github.com/x/y", undefined)).toBe(true);
70
+ expect(repoAllowed("github.com/x/y", [])).toBe(true);
71
+ });
72
+
73
+ test("exact + owner wildcard", () => {
74
+ expect(repoAllowed("github.com/snomiao/codehost", ["github.com/snomiao/codehost"])).toBe(true);
75
+ expect(repoAllowed("github.com/snomiao/codehost", ["github.com/snomiao/*"])).toBe(true);
76
+ expect(repoAllowed("github.com/evil/repo", ["github.com/snomiao/*"])).toBe(false);
77
+ expect(repoAllowed("gitlab.com/snomiao/x", ["github.com/snomiao/*"])).toBe(false);
78
+ });
79
+ });
@@ -0,0 +1,67 @@
1
+ // Pure provisioning helpers: the security boundary for "open a repo link runs a
2
+ // host setup script". The room token already grants code execution (the editor
3
+ // is a terminal), so script execution is not new trust — the only new surface is
4
+ // a crafted/shared link auto-triggering setup.sh with attacker-chosen
5
+ // owner/repo/branch. That surface is bounded entirely here: validate the
6
+ // identity so it can't traverse out of the home root or inject options, and
7
+ // compute the workspace path **daemon-authoritatively** (never from script
8
+ // output). Kept pure + unit-tested precisely because it's the injection gate.
9
+
10
+ import { DEFAULT_BRANCH, DEFAULT_LAYOUT } from "./repo";
11
+
12
+ export interface ProvisionTarget {
13
+ owner: string;
14
+ repo: string;
15
+ branch: string;
16
+ }
17
+
18
+ export type ValidateResult = { ok: true; target: ProvisionTarget } | { ok: false; reason: string };
19
+
20
+ // First char must be alphanumeric: rejects "..", ".", leading-dot tricks, and a
21
+ // leading "-" in one stroke (a bare `[A-Za-z0-9._-]+` would match "..").
22
+ const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
23
+ // Positive allowlist for a branch ref as a whole: safe path chars only. Excludes
24
+ // whitespace, control chars, and shell metacharacters by construction.
25
+ const BRANCH_CHARS = /^[A-Za-z0-9._/-]+$/;
26
+
27
+ /** Validate the (owner, repo, branch) identity carried by a repo deep link before
28
+ * it is used to build a filesystem path or passed to a setup script. Branch may
29
+ * contain "/" (worktree refs) but no empty/`.`/`..` segment and no segment
30
+ * starting with "-" (else `git checkout "$BRANCH"` eats it as an option flag —
31
+ * option injection survives env-passing because the script interpolates it). An
32
+ * empty branch defaults to DEFAULT_BRANCH. */
33
+ export function validateProvisionTarget(owner: string, repo: string, branch: string): ValidateResult {
34
+ if (!SEGMENT.test(owner)) return { ok: false, reason: `invalid owner: ${owner}` };
35
+ if (!SEGMENT.test(repo)) return { ok: false, reason: `invalid repo: ${repo}` };
36
+ const b = (branch || DEFAULT_BRANCH).trim();
37
+ if (!BRANCH_CHARS.test(b)) return { ok: false, reason: `invalid branch: ${b}` };
38
+ const segs = b.split("/");
39
+ if (segs.some((s) => s === "" || s === "." || s === ".." || s.startsWith("-"))) {
40
+ return { ok: false, reason: `invalid branch: ${b}` };
41
+ }
42
+ return { ok: true, target: { owner, repo, branch: b } };
43
+ }
44
+
45
+ /** Fill a workspace layout template from a *validated* target. POSIX-space (the
46
+ * served cwd is already `toPosixPath`'d), so the result is the `?folder=` form.
47
+ * Safe against traversal only because `target` passed validateProvisionTarget. */
48
+ export function resolveWorkspacePath(homePosix: string, layout: string, target: ProvisionTarget): string {
49
+ const rel = (layout || DEFAULT_LAYOUT)
50
+ .replace(/\{owner\}/g, target.owner)
51
+ .replace(/\{repo\}/g, target.repo)
52
+ .replace(/\{branch\}/g, target.branch);
53
+ return `${homePosix.replace(/\/+$/, "")}/${rel.replace(/^\/+/, "")}`;
54
+ }
55
+
56
+ /** Whether a repo may be auto-provisioned. An empty/absent allowlist allows all
57
+ * (provisioning is already opt-in by the presence of setup.sh); otherwise the
58
+ * repo key `<host>/<owner>/<repo>` must match an entry, where a trailing `/*`
59
+ * is an owner/prefix wildcard (e.g. `github.com/snomiao/*`). */
60
+ export function repoAllowed(repoKey: string, allowlist: string[] | undefined): boolean {
61
+ if (!allowlist || allowlist.length === 0) return true;
62
+ return allowlist.some((rule) => {
63
+ const r = rule.trim();
64
+ if (r.endsWith("/*")) return repoKey.startsWith(r.slice(0, -1));
65
+ return repoKey === r;
66
+ });
67
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { gitUrlToPath, parseDeepLink, pickRoomMatch, repoKey, resolveDevTarget, resolveRepoTarget, shareableDeepLink, toPosixPath } from "./repo";
3
- import type { PeerInfo } from "./signaling";
3
+ import type { PeerInfo, WorkspaceInfo } from "./signaling";
4
4
  import { parseGitRemote } from "../cli/git";
5
5
 
6
6
  describe("toPosixPath", () => {
@@ -217,6 +217,115 @@ describe("resolveRepoTarget root selection", () => {
217
217
  });
218
218
  });
219
219
 
220
+ describe("resolveDevTarget advertised workspaces", () => {
221
+ const root: PeerInfo = {
222
+ peerId: "root1",
223
+ role: "server",
224
+ meta: {
225
+ name: "mac",
226
+ host: "mbp",
227
+ cwd: "/Users/sno",
228
+ kind: "root",
229
+ workspaces: [{ path: "/Users/sno/scratch/tool" }],
230
+ },
231
+ };
232
+
233
+ test("a /host/<host>/<path> link matches a registered workspace on a root daemon", () => {
234
+ const res = resolveDevTarget([root], { host: "mbp", path: "/Users/sno/scratch/tool" });
235
+ expect(res?.peerId).toBe("root1");
236
+ expect(res?.folder).toBe("/Users/sno/scratch/tool");
237
+ expect(res?.exact).toBe(true);
238
+ });
239
+
240
+ test("host scoping still applies to workspace matches", () => {
241
+ expect(resolveDevTarget([root], { host: "other", path: "/Users/sno/scratch/tool" })).toBeNull();
242
+ });
243
+
244
+ test("an exact cwd mount still wins (no folder synthesized)", () => {
245
+ const res = resolveDevTarget([root], { host: "mbp", path: "/Users/sno" });
246
+ expect(res).toEqual({ peerId: "root1" });
247
+ });
248
+ });
249
+
250
+ describe("resolveRepoTarget enumerated workspaces (exact)", () => {
251
+ const target = { host: "github.com", owner: "snomiao", name: "codehost" };
252
+ const root = (peerId: string, cwd: string, workspaces?: WorkspaceInfo[]): PeerInfo => ({
253
+ peerId,
254
+ role: "server",
255
+ meta: { name: peerId, host: "Mac", cwd, kind: "root", workspaces },
256
+ });
257
+
258
+ test("an enumerated checkout beats a deeper root's synthesized fallback", () => {
259
+ const servers = [
260
+ root("deep", "/Users/sno/ws"),
261
+ root("listed", "/Users/sno", [
262
+ { path: "/Users/sno/snomiao/codehost/tree/main", repo: "github.com/snomiao/codehost", branch: "main" },
263
+ ]),
264
+ ];
265
+ const res = resolveRepoTarget(servers, target);
266
+ expect(res?.peerId).toBe("listed");
267
+ expect(res?.folder).toBe("/Users/sno/snomiao/codehost/tree/main");
268
+ expect(res?.exact).toBe(true);
269
+ });
270
+
271
+ test("branch mismatch falls back to the synthesized path", () => {
272
+ const servers = [
273
+ root("listed", "/Users/sno/ws", [
274
+ { path: "/Users/sno/ws/snomiao/codehost/tree/main", repo: "github.com/snomiao/codehost", branch: "main" },
275
+ ]),
276
+ ];
277
+ const res = resolveRepoTarget(servers, { ...target, branch: "dev" });
278
+ expect(res?.exact).toBeUndefined();
279
+ expect(res?.folder).toBe("/Users/sno/ws/snomiao/codehost/tree/dev");
280
+ });
281
+
282
+ test("pickRoomMatch ranks an exact enumerated match like a repo daemon", () => {
283
+ const fallback = { token: "room-a", resolution: { peerId: "x", folder: "/a/b" } };
284
+ const exact = { token: "room-b", resolution: { peerId: "y", folder: "/c/d", exact: true } };
285
+ expect(pickRoomMatch([fallback, exact])?.token).toBe("room-b");
286
+ });
287
+ });
288
+
289
+ describe("resolveRepoTarget machine preference (history)", () => {
290
+ const target = { host: "github.com", owner: "snomiao", name: "codehost" };
291
+ const repoDaemon = (peerId: string, hostId?: string, host = "Mac"): PeerInfo => ({
292
+ peerId,
293
+ role: "server",
294
+ meta: { name: peerId, host, hostId, cwd: "/x", repo: "github.com/snomiao/codehost" },
295
+ });
296
+ const root = (peerId: string, cwd: string, hostId?: string, host = "Mac"): PeerInfo => ({
297
+ peerId,
298
+ role: "server",
299
+ meta: { name: peerId, host, hostId, cwd, kind: "root" },
300
+ });
301
+
302
+ test("among equal repo daemons, prefers the history hostId", () => {
303
+ const servers = [repoDaemon("a", "id-a"), repoDaemon("b", "id-b")];
304
+ expect(resolveRepoTarget(servers, target, { hostId: "id-b" })?.peerId).toBe("b");
305
+ expect(resolveRepoTarget(servers, target, { hostId: "id-a" })?.peerId).toBe("a");
306
+ });
307
+
308
+ test("hostname fallback matches entries/daemons without a hostId", () => {
309
+ const servers = [repoDaemon("a", undefined, "mbp"), repoDaemon("b", undefined, "win")];
310
+ expect(resolveRepoTarget(servers, target, { host: "win" })?.peerId).toBe("b");
311
+ });
312
+
313
+ test("preferred root beats a deeper root on another machine", () => {
314
+ const servers = [root("deep", "/Users/sno/ws/x", "id-other"), root("mine", "/Users/sno", "id-mine")];
315
+ expect(resolveRepoTarget(servers, target, { hostId: "id-mine" })?.peerId).toBe("mine");
316
+ });
317
+
318
+ test("no preference keeps the existing order (deepest root, first repo daemon)", () => {
319
+ const servers = [root("shallow", "/Users/sno", "id-1"), root("deep", "/Users/sno/ws", "id-2")];
320
+ expect(resolveRepoTarget(servers, target)?.peerId).toBe("deep");
321
+ });
322
+
323
+ test("a stale preference (machine offline) falls back gracefully", () => {
324
+ const servers = [repoDaemon("a", "id-a")];
325
+ expect(resolveRepoTarget(servers, target, { hostId: "gone" })?.peerId).toBe("a");
326
+ });
327
+ });
328
+
220
329
  describe("gitUrlToPath", () => {
221
330
  test("github URLs -> /gh, preserving branch (incl. slashes)", () => {
222
331
  expect(gitUrlToPath("https://github.com/snomiao/codehost")).toBe("/gh/snomiao/codehost");
@@ -100,6 +100,16 @@ export function toPosixPath(p: string): string {
100
100
  return p.replace(/\\/g, "/");
101
101
  }
102
102
 
103
+ /** Inverse of `toPosixPath` for the host OS: the VS Code `?folder=` form back to
104
+ * a real filesystem path. `/C:/ws` -> `C:\ws` (Windows); a POSIX path is
105
+ * returned unchanged (only Windows cwds carry the `/<drive>:/` shape). */
106
+ export function fromPosixPath(p: string): string {
107
+ const drive = /^\/([A-Za-z]):(\/.*)?$/.exec(p);
108
+ if (!drive) return p;
109
+ const rest = (drive[2] ?? "").replace(/\//g, "\\");
110
+ return `${drive[1]}:${rest || "\\"}`;
111
+ }
112
+
103
113
  /** Fill a layout template from a repo target (default branch -> DEFAULT_BRANCH). */
104
114
  export function fillLayout(layout: string, t: RepoTarget): string {
105
115
  return layout
@@ -163,26 +173,66 @@ export interface Resolution {
163
173
  peerId: string;
164
174
  /** Folder to open via ?folder= (root kind); undefined opens the repo as-is. */
165
175
  folder?: string;
176
+ /** The folder is a checkout the daemon *enumerated* (it exists on disk), not
177
+ * an optimistic layout-synthesized path — rank it like an exact match. */
178
+ exact?: boolean;
179
+ }
180
+
181
+ /** Machine preference (from history) used to break ties between matches. */
182
+ export interface ResolvePrefs {
183
+ /** Stable machine id — prefer servers advertising it. */
184
+ hostId?: string;
185
+ /** Hostname fallback for pre-hostId daemons/entries. */
186
+ host?: string;
187
+ }
188
+
189
+ function prefers(meta: PeerMeta | null | undefined, prefer?: ResolvePrefs): boolean {
190
+ if (!prefer || !meta) return false;
191
+ if (prefer.hostId && meta.hostId) return meta.hostId === prefer.hostId;
192
+ return !!prefer.host && meta.host === prefer.host;
166
193
  }
167
194
 
168
195
  /**
169
196
  * Pick the best live server for a repo deep link. Prefers an exact `repo`
170
197
  * daemon; otherwise falls back to a `root` daemon that can open the subfolder.
171
- * Among several roots, prefers the **deepest** (longest cwd) with a nested
172
- * setup like /Users/sno and /Users/sno/ws both serving, the layout subfolder
173
- * exists under the deeper one (observed: /gh/snomiao/codehost belongs to
174
- * /Users/sno/ws/..., not /Users/sno/...). Returns null if nothing matches.
198
+ * Ties (several repo daemons, or several roots) break toward the machine in
199
+ * `prefer` the one history says served this repo last. Among several roots,
200
+ * then prefers the **deepest** (longest cwd) with a nested setup like
201
+ * /Users/sno and /Users/sno/ws both serving, the layout subfolder exists under
202
+ * the deeper one (observed: /gh/snomiao/codehost belongs to /Users/sno/ws/...,
203
+ * not /Users/sno/...). Returns null if nothing matches.
175
204
  */
176
- export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Resolution | null {
205
+ export function resolveRepoTarget(
206
+ servers: PeerInfo[],
207
+ target: RepoTarget,
208
+ prefer?: ResolvePrefs,
209
+ ): Resolution | null {
177
210
  const key = repoKey(target);
178
- const repoMatch = servers.find(
211
+ const repoMatches = servers.filter(
179
212
  (s) => s.meta?.kind !== "root" && s.meta?.repo === key && branchOk(s.meta, target),
180
213
  );
214
+ const repoMatch = repoMatches.find((s) => prefers(s.meta, prefer)) ?? repoMatches[0];
181
215
  if (repoMatch) return { peerId: repoMatch.peerId };
182
216
 
183
- const root = servers
217
+ const roots = servers
184
218
  .filter((s) => s.meta?.kind === "root")
185
- .sort((a, b) => (b.meta?.cwd.length ?? 0) - (a.meta?.cwd.length ?? 0))[0];
219
+ .sort(
220
+ (a, b) =>
221
+ Number(prefers(b.meta, prefer)) - Number(prefers(a.meta, prefer)) ||
222
+ (b.meta?.cwd.length ?? 0) - (a.meta?.cwd.length ?? 0),
223
+ );
224
+
225
+ // A root that *enumerated* a matching checkout knows it exists on disk —
226
+ // rank it exact, ahead of any synthesized fallback.
227
+ const wantBranch = target.branch || DEFAULT_BRANCH;
228
+ for (const s of roots) {
229
+ const ws = s.meta?.workspaces?.find(
230
+ (w) => w.repo === key && (!w.branch || w.branch === wantBranch),
231
+ );
232
+ if (ws) return { peerId: s.peerId, folder: ws.path, exact: true };
233
+ }
234
+
235
+ const root = roots[0];
186
236
  if (root && root.meta) {
187
237
  const folder = `${trimSlash(root.meta.cwd)}/${fillLayout(root.meta.layout || DEFAULT_LAYOUT, target)}`;
188
238
  return { peerId: root.peerId, folder };
@@ -194,13 +244,20 @@ export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Reso
194
244
  * to `target.host` when the link carries one (a bare path is ambiguous across
195
245
  * machines). Compares with leading + trailing slashes stripped: `parseDeepLink`
196
246
  * forces a leading "/" on the path, but a served cwd may lack one (e.g. an
197
- * `expose` server's `localhost:<port>`), so a trailing-only trim never matches. */
247
+ * `expose` server's `localhost:<port>`), so a trailing-only trim never matches.
248
+ * A root daemon whose *advertised workspaces* include the path matches too —
249
+ * that's how directories registered with the host daemon resolve. */
198
250
  export function resolveDevTarget(servers: PeerInfo[], target: DevTarget): Resolution | null {
199
251
  const want = stripEnds(target.path);
200
- const hit = servers.find(
201
- (s) => s.meta && stripEnds(s.meta.cwd) === want && (!target.host || s.meta.host === target.host),
202
- );
203
- return hit ? { peerId: hit.peerId } : null;
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));
254
+ if (hit) return { peerId: hit.peerId };
255
+ for (const s of servers) {
256
+ if (!s.meta || !hostOk(s.meta)) continue;
257
+ const ws = s.meta.workspaces?.find((w) => stripEnds(w.path) === want);
258
+ if (ws) return { peerId: s.peerId, folder: ws.path, exact: true };
259
+ }
260
+ return null;
204
261
  }
205
262
 
206
263
  /** A candidate room (its token) plus how the deep link resolved within it. */
@@ -211,14 +268,15 @@ export interface RoomMatch {
211
268
 
212
269
  /**
213
270
  * Rank matches found while searching multiple rooms for a token-less deep link.
214
- * An *exact* match (a server that genuinely serves this repo/folder — no
215
- * synthesized `folder`) beats a *root fallback* (a root daemon that would open
216
- * the repo as a subfolder, which `resolveRepoTarget` returns for ANY repo link).
217
- * Without this preference, first-responder-wins could pick an unrelated room
218
- * that merely has a root server. Returns null when there are no matches.
271
+ * An *exact* match (a server that genuinely serves this repo/folder — a repo
272
+ * daemon, or a root whose enumerated checkout matched) beats a *root fallback*
273
+ * (a root daemon that would open the repo as a synthesized subfolder, which
274
+ * `resolveRepoTarget` returns for ANY repo link). Without this preference,
275
+ * first-responder-wins could pick an unrelated room that merely has a root
276
+ * server. Returns null when there are no matches.
219
277
  */
220
278
  export function pickRoomMatch(matches: RoomMatch[]): RoomMatch | null {
221
- return matches.find((m) => !m.resolution.folder) ?? matches[0] ?? null;
279
+ return matches.find((m) => !m.resolution.folder || m.resolution.exact) ?? matches[0] ?? null;
222
280
  }
223
281
 
224
282
  function branchOk(meta: PeerMeta, target: RepoTarget): boolean {