codehost 0.15.0 → 0.16.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,13 @@
1
+ # [0.16.0](https://github.com/snomiao/codehost/compare/v0.15.0...v0.16.0) (2026-06-10)
2
+
3
+
4
+ ### Features
5
+
6
+ * **provision:** codehost init scaffolds .codehost/ (config + setup hooks) ([b1a2e9c](https://github.com/snomiao/codehost/commit/b1a2e9c23230e09dc6a291dbcab098083bb59f4d))
7
+ * **provision:** daemon-side provision handler over the tunnel ([c297e95](https://github.com/snomiao/codehost/commit/c297e9577b3e1a65260ab105478feb63186fce46))
8
+ * **provision:** tested security core for workspace provisioning ([ca7508c](https://github.com/snomiao/codehost/commit/ca7508c48e78eb46fbf8bf42ef398360bea38f86))
9
+ * **web:** provisioning browser flow — run setup.sh on repo open, stream log ([9bd8742](https://github.com/snomiao/codehost/commit/9bd8742b143e84d386f8af138df73e57cac70e46))
10
+
1
11
  # [0.15.0](https://github.com/snomiao/codehost/compare/v0.14.0...v0.15.0) (2026-06-10)
2
12
 
3
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,6 +38,7 @@
38
38
  "oxmgr": "^0.4.0",
39
39
  "react": "^19.1.1",
40
40
  "react-dom": "^19.1.1",
41
+ "yaml": "^2.9.0",
41
42
  "yargs": "^17.7.2"
42
43
  },
43
44
  "devDependencies": {
@@ -0,0 +1,27 @@
1
+ import { resolve } from "node:path";
2
+ import type { CommandModule } from "yargs";
3
+ import { scaffoldCodehost } from "../init";
4
+
5
+ interface InitArgs {
6
+ dir: string;
7
+ force: boolean;
8
+ }
9
+
10
+ export const initCommand: CommandModule<{}, InitArgs> = {
11
+ command: "init [dir]",
12
+ describe: "Scaffold .codehost/ (config.yaml + setup.sh) so repo links auto-provision",
13
+ builder: (y) =>
14
+ y
15
+ .positional("dir", { describe: "Home dir to scaffold (defaults to cwd)", type: "string", default: "." })
16
+ .option("force", { alias: "f", describe: "Overwrite existing files", type: "boolean", default: false }) as any,
17
+ handler: (argv) => {
18
+ const dir = resolve(process.cwd(), argv.dir);
19
+ const written = scaffoldCodehost(dir, argv.force);
20
+ if (written.length === 0) {
21
+ console.log(`[codehost] .codehost/ already set up in ${dir} (use --force to overwrite)`);
22
+ return;
23
+ }
24
+ console.log(`[codehost] scaffolded:\n${written.map((p) => ` ${p}`).join("\n")}`);
25
+ console.log(`[codehost] edit .codehost/setup.sh, then run: codehost serve ${dir}`);
26
+ },
27
+ };
@@ -2,7 +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 { DEFAULT_LAYOUT, toPosixPath } from "../../shared/repo";
5
+ import { DEFAULT_LAYOUT, GITHUB_HOST, toPosixPath } from "../../shared/repo";
6
6
  import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
7
7
  import { launchServeDaemon } from "../daemonize";
8
8
  import { announceConnect } from "../open-url";
@@ -101,6 +101,7 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
101
101
  signal: argv.signal,
102
102
  meta,
103
103
  label: `serving workspace root ${dir}`,
104
+ provision: { homeDir: dir, host: GITHUB_HOST },
104
105
  launch: async (basePath) => {
105
106
  const v = await launchVscode({ dir, basePath, port: argv.port });
106
107
  return { port: v.port, stop: v.stop };
package/src/cli/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import yargs from "yargs";
3
3
  import { hideBin } from "yargs/helpers";
4
4
  import { setupCommand } from "./commands/setup";
5
+ import { initCommand } from "./commands/init";
5
6
  import { serveCommand } from "./commands/serve";
6
7
  import { devCommand } from "./commands/dev";
7
8
  import { exposeCommand } from "./commands/expose";
@@ -14,6 +15,7 @@ yargs(hideBin(process.argv))
14
15
  .scriptName("codehost")
15
16
  .usage("$0 <command> [options]")
16
17
  .command(setupCommand)
18
+ .command(initCommand)
17
19
  .command(serveCommand)
18
20
  .command(devCommand)
19
21
  .command(exposeCommand)
@@ -0,0 +1,48 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { parse as parseYaml } from "yaml";
6
+ import { scaffoldCodehost } from "./init";
7
+
8
+ const homes: string[] = [];
9
+ afterAll(() => homes.forEach((h) => rmSync(h, { recursive: true, force: true })));
10
+ function mkHome(): string {
11
+ const h = mkdtempSync(join(tmpdir(), "codehost-init-"));
12
+ homes.push(h);
13
+ return h;
14
+ }
15
+
16
+ describe("scaffoldCodehost", () => {
17
+ test("writes config.yaml + setup.sh + setup.ps1", () => {
18
+ const home = mkHome();
19
+ const written = scaffoldCodehost(home);
20
+ expect(written).toHaveLength(3);
21
+ expect(existsSync(join(home, ".codehost", "config.yaml"))).toBe(true);
22
+ expect(existsSync(join(home, ".codehost", "setup.sh"))).toBe(true);
23
+ expect(existsSync(join(home, ".codehost", "setup.ps1"))).toBe(true);
24
+ });
25
+
26
+ test("config.yaml is valid YAML with a workspace template", () => {
27
+ const home = mkHome();
28
+ scaffoldCodehost(home);
29
+ const cfg = parseYaml(readFileSync(join(home, ".codehost", "config.yaml"), "utf8"));
30
+ expect(cfg.workspace).toBe("ws/{owner}/{repo}/tree/{branch}");
31
+ });
32
+
33
+ test("setup.sh keeps shell vars literal (no JS interpolation leaked)", () => {
34
+ const home = mkHome();
35
+ scaffoldCodehost(home);
36
+ const sh = readFileSync(join(home, ".codehost", "setup.sh"), "utf8");
37
+ expect(sh).toContain("$CODEHOST_WS");
38
+ expect(sh).toContain("${ws%/tree/$CODEHOST_BRANCH}");
39
+ expect(sh).toContain("git -C \"$repo\" worktree add");
40
+ });
41
+
42
+ test("idempotent: a second run writes nothing; --force overwrites", () => {
43
+ const home = mkHome();
44
+ expect(scaffoldCodehost(home)).toHaveLength(3);
45
+ expect(scaffoldCodehost(home)).toHaveLength(0);
46
+ expect(scaffoldCodehost(home, true)).toHaveLength(3);
47
+ });
48
+ });
@@ -0,0 +1,103 @@
1
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ // Scaffolds a home's `.codehost/` config dir: an editable, idempotent setup hook
5
+ // (run on every repo open) plus a config.yaml. Provisioning is opt-in by these
6
+ // files existing — `codehost init` is how you opt in.
7
+
8
+ const CONFIG_YAML = `# codehost workspace config — see docs/provisioning.md
9
+
10
+ # Where /gh/<owner>/<repo>/tree/<branch> lands, relative to this home dir.
11
+ # Default (if omitted): {owner}/{repo}/tree/{branch}
12
+ workspace: "ws/{owner}/{repo}/tree/{branch}"
13
+
14
+ # Optional: only these repos may auto-provision (a trailing /* is an owner
15
+ # wildcard). Empty/absent = allow all. The room token already grants access, so
16
+ # this is extra hardening, not the primary gate.
17
+ # allowlist:
18
+ # - github.com/snomiao/*
19
+ `;
20
+
21
+ const SETUP_SH = `#!/usr/bin/env bash
22
+ # codehost provisioning hook. Runs on every open of /gh/<owner>/<repo>/tree/<branch>
23
+ # BEFORE the editor opens CODEHOST_WS. Keep it idempotent: clone/worktree if
24
+ # missing, fast-skip if present. Edit freely (install deps, pull/rebase policy…).
25
+ #
26
+ # Env in: CODEHOST_OWNER CODEHOST_REPO CODEHOST_BRANCH CODEHOST_HOST
27
+ # CODEHOST_HOME (this dir) CODEHOST_WS (the path the editor opens)
28
+ set -euo pipefail
29
+
30
+ ws="$CODEHOST_WS"
31
+ if [ -e "$ws/.git" ]; then
32
+ echo "[setup] $ws already provisioned"
33
+ exit 0
34
+ fi
35
+
36
+ # Primary clone = the workspace path minus the /tree/<branch> tail (layout-agnostic).
37
+ repo="\${ws%/tree/$CODEHOST_BRANCH}"
38
+ url="https://$CODEHOST_HOST/$CODEHOST_OWNER/$CODEHOST_REPO.git"
39
+
40
+ if [ ! -e "$repo/.git" ]; then
41
+ echo "[setup] cloning $url"
42
+ mkdir -p "$(dirname "$repo")"
43
+ git clone "$url" "$repo"
44
+ fi
45
+
46
+ echo "[setup] adding worktree $ws @ $CODEHOST_BRANCH"
47
+ mkdir -p "$(dirname "$ws")"
48
+ git -C "$repo" fetch --quiet origin "$CODEHOST_BRANCH" || true
49
+ git -C "$repo" worktree add "$ws" "$CODEHOST_BRANCH" \\
50
+ || git -C "$repo" worktree add -b "$CODEHOST_BRANCH" "$ws" "origin/$CODEHOST_BRANCH"
51
+
52
+ # Example: install deps for this worktree (uncomment / edit).
53
+ # ( cd "$ws" && bun install )
54
+
55
+ echo "[setup] ready: $ws"
56
+ `;
57
+
58
+ const SETUP_PS1 = `# codehost provisioning hook (Windows). See setup.sh for the contract.
59
+ $ErrorActionPreference = "Stop"
60
+ $ws = $env:CODEHOST_WS
61
+ if (Test-Path (Join-Path $ws ".git")) { Write-Host "[setup] $ws already provisioned"; exit 0 }
62
+
63
+ $repo = $ws -replace "[\\\\/]tree[\\\\/]$([regex]::Escape($env:CODEHOST_BRANCH))$", ""
64
+ $url = "https://$($env:CODEHOST_HOST)/$($env:CODEHOST_OWNER)/$($env:CODEHOST_REPO).git"
65
+
66
+ if (-not (Test-Path (Join-Path $repo ".git"))) {
67
+ Write-Host "[setup] cloning $url"
68
+ New-Item -ItemType Directory -Force -Path (Split-Path $repo) | Out-Null
69
+ git clone $url $repo
70
+ }
71
+ Write-Host "[setup] adding worktree $ws @ $($env:CODEHOST_BRANCH)"
72
+ New-Item -ItemType Directory -Force -Path (Split-Path $ws) | Out-Null
73
+ git -C $repo worktree add $ws $env:CODEHOST_BRANCH
74
+ Write-Host "[setup] ready: $ws"
75
+ `;
76
+
77
+ const FILES: Array<{ rel: string; body: string; exec?: boolean }> = [
78
+ { rel: "config.yaml", body: CONFIG_YAML },
79
+ { rel: "setup.sh", body: SETUP_SH, exec: true },
80
+ { rel: "setup.ps1", body: SETUP_PS1 },
81
+ ];
82
+
83
+ /** Write `<homeDir>/.codehost/{config.yaml,setup.sh,setup.ps1}`. Existing files
84
+ * are kept unless `force`. Returns the list of files actually written. */
85
+ export function scaffoldCodehost(homeDir: string, force = false): string[] {
86
+ const dir = join(homeDir, ".codehost");
87
+ mkdirSync(dir, { recursive: true });
88
+ const written: string[] = [];
89
+ for (const f of FILES) {
90
+ const path = join(dir, f.rel);
91
+ if (existsSync(path) && !force) continue;
92
+ writeFileSync(path, f.body);
93
+ if (f.exec) {
94
+ try {
95
+ chmodSync(path, 0o755);
96
+ } catch {
97
+ // non-POSIX fs — ignore
98
+ }
99
+ }
100
+ written.push(path);
101
+ }
102
+ return written;
103
+ }
@@ -0,0 +1,76 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { handleProvision, isProvisionPath } from "./provision-server";
6
+
7
+ // A throwaway home with an optional .codehost/setup.sh.
8
+ function makeHome(setup?: string, configYaml?: string): string {
9
+ const home = mkdtempSync(join(tmpdir(), "codehost-prov-"));
10
+ if (setup || configYaml) mkdirSync(join(home, ".codehost"), { recursive: true });
11
+ if (setup) writeFileSync(join(home, ".codehost", "setup.sh"), setup);
12
+ if (configYaml) writeFileSync(join(home, ".codehost", "config.yaml"), configYaml);
13
+ homes.push(home);
14
+ return home;
15
+ }
16
+ const homes: string[] = [];
17
+ afterAll(() => homes.forEach((h) => rmSync(h, { recursive: true, force: true })));
18
+
19
+ const q = (owner: string, repo: string, branch = "main") =>
20
+ `/__codehost/provision?owner=${owner}&repo=${repo}&branch=${branch}`;
21
+
22
+ describe("isProvisionPath", () => {
23
+ test("matches the route, ignores query + other paths", () => {
24
+ expect(isProvisionPath("/__codehost/provision?owner=a")).toBe(true);
25
+ expect(isProvisionPath("/vs/abc/?folder=x")).toBe(false);
26
+ });
27
+ });
28
+
29
+ describe("handleProvision", () => {
30
+ test("no setup script → 200 + workspace path in header/body (today's behavior)", async () => {
31
+ const home = makeHome();
32
+ const res = await handleProvision(q("snomiao", "codehost"), { homeDir: home, host: "github.com" });
33
+ expect(res.status).toBe(200);
34
+ expect(res.headers.get("x-codehost-workspace")).toBe(`${home}/snomiao/codehost/tree/main`);
35
+ expect(await res.json()).toEqual({ workspace: `${home}/snomiao/codehost/tree/main` });
36
+ });
37
+
38
+ test("runs setup.sh, streams output + exit sentinel, env is passed", async () => {
39
+ const home = makeHome('echo "owner=$CODEHOST_OWNER branch=$CODEHOST_BRANCH ws=$CODEHOST_WS"\nexit 0\n');
40
+ const res = await handleProvision(q("snomiao", "codehost", "feat/x"), { homeDir: home, host: "github.com" });
41
+ expect(res.status).toBe(200);
42
+ expect(res.headers.get("x-codehost-workspace")).toBe(`${home}/snomiao/codehost/tree/feat/x`);
43
+ const body = await res.text();
44
+ expect(body).toContain(`owner=snomiao branch=feat/x ws=${home}/snomiao/codehost/tree/feat/x`);
45
+ expect(body).toContain("::codehost:exit=0");
46
+ });
47
+
48
+ test("propagates a non-zero exit code in the sentinel", async () => {
49
+ const home = makeHome('echo "boom" >&2\nexit 7\n');
50
+ const res = await handleProvision(q("snomiao", "codehost"), { homeDir: home, host: "github.com" });
51
+ const body = await res.text();
52
+ expect(body).toContain("boom");
53
+ expect(body).toContain("::codehost:exit=7");
54
+ });
55
+
56
+ test("rejects a traversal identity with 400 (no spawn)", async () => {
57
+ const home = makeHome("echo SHOULD_NOT_RUN\nexit 0\n");
58
+ const res = await handleProvision(q("..", "codehost"), { homeDir: home, host: "github.com" });
59
+ expect(res.status).toBe(400);
60
+ expect(await res.text()).not.toContain("SHOULD_NOT_RUN");
61
+ });
62
+
63
+ test("enforces the config allowlist with 403", async () => {
64
+ const home = makeHome("echo hi\nexit 0\n", "allowlist:\n - github.com/snomiao/*\n");
65
+ const ok = await handleProvision(q("snomiao", "codehost"), { homeDir: home, host: "github.com" });
66
+ expect(ok.status).toBe(200);
67
+ const denied = await handleProvision(q("evil", "repo"), { homeDir: home, host: "github.com" });
68
+ expect(denied.status).toBe(403);
69
+ });
70
+
71
+ test("config.yaml workspace template overrides the layout", async () => {
72
+ const home = makeHome(undefined, 'workspace: "ws/{owner}/{repo}/tree/{branch}"\n');
73
+ const res = await handleProvision(q("snomiao", "codehost"), { homeDir: home, host: "github.com" });
74
+ expect(res.headers.get("x-codehost-workspace")).toBe(`${home}/ws/snomiao/codehost/tree/main`);
75
+ });
76
+ });
@@ -0,0 +1,178 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ import { repoAllowed, resolveWorkspacePath, validateProvisionTarget, type ProvisionTarget } from "../shared/provision";
5
+ import { fromPosixPath, repoKey, toPosixPath } from "../shared/repo";
6
+
7
+ // Daemon side of provisioning. A repo open hits `GET /__codehost/provision?...`
8
+ // over the tunnel; this validates the identity, computes the daemon-authoritative
9
+ // workspace path, and (if `.codehost/setup.sh` exists) runs it, streaming its
10
+ // output back as the response body. The resolved path rides in the
11
+ // `x-codehost-workspace` header so it never depends on parsing script output.
12
+
13
+ export const PROVISION_PATH = "/__codehost/provision";
14
+ const TIMEOUT_MS = Number(process.env.CODEHOST_PROVISION_TIMEOUT_MS) || 15 * 60_000;
15
+
16
+ export interface ProvisionDeps {
17
+ /** Real OS path of the served home root. */
18
+ homeDir: string;
19
+ /** Git host advertised by this daemon (default github.com). */
20
+ host: string;
21
+ }
22
+
23
+ interface CodehostConfig {
24
+ workspace?: string; // layout template, e.g. "ws/{owner}/{repo}/tree/{branch}"
25
+ allowlist?: string[];
26
+ }
27
+
28
+ /** True for the provision route (ignoring the query string). */
29
+ export function isProvisionPath(path: string): boolean {
30
+ return path.split("?")[0] === PROVISION_PATH;
31
+ }
32
+
33
+ function readConfig(homeDir: string): CodehostConfig {
34
+ try {
35
+ const raw = readFileSync(join(homeDir, ".codehost", "config.yaml"), "utf8");
36
+ const c = (parseYaml(raw) ?? {}) as Record<string, unknown>;
37
+ return {
38
+ workspace: typeof c.workspace === "string" ? c.workspace : undefined,
39
+ allowlist: Array.isArray(c.allowlist)
40
+ ? c.allowlist.filter((x): x is string => typeof x === "string")
41
+ : undefined,
42
+ };
43
+ } catch {
44
+ return {};
45
+ }
46
+ }
47
+
48
+ /** Locate the host's setup script (platform-appropriate), or null if none — in
49
+ * which case provisioning is a no-op and we just return the path. */
50
+ function findSetupScript(homeDir: string): string[] | null {
51
+ const dir = join(homeDir, ".codehost");
52
+ if (process.platform === "win32") {
53
+ const bat = join(dir, "setup.bat");
54
+ if (existsSync(bat)) return ["cmd", "/c", bat];
55
+ const ps1 = join(dir, "setup.ps1");
56
+ if (existsSync(ps1)) return ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ps1];
57
+ }
58
+ const sh = join(dir, "setup.sh");
59
+ if (existsSync(sh)) return ["bash", sh];
60
+ return null;
61
+ }
62
+
63
+ // Per-workspace coalescing: a concurrent open of the same target waits for the
64
+ // running provision instead of spawning a second one.
65
+ const inFlight = new Map<string, Promise<number>>();
66
+
67
+ export async function handleProvision(rawPath: string, deps: ProvisionDeps): Promise<Response> {
68
+ const url = new URL(`http://x${rawPath}`);
69
+ const v = validateProvisionTarget(
70
+ url.searchParams.get("owner") ?? "",
71
+ url.searchParams.get("repo") ?? "",
72
+ url.searchParams.get("branch") ?? "",
73
+ );
74
+ if (!v.ok) return json(400, { error: v.reason });
75
+
76
+ const host = (url.searchParams.get("host") ?? deps.host).toLowerCase();
77
+ const cfg = readConfig(deps.homeDir);
78
+ const key = repoKey({ host, owner: v.target.owner, name: v.target.repo });
79
+ if (!repoAllowed(key, cfg.allowlist)) return json(403, { error: `repo not allowlisted: ${key}` });
80
+
81
+ const wsPosix = resolveWorkspacePath(toPosixPath(deps.homeDir), cfg.workspace ?? "", v.target);
82
+ const headers = { "x-codehost-workspace": wsPosix };
83
+
84
+ const cmd = findSetupScript(deps.homeDir);
85
+ if (!cmd) return json(200, { workspace: wsPosix }, headers); // no script: hand back the path
86
+
87
+ const lockKey = fromPosixPath(wsPosix);
88
+ const existing = inFlight.get(lockKey);
89
+ const body = existing
90
+ ? coalescedBody(existing) // a provision is already running for this workspace
91
+ : freshBody(cmd, deps, v.target, host, lockKey);
92
+ return new Response(body, {
93
+ status: 200,
94
+ headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", ...headers },
95
+ });
96
+ }
97
+
98
+ /** Spawn the setup script, streaming merged stdout+stderr, ending with an exit
99
+ * sentinel the browser parses for success/failure. */
100
+ function freshBody(
101
+ cmd: string[],
102
+ deps: ProvisionDeps,
103
+ target: ProvisionTarget,
104
+ host: string,
105
+ lockKey: string,
106
+ ): ReadableStream<Uint8Array> {
107
+ const enc = new TextEncoder();
108
+ return new ReadableStream<Uint8Array>({
109
+ async start(controller) {
110
+ let resolveDone!: (code: number) => void;
111
+ inFlight.set(lockKey, new Promise<number>((r) => (resolveDone = r)));
112
+ const say = (s: string) => controller.enqueue(enc.encode(s));
113
+ say(`[codehost] provisioning ${host}/${target.owner}/${target.repo}@${target.branch}\n`);
114
+ let code = 1;
115
+ try {
116
+ const proc = Bun.spawn(cmd, {
117
+ cwd: deps.homeDir,
118
+ env: {
119
+ ...process.env,
120
+ CODEHOST_OWNER: target.owner,
121
+ CODEHOST_REPO: target.repo,
122
+ CODEHOST_BRANCH: target.branch,
123
+ CODEHOST_HOST: host,
124
+ CODEHOST_HOME: deps.homeDir,
125
+ CODEHOST_WS: fromPosixPath(lockKey),
126
+ },
127
+ stdout: "pipe",
128
+ stderr: "pipe",
129
+ });
130
+ const timer = setTimeout(() => {
131
+ try {
132
+ proc.kill();
133
+ } catch {
134
+ // already gone
135
+ }
136
+ }, TIMEOUT_MS);
137
+ const pump = async (stream: ReadableStream<Uint8Array>) => {
138
+ const reader = stream.getReader();
139
+ for (;;) {
140
+ const { done, value } = await reader.read();
141
+ if (done) break;
142
+ controller.enqueue(value);
143
+ }
144
+ };
145
+ await Promise.all([pump(proc.stdout), pump(proc.stderr)]);
146
+ code = await proc.exited;
147
+ clearTimeout(timer);
148
+ } catch (err) {
149
+ say(`[codehost] provision error: ${String(err)}\n`);
150
+ } finally {
151
+ inFlight.delete(lockKey);
152
+ resolveDone(code);
153
+ say(`\n::codehost:exit=${code}\n`);
154
+ controller.close();
155
+ }
156
+ },
157
+ });
158
+ }
159
+
160
+ /** Attach to a running provision: wait for it, then emit the exit sentinel. */
161
+ function coalescedBody(existing: Promise<number>): ReadableStream<Uint8Array> {
162
+ const enc = new TextEncoder();
163
+ return new ReadableStream<Uint8Array>({
164
+ async start(controller) {
165
+ controller.enqueue(enc.encode("[codehost] provision already running for this workspace; waiting…\n"));
166
+ const code = await existing.catch(() => 1);
167
+ controller.enqueue(enc.encode(`\n::codehost:exit=${code}\n`));
168
+ controller.close();
169
+ },
170
+ });
171
+ }
172
+
173
+ function json(status: number, obj: unknown, extra: Record<string, string> = {}): Response {
174
+ return new Response(JSON.stringify(obj), {
175
+ status,
176
+ headers: { "content-type": "application/json", "cache-control": "no-store", ...extra },
177
+ });
178
+ }
@@ -2,6 +2,7 @@ import { type PeerMeta, newPeerId } from "../shared/signaling";
2
2
  import { SignalingClient } from "../shared/signaling-client";
3
3
  import { RtcDaemon } from "./rtc-daemon";
4
4
  import { Tunnel } from "./tunnel";
5
+ import { handleProvision, type ProvisionDeps } from "./provision-server";
5
6
 
6
7
  export interface LaunchResult {
7
8
  /** Local port to tunnel to. */
@@ -24,6 +25,9 @@ export interface RunServerOptions {
24
25
  label: string;
25
26
  /** Prepare the local target to tunnel, given the /vs/<peerId> base path. */
26
27
  launch: (basePath: string) => Promise<LaunchResult>;
28
+ /** Enables `/__codehost/provision` on the tunnel (serve only — runs the home's
29
+ * setup.sh). Omitted by `expose`, which has no home/workspace. */
30
+ provision?: ProvisionDeps;
27
31
  }
28
32
 
29
33
  /**
@@ -64,7 +68,12 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
64
68
  sendSignal: (to, data) => client.sendSignal(to, data),
65
69
  onChannel: (viewerId, channel) => {
66
70
  console.log(`[codehost] viewer ${viewerId.slice(0, 8)} connected; bridging to :${target.port}`);
67
- new Tunnel(channel, target.port, target.stripBasePath);
71
+ new Tunnel(
72
+ channel,
73
+ target.port,
74
+ target.stripBasePath,
75
+ opts.provision ? (rawPath) => handleProvision(rawPath, opts.provision!) : undefined,
76
+ );
68
77
  },
69
78
  });
70
79
 
package/src/cli/tunnel.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { DataChannel } from "node-datachannel";
2
+ import { isProvisionPath } from "./provision-server";
2
3
  import {
3
4
  type HttpReqHead,
4
5
  Op,
@@ -54,6 +55,9 @@ export class Tunnel {
54
55
  * doesn't know it, so we strip `/vs/<peerId>` before proxying.
55
56
  */
56
57
  private stripPrefix?: string,
58
+ /** Handles `/__codehost/*` requests locally (provisioning) instead of
59
+ * forwarding to the local server. Wired only for `serve` (not `expose`). */
60
+ private onProvision?: (rawPath: string) => Promise<Response>,
57
61
  ) {
58
62
  this.origin = `http://127.0.0.1:${vscodePort}`;
59
63
  this.wsOrigin = `ws://127.0.0.1:${vscodePort}`;
@@ -134,12 +138,15 @@ export class Tunnel {
134
138
  const body = hasBody ? concat(stream.body) : undefined;
135
139
 
136
140
  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
- });
141
+ const res =
142
+ this.onProvision && isProvisionPath(path)
143
+ ? await this.onProvision(path)
144
+ : await fetch(this.origin + this.localPath(path), {
145
+ method,
146
+ headers: reqHeaders,
147
+ body: body as BodyInit | undefined,
148
+ redirect: "manual",
149
+ });
143
150
 
144
151
  const resHeaders: Record<string, string> = {};
145
152
  res.headers.forEach((v, k) => {
@@ -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
+ }
@@ -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
@@ -10,6 +10,7 @@ import { connBroker } from "./conn-broker";
10
10
  import {
11
11
  DEFAULT_BRANCH,
12
12
  type DeepLink,
13
+ type RepoTarget,
13
14
  type RoomMatch,
14
15
  gitUrlToPath,
15
16
  parseDeepLink,
@@ -24,7 +25,7 @@ import { deriveTags, matchQuery, shortRoomLabel, tagKey } from "../shared/tags";
24
25
 
25
26
  const TOKEN_KEY = "codehost.token";
26
27
 
27
- type ConnState = "idle" | "connecting" | "connected" | "failed";
28
+ type ConnState = "idle" | "connecting" | "provisioning" | "connected" | "failed";
28
29
 
29
30
  /** A server discovered in a specific room (its token routes the signaling). */
30
31
  type RoomedServer = { server: PeerInfo; room: string };
@@ -180,6 +181,8 @@ export function Discovery() {
180
181
  const [activePeerId, setActivePeerId] = useState<string | null>(null);
181
182
  const [connState, setConnState] = useState<ConnState>("idle");
182
183
  const [iframeSrc, setIframeSrc] = useState<string | null>(null);
184
+ // Streamed setup.sh output shown while connState === "provisioning".
185
+ const [provisionLog, setProvisionLog] = useState("");
183
186
 
184
187
  const rtcRef = useRef<RtcClient | null>(null);
185
188
  const activePeerRef = useRef<string | null>(null);
@@ -351,7 +354,13 @@ export function Discovery() {
351
354
  sendersRef.current.delete(t);
352
355
  }
353
356
 
354
- async function connectTo(server: PeerInfo, room: string, folder?: string, fromHistory = false) {
357
+ async function connectTo(
358
+ server: PeerInfo,
359
+ room: string,
360
+ folder?: string,
361
+ fromHistory = false,
362
+ repoTarget?: RepoTarget,
363
+ ) {
355
364
  const send = sendersRef.current.get(room);
356
365
  if (!send) return;
357
366
  dialingRef.current = true; // synchronous gate against concurrent triggers
@@ -374,7 +383,7 @@ export function Discovery() {
374
383
  // handshake) and push a history entry, so Back returns to the list and
375
384
  // Forward returns here. When `fromHistory`, the browser already set the URL
376
385
  // (back/forward/reconnect) — don't push again, but a prior entry exists.
377
- const openFolder = folder ?? server.meta?.cwd;
386
+ let openFolder = folder ?? server.meta?.cwd;
378
387
  if (fromHistory) {
379
388
  pushedRef.current = true;
380
389
  sharePathRef.current = window.location.pathname;
@@ -425,10 +434,23 @@ export function Discovery() {
425
434
  });
426
435
 
427
436
  await connBroker.connect(server.peerId, establish);
437
+
438
+ // For a repo deep link, ask the daemon to provision (run .codehost/setup.sh
439
+ // and hand back the authoritative workspace path) before opening. Streams
440
+ // the log under the "provisioning" state. Daemons without the route (older
441
+ // builds) return no path → fall back to the browser-computed folder.
442
+ if (repoTarget) {
443
+ setConnState("provisioning");
444
+ setProvisionLog("");
445
+ const ws = await runProvision(server.peerId, repoTarget);
446
+ if (activePeerRef.current !== server.peerId) return; // cancelled/switched mid-provision
447
+ if (ws) openFolder = ws;
448
+ }
449
+
428
450
  setConnState("connected");
429
451
  // The daemon no longer sets a default folder (current VS Code serve-web
430
452
  // dropped that flag), so open the served workspace from here: the
431
- // deep-link folder if we have one, else the server's reported cwd.
453
+ // provisioned/deep-link folder if we have one, else the server's reported cwd.
432
454
  activeFolderRef.current = openFolder;
433
455
  setIframeSrc(`/vs/${server.peerId}/${folderQuery(openFolder)}`);
434
456
  setResolving(null);
@@ -446,6 +468,48 @@ export function Discovery() {
446
468
  }
447
469
  }
448
470
 
471
+ // Ask the daemon to provision a repo workspace over the tunnel: stream
472
+ // setup.sh's output into `provisionLog` and return the daemon-authoritative
473
+ // path (the `x-codehost-workspace` header). Returns null when the daemon has
474
+ // no provision route (older build) or the call fails — caller falls back.
475
+ async function runProvision(peerId: string, t: RepoTarget): Promise<string | null> {
476
+ const params = new URLSearchParams({
477
+ owner: t.owner,
478
+ repo: t.name,
479
+ branch: t.branch ?? DEFAULT_BRANCH,
480
+ host: t.host,
481
+ });
482
+ let res: Response;
483
+ try {
484
+ res = await connBroker.tunnelFor(peerId).fetch("GET", `/__codehost/provision?${params}`, {});
485
+ } catch {
486
+ return null;
487
+ }
488
+ const ws = res.headers.get("x-codehost-workspace");
489
+ if (!ws) {
490
+ await res.body?.cancel().catch(() => {});
491
+ return null;
492
+ }
493
+ let buf = `[codehost] provisioning ${t.owner}/${t.name}@${t.branch ?? DEFAULT_BRANCH}…\n`;
494
+ setProvisionLog(buf);
495
+ if (res.body) {
496
+ const reader = res.body.getReader();
497
+ const dec = new TextDecoder();
498
+ try {
499
+ for (;;) {
500
+ const { done, value } = await reader.read();
501
+ if (done) break;
502
+ buf += dec.decode(value, { stream: true });
503
+ // Hide the internal exit sentinel from the displayed log.
504
+ setProvisionLog(buf.replace(/\n::codehost:exit=\d+\n?/, "\n"));
505
+ }
506
+ } catch {
507
+ // stream interrupted (channel closed) — return the path anyway
508
+ }
509
+ }
510
+ return ws;
511
+ }
512
+
449
513
  // Shareable deep-link pathname for a server+folder, with no side effects (no
450
514
  // token — Share adds that). Keeps an existing deep-link path as-is; otherwise
451
515
  // derives /gh|/git|/dev from the server's repo identity or opened folder.
@@ -493,7 +557,7 @@ export function Discovery() {
493
557
  const match = allServers.find((x) => x.server.peerId === res.peerId);
494
558
  if (!match) return;
495
559
  resolvedRef.current = true;
496
- void connectTo(match.server, match.room, res.folder);
560
+ void connectTo(match.server, match.room, res.folder, false, dl.type === "repo" ? dl.target : undefined);
497
561
  return;
498
562
  }
499
563
  // No deep link, but a token arrived via the URL: open that room's server
@@ -563,7 +627,7 @@ export function Discovery() {
563
627
  const target = findServerForDeepLink(dl);
564
628
  if (!target) return; // its server isn't present (yet) — wait for it to appear
565
629
  if (activePeerRef.current === target.server.peerId && connStateRef.current === "connected") return;
566
- void connectTo(target.server, target.room, target.folder, true);
630
+ void connectTo(target.server, target.room, target.folder, true, dl.type === "repo" ? dl.target : undefined);
567
631
  }
568
632
 
569
633
  function disconnect() {
@@ -630,6 +694,27 @@ export function Discovery() {
630
694
  />
631
695
  ));
632
696
 
697
+ // Provisioning view: the daemon's setup.sh is running; stream its log.
698
+ if (connState === "provisioning") {
699
+ return (
700
+ <>
701
+ {roomClients}
702
+ <div style={styles.page}>
703
+ <header style={styles.header}>
704
+ <span style={styles.brand}>codehost</span>
705
+ <span style={styles.dim}>·</span>
706
+ <span style={styles.dim}>provisioning…</span>
707
+ <span style={{ flex: 1 }} />
708
+ <button style={styles.connectBtn} onClick={disconnect}>
709
+ Cancel
710
+ </button>
711
+ </header>
712
+ <pre style={styles.provLog}>{provisionLog || "starting…"}</pre>
713
+ </div>
714
+ </>
715
+ );
716
+ }
717
+
633
718
  // Connected view: VS Code in an iframe, served over the tunnel.
634
719
  if (iframeSrc && connState === "connected") {
635
720
  return (
@@ -910,4 +995,5 @@ const styles: Record<string, React.CSSProperties> = {
910
995
  echo: { marginTop: 6, fontSize: 12, color: "#4ec9b0", fontFamily: "monospace" },
911
996
  connectBtn: { background: "#0e639c", border: "none", color: "#fff", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
912
997
  shareBtn: { background: "transparent", border: "1px solid #3d3d3d", color: "#ccc", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
998
+ 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" },
913
999
  };