codehost 0.14.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 +17 -0
- package/docs/provisioning.md +130 -0
- package/package.json +2 -1
- package/src/cli/commands/init.ts +27 -0
- package/src/cli/commands/serve.ts +2 -1
- 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/provision-server.test.ts +76 -0
- package/src/cli/provision-server.ts +178 -0
- package/src/cli/run-server.ts +10 -1
- package/src/cli/tunnel.ts +13 -6
- package/src/shared/provision.test.ts +79 -0
- package/src/shared/provision.ts +67 -0
- package/src/shared/repo.test.ts +48 -1
- package/src/shared/repo.ts +39 -2
- package/src/web/discovery.tsx +136 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
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
|
+
|
|
11
|
+
# [0.15.0](https://github.com/snomiao/codehost/compare/v0.14.0...v0.15.0) (2026-06-10)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
* **web:** 'open a GitHub URL' box + deepest-root repo resolution ([0d9ebec](https://github.com/snomiao/codehost/commit/0d9ebec4409430c327e2945657b6803ca704fd53))
|
|
17
|
+
|
|
1
18
|
# [0.14.0](https://github.com/snomiao/codehost/compare/v0.13.0...v0.14.0) (2026-06-10)
|
|
2
19
|
|
|
3
20
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Workspace provisioning
|
|
2
|
+
|
|
3
|
+
Status: **design / proposal** (not implemented). Captures the design discussed
|
|
4
|
+
for "open a repo link → the workspace materializes on the host".
|
|
5
|
+
|
|
6
|
+
## Goal
|
|
7
|
+
|
|
8
|
+
Opening a repo deep link should *materialize* the workspace on the serving host
|
|
9
|
+
on demand — clone / worktree-add / install — instead of requiring it to already
|
|
10
|
+
exist. Today, opening `/gh/<owner>/<repo>/tree/<branch>` when that worktree isn't
|
|
11
|
+
present just fails (`serve-web` reports "workspace does not exist").
|
|
12
|
+
|
|
13
|
+
The host owner stays in control: all provisioning is a **user-authored,
|
|
14
|
+
idempotent script** on the host. codehost never invents commands.
|
|
15
|
+
|
|
16
|
+
## Model
|
|
17
|
+
|
|
18
|
+
`codehost serve` runs in a *home* directory (`$HOME_ROOT`):
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
$HOME_ROOT/
|
|
22
|
+
├── .codehost/ # config dir — edited in VS Code itself
|
|
23
|
+
│ ├── config.yaml # settings (workspace path template, allowlist…)
|
|
24
|
+
│ ├── setup.sh / setup.bat # idempotent provisioning, run on every open
|
|
25
|
+
│ └── … # any other files the user keeps here
|
|
26
|
+
└── ws/ # default workspaces root (redefinable in config.yaml)
|
|
27
|
+
└── <owner>/<repo>/tree/<branch>/
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- `.codehost/` is the config folder. `ws/` is where github-shaped paths land.
|
|
31
|
+
- Default layout: `ws/{owner}/{repo}/tree/{branch}` (the `tree/<branch>` worktree
|
|
32
|
+
convention — branches are real side-by-side directories).
|
|
33
|
+
- The workspace path is redefinable in `config.yaml`.
|
|
34
|
+
|
|
35
|
+
## Open flow
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
open /gh/<owner>/<repo>/tree/<branch>
|
|
39
|
+
→ daemon runs .codehost/setup.sh with owner/repo/branch (+ host)
|
|
40
|
+
setup.sh decides: clone / git worktree add / pull / rebase / skip
|
|
41
|
+
(idempotent — the policy, incl. auto-upgrade/rebase, is the user's)
|
|
42
|
+
→ VS Code opens the resulting workspace path
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Because `setup.sh` is idempotent and runs **every** time, codehost does not track
|
|
46
|
+
clone state at all — the script clones if missing, fast-skips if present, and the
|
|
47
|
+
user decides whether to auto-pull/rebase. This removes a lot of codehost logic.
|
|
48
|
+
|
|
49
|
+
## `setup.sh` contract
|
|
50
|
+
|
|
51
|
+
- **Input** (env, never interpolated into a command string):
|
|
52
|
+
`CODEHOST_OWNER`, `CODEHOST_REPO`, `CODEHOST_BRANCH`, `CODEHOST_HOST`,
|
|
53
|
+
`CODEHOST_HOME` (the home root), `CODEHOST_WS` (the resolved default path from
|
|
54
|
+
the `config.yaml` template).
|
|
55
|
+
- **Output**: the absolute workspace path on stdout (last line). If it prints
|
|
56
|
+
nothing, codehost uses `CODEHOST_WS` (the template default).
|
|
57
|
+
- **Exit non-zero** → provisioning failed; surface the error to the browser.
|
|
58
|
+
- Must be **idempotent** and fast on an already-provisioned path (skip).
|
|
59
|
+
- **Windows**: `setup.bat` (or pwsh) with the same env contract.
|
|
60
|
+
|
|
61
|
+
## `config.yaml` (sketch)
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
workspace: "ws/{owner}/{repo}/tree/{branch}" # path template, relative to home
|
|
65
|
+
allowlist: # which repos may auto-provision
|
|
66
|
+
- github.com/snomiao/*
|
|
67
|
+
# …future: default branch, install hook, etc.
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Reuse VS Code for config (no custom UI)
|
|
71
|
+
|
|
72
|
+
Editing config = open `$HOME_ROOT/.codehost/` in the **existing VS Code iframe**.
|
|
73
|
+
A "Config" affordance in the header opens `?folder=<home>/.codehost`. No
|
|
74
|
+
`/p/<peer-id>/` settings page, no re-invented editor. (Peer ids are ephemeral —
|
|
75
|
+
they change on every daemon restart — so config is keyed by the host/home, not
|
|
76
|
+
the peer.)
|
|
77
|
+
|
|
78
|
+
## Create-from-GitHub input box
|
|
79
|
+
|
|
80
|
+
On the workspace list page, an input: **paste a GitHub URL**.
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
https://github.com/snomiao/codehost/tree/main
|
|
84
|
+
→ parse → /gh/snomiao/codehost/tree/main → open (triggers provisioning)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
One paste opens any GitHub repo on a connected host (= "create workspace from
|
|
88
|
+
GitHub"). Accepts bare `github.com/<owner>/<repo>` and `…/tree/<branch>` forms.
|
|
89
|
+
|
|
90
|
+
## Provisioning UX
|
|
91
|
+
|
|
92
|
+
- While `setup.sh` runs, show a **"provisioning…"** state with the script's
|
|
93
|
+
stdout/stderr **streamed over the tunnel** (clone/install can take a while).
|
|
94
|
+
- Idempotent re-opens skip and are near-instant.
|
|
95
|
+
|
|
96
|
+
## Security (the crux)
|
|
97
|
+
|
|
98
|
+
- **Commands are host-authored** (`setup.sh` lives on the host). The link/viewer
|
|
99
|
+
supplies only `owner/repo/branch` identity — never commands.
|
|
100
|
+
- codehost **sanitizes** `owner/repo/branch` before handing them to `setup.sh`
|
|
101
|
+
(reject `;`, `$()`, backticks, newlines, `..`, path separators where not
|
|
102
|
+
expected) and passes them as **env**, not string-interpolated into a command.
|
|
103
|
+
- **allowlist** (`config.yaml` / `setup.sh`) gates which repos auto-provision;
|
|
104
|
+
others prompt or are denied.
|
|
105
|
+
- Any room member can trigger `setup.sh` with any identity, so the **room token
|
|
106
|
+
is the trust boundary** and `setup.sh`/allowlist owns repo policy. Editing
|
|
107
|
+
config (= changing what runs on the host) may warrant owner auth beyond the
|
|
108
|
+
room token — open question.
|
|
109
|
+
|
|
110
|
+
## Defaults / scaffolding
|
|
111
|
+
|
|
112
|
+
- No `.codehost/setup.sh` → fall back to today's behavior (resolve to the layout
|
|
113
|
+
path, no clone).
|
|
114
|
+
- `codehost init` scaffolds a starter `.codehost/` (`config.yaml` + a `setup.sh`
|
|
115
|
+
that does `gh repo clone` + `git worktree add` + the `ws/` convention).
|
|
116
|
+
|
|
117
|
+
## Related fix (independent, same path)
|
|
118
|
+
|
|
119
|
+
`resolveRepoTarget` picks the *first* root daemon without checking which one
|
|
120
|
+
actually contains the repo, so with multiple roots it can pick the wrong one
|
|
121
|
+
(observed live: it chose `/Users/sno` over the correct `/Users/sno/ws`, yielding
|
|
122
|
+
a non-existent subpath). Prefer the **deepest matching root** (longest `cwd`
|
|
123
|
+
prefix). Not part of provisioning, but it gates the same "open a repo" path.
|
|
124
|
+
|
|
125
|
+
## Open questions
|
|
126
|
+
|
|
127
|
+
1. Source of truth for the path: `setup.sh` stdout vs `config.yaml` template.
|
|
128
|
+
(Proposed: stdout wins; the template is the default handed in as `CODEHOST_WS`.)
|
|
129
|
+
2. Owner auth for editing config beyond the room token?
|
|
130
|
+
3. Log streaming transport: a new tunnel channel vs reuse of an existing one.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codehost",
|
|
3
|
-
"version": "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
|
+
});
|
package/src/cli/init.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cli/run-server.ts
CHANGED
|
@@ -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(
|
|
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 =
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
}
|
package/src/shared/repo.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { parseDeepLink, pickRoomMatch, repoKey, resolveDevTarget, shareableDeepLink, toPosixPath } from "./repo";
|
|
2
|
+
import { gitUrlToPath, parseDeepLink, pickRoomMatch, repoKey, resolveDevTarget, resolveRepoTarget, shareableDeepLink, toPosixPath } from "./repo";
|
|
3
3
|
import type { PeerInfo } from "./signaling";
|
|
4
4
|
import { parseGitRemote } from "../cli/git";
|
|
5
5
|
|
|
@@ -194,6 +194,53 @@ describe("shareableDeepLink", () => {
|
|
|
194
194
|
});
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
describe("resolveRepoTarget root selection", () => {
|
|
198
|
+
const root = (peerId: string, cwd: string): PeerInfo => ({
|
|
199
|
+
peerId,
|
|
200
|
+
role: "server",
|
|
201
|
+
meta: { name: peerId, host: "Mac", cwd, kind: "root" },
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("among nested roots, picks the deepest (longest cwd)", () => {
|
|
205
|
+
const servers = [root("shallow", "/Users/sno"), root("deep", "/Users/sno/ws")];
|
|
206
|
+
const res = resolveRepoTarget(servers, { host: "github.com", owner: "snomiao", name: "codehost" });
|
|
207
|
+
expect(res?.peerId).toBe("deep");
|
|
208
|
+
expect(res?.folder).toBe("/Users/sno/ws/snomiao/codehost/tree/main");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("an exact repo daemon still wins over any root", () => {
|
|
212
|
+
const servers: PeerInfo[] = [
|
|
213
|
+
root("deep", "/Users/sno/ws"),
|
|
214
|
+
{ peerId: "exact", role: "server", meta: { name: "x", host: "Mac", cwd: "/x", repo: "github.com/snomiao/codehost" } },
|
|
215
|
+
];
|
|
216
|
+
expect(resolveRepoTarget(servers, { host: "github.com", owner: "snomiao", name: "codehost" })?.peerId).toBe("exact");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("gitUrlToPath", () => {
|
|
221
|
+
test("github URLs -> /gh, preserving branch (incl. slashes)", () => {
|
|
222
|
+
expect(gitUrlToPath("https://github.com/snomiao/codehost")).toBe("/gh/snomiao/codehost");
|
|
223
|
+
expect(gitUrlToPath("https://github.com/snomiao/codehost/tree/main")).toBe("/gh/snomiao/codehost/tree/main");
|
|
224
|
+
expect(gitUrlToPath("github.com/snomiao/codehost")).toBe("/gh/snomiao/codehost");
|
|
225
|
+
expect(gitUrlToPath("https://github.com/snomiao/codehost.git")).toBe("/gh/snomiao/codehost");
|
|
226
|
+
expect(gitUrlToPath("https://github.com/snomiao/codehost/tree/feat/x")).toBe("/gh/snomiao/codehost/tree/feat/x");
|
|
227
|
+
expect(gitUrlToPath("https://github.com/snomiao/codehost/")).toBe("/gh/snomiao/codehost");
|
|
228
|
+
expect(gitUrlToPath("https://github.com/snomiao/codehost?tab=readme#x")).toBe("/gh/snomiao/codehost");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("other hosts + scp form -> /git/<host>/...", () => {
|
|
232
|
+
expect(gitUrlToPath("https://gitlab.com/group/proj/tree/dev")).toBe("/git/gitlab.com/group/proj/tree/dev");
|
|
233
|
+
expect(gitUrlToPath("git@github.com:snomiao/codehost.git")).toBe("/gh/snomiao/codehost");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("non-repo / junk -> null", () => {
|
|
237
|
+
expect(gitUrlToPath("")).toBeNull();
|
|
238
|
+
expect(gitUrlToPath("not a url")).toBeNull();
|
|
239
|
+
expect(gitUrlToPath("https://github.com/snomiao")).toBeNull(); // no repo
|
|
240
|
+
expect(gitUrlToPath("/gh/snomiao/codehost")).toBeNull(); // already a deep link, no host
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
197
244
|
describe("pickRoomMatch (cross-room ranking)", () => {
|
|
198
245
|
const exact = { token: "tA", resolution: { peerId: "p1" } }; // no folder = exact
|
|
199
246
|
const root = { token: "tB", resolution: { peerId: "p2", folder: "/work/me/repo" } };
|
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
|
|
@@ -137,6 +147,28 @@ export function shareableDeepLink(opts: {
|
|
|
137
147
|
return null;
|
|
138
148
|
}
|
|
139
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Turn a pasted git repo URL into a codehost deep-link path: github.com ->
|
|
152
|
+
* `/gh/<owner>/<repo>`, any other host -> `/git/<host>/<owner>/<repo>`,
|
|
153
|
+
* preserving `/tree/<branch>`. Accepts with or without a scheme, an `scp`-style
|
|
154
|
+
* `git@host:owner/repo`, a trailing `.git`, query/hash, and a branch containing
|
|
155
|
+
* slashes. Returns null when it isn't a recognizable repo URL — lets the "open a
|
|
156
|
+
* GitHub URL" box reuse the same resolution as a typed deep link.
|
|
157
|
+
*/
|
|
158
|
+
export function gitUrlToPath(input: string): string | null {
|
|
159
|
+
let s = input.trim();
|
|
160
|
+
if (!s) return null;
|
|
161
|
+
s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""); // scheme://
|
|
162
|
+
s = s.replace(/^[^@/]+@/, ""); // user@ (incl. git@)
|
|
163
|
+
s = s.replace(/^([^/:]+):(?!\d)/, "$1/"); // scp-style host:owner/repo -> host/owner/repo
|
|
164
|
+
s = s.split(/[?#]/)[0].replace(/\/+$/, ""); // drop query/hash + trailing slash
|
|
165
|
+
const m = s.match(/^([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/(.+))?$/);
|
|
166
|
+
if (!m) return null;
|
|
167
|
+
const host = m[1].toLowerCase();
|
|
168
|
+
if (!host.includes(".")) return null; // require a real hostname (github.com)
|
|
169
|
+
return shareableDeepLink({ repo: `${host}/${m[2]}/${m[3]}`, branch: m[4] });
|
|
170
|
+
}
|
|
171
|
+
|
|
140
172
|
export interface Resolution {
|
|
141
173
|
peerId: string;
|
|
142
174
|
/** Folder to open via ?folder= (root kind); undefined opens the repo as-is. */
|
|
@@ -146,7 +178,10 @@ export interface Resolution {
|
|
|
146
178
|
/**
|
|
147
179
|
* Pick the best live server for a repo deep link. Prefers an exact `repo`
|
|
148
180
|
* daemon; otherwise falls back to a `root` daemon that can open the subfolder.
|
|
149
|
-
*
|
|
181
|
+
* Among several roots, prefers the **deepest** (longest cwd) — with a nested
|
|
182
|
+
* setup like /Users/sno and /Users/sno/ws both serving, the layout subfolder
|
|
183
|
+
* exists under the deeper one (observed: /gh/snomiao/codehost belongs to
|
|
184
|
+
* /Users/sno/ws/..., not /Users/sno/...). Returns null if nothing matches.
|
|
150
185
|
*/
|
|
151
186
|
export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Resolution | null {
|
|
152
187
|
const key = repoKey(target);
|
|
@@ -155,7 +190,9 @@ export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Reso
|
|
|
155
190
|
);
|
|
156
191
|
if (repoMatch) return { peerId: repoMatch.peerId };
|
|
157
192
|
|
|
158
|
-
const root = servers
|
|
193
|
+
const root = servers
|
|
194
|
+
.filter((s) => s.meta?.kind === "root")
|
|
195
|
+
.sort((a, b) => (b.meta?.cwd.length ?? 0) - (a.meta?.cwd.length ?? 0))[0];
|
|
159
196
|
if (root && root.meta) {
|
|
160
197
|
const folder = `${trimSlash(root.meta.cwd)}/${fillLayout(root.meta.layout || DEFAULT_LAYOUT, target)}`;
|
|
161
198
|
return { peerId: root.peerId, folder };
|
package/src/web/discovery.tsx
CHANGED
|
@@ -10,7 +10,9 @@ import { connBroker } from "./conn-broker";
|
|
|
10
10
|
import {
|
|
11
11
|
DEFAULT_BRANCH,
|
|
12
12
|
type DeepLink,
|
|
13
|
+
type RepoTarget,
|
|
13
14
|
type RoomMatch,
|
|
15
|
+
gitUrlToPath,
|
|
14
16
|
parseDeepLink,
|
|
15
17
|
pickRoomMatch,
|
|
16
18
|
repoKey,
|
|
@@ -23,7 +25,7 @@ import { deriveTags, matchQuery, shortRoomLabel, tagKey } from "../shared/tags";
|
|
|
23
25
|
|
|
24
26
|
const TOKEN_KEY = "codehost.token";
|
|
25
27
|
|
|
26
|
-
type ConnState = "idle" | "connecting" | "connected" | "failed";
|
|
28
|
+
type ConnState = "idle" | "connecting" | "provisioning" | "connected" | "failed";
|
|
27
29
|
|
|
28
30
|
/** A server discovered in a specific room (its token routes the signaling). */
|
|
29
31
|
type RoomedServer = { server: PeerInfo; room: string };
|
|
@@ -162,6 +164,12 @@ export function Discovery() {
|
|
|
162
164
|
const [editingToken, setEditingToken] = useState(false);
|
|
163
165
|
const [tokenError, setTokenError] = useState<string | null>(null);
|
|
164
166
|
|
|
167
|
+
// "Open a GitHub repo" box: paste a github.com URL -> navigate to its /gh/
|
|
168
|
+
// deep link, which resolves/opens (and, once provisioning lands, materializes)
|
|
169
|
+
// the workspace.
|
|
170
|
+
const [ghUrl, setGhUrl] = useState("");
|
|
171
|
+
const [ghError, setGhError] = useState<string | null>(null);
|
|
172
|
+
|
|
165
173
|
// Fake-tag filter over the merged workspace list: a free-text box plus a set
|
|
166
174
|
// of pinned tag tokens (chips). Both feed the same `ay ls`-style AND matcher.
|
|
167
175
|
const [filter, setFilter] = useState("");
|
|
@@ -173,6 +181,8 @@ export function Discovery() {
|
|
|
173
181
|
const [activePeerId, setActivePeerId] = useState<string | null>(null);
|
|
174
182
|
const [connState, setConnState] = useState<ConnState>("idle");
|
|
175
183
|
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
|
184
|
+
// Streamed setup.sh output shown while connState === "provisioning".
|
|
185
|
+
const [provisionLog, setProvisionLog] = useState("");
|
|
176
186
|
|
|
177
187
|
const rtcRef = useRef<RtcClient | null>(null);
|
|
178
188
|
const activePeerRef = useRef<string | null>(null);
|
|
@@ -312,6 +322,22 @@ export function Discovery() {
|
|
|
312
322
|
setEditingToken(false);
|
|
313
323
|
}
|
|
314
324
|
|
|
325
|
+
function openGithubUrl(e: React.FormEvent) {
|
|
326
|
+
e.preventDefault();
|
|
327
|
+
const path = gitUrlToPath(ghUrl);
|
|
328
|
+
if (!path) {
|
|
329
|
+
setGhError("not a recognizable git repo URL");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
setGhError(null);
|
|
333
|
+
setGhUrl("");
|
|
334
|
+
// Navigate to the deep link and reconcile: connect if a daemon already
|
|
335
|
+
// serves it (provisioning, later, will materialize it when it doesn't).
|
|
336
|
+
history.pushState(null, "", path);
|
|
337
|
+
setResolving(deepLinkLabel(parseDeepLink(path)));
|
|
338
|
+
syncToUrl();
|
|
339
|
+
}
|
|
340
|
+
|
|
315
341
|
function leaveRoom(t: string) {
|
|
316
342
|
if (activeRoomRef.current === t) disconnect();
|
|
317
343
|
setTokens((prev) => prev.filter((x) => x !== t));
|
|
@@ -328,7 +354,13 @@ export function Discovery() {
|
|
|
328
354
|
sendersRef.current.delete(t);
|
|
329
355
|
}
|
|
330
356
|
|
|
331
|
-
async function connectTo(
|
|
357
|
+
async function connectTo(
|
|
358
|
+
server: PeerInfo,
|
|
359
|
+
room: string,
|
|
360
|
+
folder?: string,
|
|
361
|
+
fromHistory = false,
|
|
362
|
+
repoTarget?: RepoTarget,
|
|
363
|
+
) {
|
|
332
364
|
const send = sendersRef.current.get(room);
|
|
333
365
|
if (!send) return;
|
|
334
366
|
dialingRef.current = true; // synchronous gate against concurrent triggers
|
|
@@ -351,7 +383,7 @@ export function Discovery() {
|
|
|
351
383
|
// handshake) and push a history entry, so Back returns to the list and
|
|
352
384
|
// Forward returns here. When `fromHistory`, the browser already set the URL
|
|
353
385
|
// (back/forward/reconnect) — don't push again, but a prior entry exists.
|
|
354
|
-
|
|
386
|
+
let openFolder = folder ?? server.meta?.cwd;
|
|
355
387
|
if (fromHistory) {
|
|
356
388
|
pushedRef.current = true;
|
|
357
389
|
sharePathRef.current = window.location.pathname;
|
|
@@ -402,10 +434,23 @@ export function Discovery() {
|
|
|
402
434
|
});
|
|
403
435
|
|
|
404
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
|
+
|
|
405
450
|
setConnState("connected");
|
|
406
451
|
// The daemon no longer sets a default folder (current VS Code serve-web
|
|
407
452
|
// dropped that flag), so open the served workspace from here: the
|
|
408
|
-
// 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.
|
|
409
454
|
activeFolderRef.current = openFolder;
|
|
410
455
|
setIframeSrc(`/vs/${server.peerId}/${folderQuery(openFolder)}`);
|
|
411
456
|
setResolving(null);
|
|
@@ -423,6 +468,48 @@ export function Discovery() {
|
|
|
423
468
|
}
|
|
424
469
|
}
|
|
425
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
|
+
|
|
426
513
|
// Shareable deep-link pathname for a server+folder, with no side effects (no
|
|
427
514
|
// token — Share adds that). Keeps an existing deep-link path as-is; otherwise
|
|
428
515
|
// derives /gh|/git|/dev from the server's repo identity or opened folder.
|
|
@@ -470,7 +557,7 @@ export function Discovery() {
|
|
|
470
557
|
const match = allServers.find((x) => x.server.peerId === res.peerId);
|
|
471
558
|
if (!match) return;
|
|
472
559
|
resolvedRef.current = true;
|
|
473
|
-
void connectTo(match.server, match.room, res.folder);
|
|
560
|
+
void connectTo(match.server, match.room, res.folder, false, dl.type === "repo" ? dl.target : undefined);
|
|
474
561
|
return;
|
|
475
562
|
}
|
|
476
563
|
// No deep link, but a token arrived via the URL: open that room's server
|
|
@@ -540,7 +627,7 @@ export function Discovery() {
|
|
|
540
627
|
const target = findServerForDeepLink(dl);
|
|
541
628
|
if (!target) return; // its server isn't present (yet) — wait for it to appear
|
|
542
629
|
if (activePeerRef.current === target.server.peerId && connStateRef.current === "connected") return;
|
|
543
|
-
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);
|
|
544
631
|
}
|
|
545
632
|
|
|
546
633
|
function disconnect() {
|
|
@@ -607,6 +694,27 @@ export function Discovery() {
|
|
|
607
694
|
/>
|
|
608
695
|
));
|
|
609
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
|
+
|
|
610
718
|
// Connected view: VS Code in an iframe, served over the tunnel.
|
|
611
719
|
if (iframeSrc && connState === "connected") {
|
|
612
720
|
return (
|
|
@@ -735,6 +843,26 @@ export function Discovery() {
|
|
|
735
843
|
</>
|
|
736
844
|
)}
|
|
737
845
|
|
|
846
|
+
{tokens.length > 0 && (
|
|
847
|
+
<>
|
|
848
|
+
<form onSubmit={openGithubUrl} style={styles.ghForm}>
|
|
849
|
+
<input
|
|
850
|
+
value={ghUrl}
|
|
851
|
+
onChange={(e) => {
|
|
852
|
+
setGhUrl(e.target.value);
|
|
853
|
+
if (ghError) setGhError(null);
|
|
854
|
+
}}
|
|
855
|
+
placeholder="open a repo… paste a github.com URL"
|
|
856
|
+
style={styles.input}
|
|
857
|
+
/>
|
|
858
|
+
<button type="submit" style={styles.button}>
|
|
859
|
+
Open
|
|
860
|
+
</button>
|
|
861
|
+
</form>
|
|
862
|
+
{ghError && <p style={styles.tokenError}>{ghError}</p>}
|
|
863
|
+
</>
|
|
864
|
+
)}
|
|
865
|
+
|
|
738
866
|
<div style={styles.listHead}>
|
|
739
867
|
<h2 style={styles.h2}>Workspaces</h2>
|
|
740
868
|
{serverCount > 0 && (
|
|
@@ -836,6 +964,7 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
836
964
|
roomChipX: { background: "transparent", border: "none", color: "inherit", cursor: "pointer", fontSize: 11, padding: 0, lineHeight: 1 },
|
|
837
965
|
input: { flex: 1, background: "#252525", border: "1px solid #3d3d3d", color: "#eee", padding: "8px 10px", borderRadius: 6, fontSize: 13, outline: "none" },
|
|
838
966
|
button: { background: "#0e639c", border: "none", color: "#fff", padding: "8px 16px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
967
|
+
ghForm: { display: "flex", alignItems: "center", gap: 8, margin: "0 0 14px" },
|
|
839
968
|
listHead: { display: "flex", alignItems: "baseline", gap: 10, margin: "0 0 12px" },
|
|
840
969
|
h2: { fontSize: 14, color: "#aaa", fontWeight: 600, margin: 0 },
|
|
841
970
|
count: { fontSize: 12, color: "#888", fontFamily: "monospace" },
|
|
@@ -866,4 +995,5 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
866
995
|
echo: { marginTop: 6, fontSize: 12, color: "#4ec9b0", fontFamily: "monospace" },
|
|
867
996
|
connectBtn: { background: "#0e639c", border: "none", color: "#fff", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
868
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" },
|
|
869
999
|
};
|