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/CHANGELOG.md +27 -0
- package/package.json +3 -1
- package/src/cli/commands/dev.ts +32 -1
- package/src/cli/commands/expose.ts +2 -0
- package/src/cli/commands/init.ts +27 -0
- package/src/cli/commands/serve.ts +56 -11
- package/src/cli/config.test.ts +31 -0
- package/src/cli/config.ts +20 -7
- package/src/cli/daemonize.ts +4 -0
- package/src/cli/index.ts +2 -0
- package/src/cli/init.test.ts +48 -0
- package/src/cli/init.ts +103 -0
- package/src/cli/plugins/agent-yes.test.ts +71 -0
- package/src/cli/plugins/agent-yes.ts +116 -0
- package/src/cli/plugins/types.ts +39 -0
- package/src/cli/provision-server.test.ts +76 -0
- package/src/cli/provision-server.ts +186 -0
- package/src/cli/registry.test.ts +51 -0
- package/src/cli/registry.ts +93 -0
- package/src/cli/run-server.ts +62 -2
- package/src/cli/tunnel.ts +25 -7
- package/src/cli/vscode-install.ts +20 -3
- package/src/cli/workspaces.test.ts +65 -0
- package/src/cli/workspaces.ts +80 -0
- package/src/shared/provision.test.ts +79 -0
- package/src/shared/provision.ts +67 -0
- package/src/shared/repo.test.ts +110 -1
- package/src/shared/repo.ts +77 -19
- package/src/shared/signaling-client.ts +10 -0
- package/src/shared/signaling.ts +55 -1
- package/src/web/discovery.tsx +226 -44
- package/src/web/history.ts +4 -1
- package/src/web/room-client.ts +114 -0
- package/worker/room.ts +9 -0
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
53
|
-
//
|
|
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))
|
|
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
|
+
}
|
package/src/shared/repo.test.ts
CHANGED
|
@@ -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");
|
package/src/shared/repo.ts
CHANGED
|
@@ -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
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* /Users/sno
|
|
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(
|
|
205
|
+
export function resolveRepoTarget(
|
|
206
|
+
servers: PeerInfo[],
|
|
207
|
+
target: RepoTarget,
|
|
208
|
+
prefer?: ResolvePrefs,
|
|
209
|
+
): Resolution | null {
|
|
177
210
|
const key = repoKey(target);
|
|
178
|
-
const
|
|
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
|
|
217
|
+
const roots = servers
|
|
184
218
|
.filter((s) => s.meta?.kind === "root")
|
|
185
|
-
.sort(
|
|
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
|
|
201
|
-
|
|
202
|
-
);
|
|
203
|
-
|
|
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 —
|
|
215
|
-
*
|
|
216
|
-
* the repo as a subfolder, which
|
|
217
|
-
*
|
|
218
|
-
*
|
|
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 {
|