codehost 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/package.json +3 -1
- package/src/cli/commands/dev.ts +32 -1
- package/src/cli/commands/expose.ts +2 -0
- package/src/cli/commands/init.ts +27 -0
- package/src/cli/commands/serve.ts +56 -11
- package/src/cli/config.test.ts +31 -0
- package/src/cli/config.ts +20 -7
- package/src/cli/daemonize.ts +4 -0
- package/src/cli/index.ts +2 -0
- package/src/cli/init.test.ts +48 -0
- package/src/cli/init.ts +103 -0
- package/src/cli/plugins/agent-yes.test.ts +71 -0
- package/src/cli/plugins/agent-yes.ts +116 -0
- package/src/cli/plugins/types.ts +39 -0
- package/src/cli/provision-server.test.ts +76 -0
- package/src/cli/provision-server.ts +186 -0
- package/src/cli/registry.test.ts +51 -0
- package/src/cli/registry.ts +93 -0
- package/src/cli/run-server.ts +62 -2
- package/src/cli/tunnel.ts +25 -7
- package/src/cli/vscode-install.ts +20 -3
- package/src/cli/workspaces.test.ts +65 -0
- package/src/cli/workspaces.ts +80 -0
- package/src/shared/provision.test.ts +79 -0
- package/src/shared/provision.ts +67 -0
- package/src/shared/repo.test.ts +110 -1
- package/src/shared/repo.ts +77 -19
- package/src/shared/signaling-client.ts +10 -0
- package/src/shared/signaling.ts +55 -1
- package/src/web/discovery.tsx +226 -44
- package/src/web/history.ts +4 -1
- package/src/web/room-client.ts +114 -0
- package/worker/room.ts +9 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { toPosixPath } from "../../shared/repo";
|
|
5
|
+
import type { AgentInfo } from "../../shared/signaling";
|
|
6
|
+
import type { DaemonPlugin } from "./types";
|
|
7
|
+
|
|
8
|
+
// agent-yes plugin: advertises the machine's live agent CLI sessions (read
|
|
9
|
+
// straight from agent-yes's registry, so it works even when `ay serve` is
|
|
10
|
+
// down) and proxies /__codehost/agent-yes/* to the local `ay serve` API with
|
|
11
|
+
// its bearer token injected. Granting room members agent control is not new
|
|
12
|
+
// trust: the room token already grants code execution (the editor is a
|
|
13
|
+
// terminal) — same boundary as provisioning (see shared/provision.ts).
|
|
14
|
+
|
|
15
|
+
const AY_DIR = join(homedir(), ".agent-yes");
|
|
16
|
+
const AY_PORT = Number(process.env.CODEHOST_AY_PORT) || 7432;
|
|
17
|
+
/** Cap the advertised list (meta rides the signaling room broadcast). */
|
|
18
|
+
const MAX_AGENTS = 50;
|
|
19
|
+
|
|
20
|
+
interface AyRecord {
|
|
21
|
+
pid: number;
|
|
22
|
+
cli?: string;
|
|
23
|
+
prompt?: string | null;
|
|
24
|
+
cwd?: string;
|
|
25
|
+
status?: "active" | "idle" | "exited";
|
|
26
|
+
started_at?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** agent-yes's global registry: JSONL, last line per pid wins. */
|
|
30
|
+
export function readAgents(dir: string = AY_DIR): AgentInfo[] {
|
|
31
|
+
let raw: string;
|
|
32
|
+
try {
|
|
33
|
+
raw = readFileSync(join(dir, "pids.jsonl"), "utf8");
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const byPid = new Map<number, AyRecord>();
|
|
38
|
+
for (const line of raw.split("\n")) {
|
|
39
|
+
if (!line.trim()) continue;
|
|
40
|
+
try {
|
|
41
|
+
const rec = JSON.parse(line) as AyRecord;
|
|
42
|
+
if (typeof rec.pid === "number") byPid.set(rec.pid, { ...byPid.get(rec.pid), ...rec });
|
|
43
|
+
} catch {
|
|
44
|
+
// skip malformed lines
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const out: AgentInfo[] = [];
|
|
48
|
+
for (const rec of [...byPid.values()].sort((a, b) => (b.started_at ?? 0) - (a.started_at ?? 0))) {
|
|
49
|
+
if (out.length >= MAX_AGENTS) break;
|
|
50
|
+
if (rec.status === "exited" || !alive(rec.pid)) continue;
|
|
51
|
+
out.push({
|
|
52
|
+
pid: rec.pid,
|
|
53
|
+
tool: rec.cli || "agent",
|
|
54
|
+
...(rec.prompt ? { title: rec.prompt.slice(0, 120) } : {}),
|
|
55
|
+
cwd: toPosixPath(rec.cwd ?? ""),
|
|
56
|
+
state: rec.status === "active" ? "active" : "idle",
|
|
57
|
+
...(rec.started_at ? { startedAt: rec.started_at } : {}),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function alive(pid: number): boolean {
|
|
64
|
+
try {
|
|
65
|
+
process.kill(pid, 0);
|
|
66
|
+
return true;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function serveToken(dir: string): string | null {
|
|
73
|
+
try {
|
|
74
|
+
const t = readFileSync(join(dir, ".serve-token"), "utf8").trim();
|
|
75
|
+
return t || null;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The plugin, or null when this machine has never run agent-yes. */
|
|
82
|
+
export function agentYesPlugin(dir: string = AY_DIR): DaemonPlugin | null {
|
|
83
|
+
if (!existsSync(dir)) return null;
|
|
84
|
+
return {
|
|
85
|
+
name: "agent-yes",
|
|
86
|
+
meta: () => {
|
|
87
|
+
const agents = readAgents(dir);
|
|
88
|
+
return agents.length > 0 ? { agents } : {};
|
|
89
|
+
},
|
|
90
|
+
route: async (path, req) => {
|
|
91
|
+
const token = serveToken(dir);
|
|
92
|
+
if (!token) {
|
|
93
|
+
return new Response(JSON.stringify({ error: "ay serve has no token (is agent-yes installed?)" }), {
|
|
94
|
+
status: 503,
|
|
95
|
+
headers: { "content-type": "application/json" },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const headers = new Headers(req.headers);
|
|
99
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
100
|
+
headers.set("host", `127.0.0.1:${AY_PORT}`);
|
|
101
|
+
try {
|
|
102
|
+
return await fetch(`http://127.0.0.1:${AY_PORT}${path}`, {
|
|
103
|
+
method: req.method,
|
|
104
|
+
headers,
|
|
105
|
+
body: req.body as BodyInit | undefined,
|
|
106
|
+
redirect: "manual",
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
return new Response(JSON.stringify({ error: "ay serve is not running (start it with `ay serve install`)" }), {
|
|
110
|
+
status: 502,
|
|
111
|
+
headers: { "content-type": "application/json" },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PeerMeta } from "../../shared/signaling";
|
|
2
|
+
|
|
3
|
+
// Daemon plugin surface: a plugin contributes (a) fields merged into the
|
|
4
|
+
// advertised PeerMeta on every refresh, and (b) an HTTP route mounted under the
|
|
5
|
+
// tunnel at /__codehost/<name>/*. codehost stays the transport + registry;
|
|
6
|
+
// plugins (agent-yes first) own their domain.
|
|
7
|
+
|
|
8
|
+
export interface DaemonPlugin {
|
|
9
|
+
/** Identifier; also the tunnel mount: requests to /__codehost/<name>/* land
|
|
10
|
+
* in `route`. */
|
|
11
|
+
name: string;
|
|
12
|
+
/** Resource contribution, merged into PeerMeta by the daemon's meta builder
|
|
13
|
+
* (startup + every refresh). Keep it small — it rides room broadcasts. */
|
|
14
|
+
meta?: () => Partial<PeerMeta>;
|
|
15
|
+
/** Serve a tunneled request. `path` is the remainder after the mount, always
|
|
16
|
+
* starting with "/" and keeping the query string. */
|
|
17
|
+
route?: (path: string, req: { method: string; headers: Headers; body?: Uint8Array }) => Promise<Response>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Route a local tunnel request to the owning plugin, or null to fall through. */
|
|
21
|
+
export function routePlugins(
|
|
22
|
+
plugins: DaemonPlugin[],
|
|
23
|
+
req: { method: string; path: string; headers: Headers; body?: Uint8Array },
|
|
24
|
+
): Promise<Response> | null {
|
|
25
|
+
for (const p of plugins) {
|
|
26
|
+
if (!p.route) continue;
|
|
27
|
+
const mount = `/__codehost/${p.name}`;
|
|
28
|
+
if (req.path === mount || req.path.startsWith(`${mount}/`) || req.path.startsWith(`${mount}?`)) {
|
|
29
|
+
const rest = req.path.slice(mount.length) || "/";
|
|
30
|
+
return p.route(rest.startsWith("/") ? rest : `/${rest}`, req);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Merge every plugin's meta contribution into a base meta. */
|
|
37
|
+
export function withPluginMeta(base: PeerMeta, plugins: DaemonPlugin[]): PeerMeta {
|
|
38
|
+
return Object.assign({}, base, ...plugins.map((p) => p.meta?.() ?? {}));
|
|
39
|
+
}
|
|
@@ -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,186 @@
|
|
|
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
|
+
/** Called after a setup script finishes (any exit code) — lets the daemon
|
|
22
|
+
* re-enumerate + re-advertise its workspaces. */
|
|
23
|
+
onProvisioned?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CodehostConfig {
|
|
27
|
+
workspace?: string; // layout template, e.g. "ws/{owner}/{repo}/tree/{branch}"
|
|
28
|
+
allowlist?: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True for the provision route (ignoring the query string). */
|
|
32
|
+
export function isProvisionPath(path: string): boolean {
|
|
33
|
+
return path.split("?")[0] === PROVISION_PATH;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readCodehostConfig(homeDir: string): CodehostConfig {
|
|
37
|
+
try {
|
|
38
|
+
const raw = readFileSync(join(homeDir, ".codehost", "config.yaml"), "utf8");
|
|
39
|
+
const c = (parseYaml(raw) ?? {}) as Record<string, unknown>;
|
|
40
|
+
return {
|
|
41
|
+
workspace: typeof c.workspace === "string" ? c.workspace : undefined,
|
|
42
|
+
allowlist: Array.isArray(c.allowlist)
|
|
43
|
+
? c.allowlist.filter((x): x is string => typeof x === "string")
|
|
44
|
+
: undefined,
|
|
45
|
+
};
|
|
46
|
+
} catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Locate the host's setup script (platform-appropriate), or null if none — in
|
|
52
|
+
* which case provisioning is a no-op and we just return the path. */
|
|
53
|
+
function findSetupScript(homeDir: string): string[] | null {
|
|
54
|
+
const dir = join(homeDir, ".codehost");
|
|
55
|
+
if (process.platform === "win32") {
|
|
56
|
+
const bat = join(dir, "setup.bat");
|
|
57
|
+
if (existsSync(bat)) return ["cmd", "/c", bat];
|
|
58
|
+
const ps1 = join(dir, "setup.ps1");
|
|
59
|
+
if (existsSync(ps1)) return ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ps1];
|
|
60
|
+
}
|
|
61
|
+
const sh = join(dir, "setup.sh");
|
|
62
|
+
if (existsSync(sh)) return ["bash", sh];
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Per-workspace coalescing: a concurrent open of the same target waits for the
|
|
67
|
+
// running provision instead of spawning a second one.
|
|
68
|
+
const inFlight = new Map<string, Promise<number>>();
|
|
69
|
+
|
|
70
|
+
export async function handleProvision(rawPath: string, deps: ProvisionDeps): Promise<Response> {
|
|
71
|
+
const url = new URL(`http://x${rawPath}`);
|
|
72
|
+
const v = validateProvisionTarget(
|
|
73
|
+
url.searchParams.get("owner") ?? "",
|
|
74
|
+
url.searchParams.get("repo") ?? "",
|
|
75
|
+
url.searchParams.get("branch") ?? "",
|
|
76
|
+
);
|
|
77
|
+
if (!v.ok) return json(400, { error: v.reason });
|
|
78
|
+
|
|
79
|
+
const host = (url.searchParams.get("host") ?? deps.host).toLowerCase();
|
|
80
|
+
const cfg = readCodehostConfig(deps.homeDir);
|
|
81
|
+
const key = repoKey({ host, owner: v.target.owner, name: v.target.repo });
|
|
82
|
+
if (!repoAllowed(key, cfg.allowlist)) return json(403, { error: `repo not allowlisted: ${key}` });
|
|
83
|
+
|
|
84
|
+
const wsPosix = resolveWorkspacePath(toPosixPath(deps.homeDir), cfg.workspace ?? "", v.target);
|
|
85
|
+
const headers = { "x-codehost-workspace": wsPosix };
|
|
86
|
+
|
|
87
|
+
const cmd = findSetupScript(deps.homeDir);
|
|
88
|
+
if (!cmd) return json(200, { workspace: wsPosix }, headers); // no script: hand back the path
|
|
89
|
+
|
|
90
|
+
const lockKey = fromPosixPath(wsPosix);
|
|
91
|
+
const existing = inFlight.get(lockKey);
|
|
92
|
+
const body = existing
|
|
93
|
+
? coalescedBody(existing) // a provision is already running for this workspace
|
|
94
|
+
: freshBody(cmd, deps, v.target, host, lockKey);
|
|
95
|
+
return new Response(body, {
|
|
96
|
+
status: 200,
|
|
97
|
+
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", ...headers },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Spawn the setup script, streaming merged stdout+stderr, ending with an exit
|
|
102
|
+
* sentinel the browser parses for success/failure. */
|
|
103
|
+
function freshBody(
|
|
104
|
+
cmd: string[],
|
|
105
|
+
deps: ProvisionDeps,
|
|
106
|
+
target: ProvisionTarget,
|
|
107
|
+
host: string,
|
|
108
|
+
lockKey: string,
|
|
109
|
+
): ReadableStream<Uint8Array> {
|
|
110
|
+
const enc = new TextEncoder();
|
|
111
|
+
return new ReadableStream<Uint8Array>({
|
|
112
|
+
async start(controller) {
|
|
113
|
+
let resolveDone!: (code: number) => void;
|
|
114
|
+
inFlight.set(lockKey, new Promise<number>((r) => (resolveDone = r)));
|
|
115
|
+
const say = (s: string) => controller.enqueue(enc.encode(s));
|
|
116
|
+
say(`[codehost] provisioning ${host}/${target.owner}/${target.repo}@${target.branch}\n`);
|
|
117
|
+
let code = 1;
|
|
118
|
+
try {
|
|
119
|
+
const proc = Bun.spawn(cmd, {
|
|
120
|
+
cwd: deps.homeDir,
|
|
121
|
+
env: {
|
|
122
|
+
...process.env,
|
|
123
|
+
CODEHOST_OWNER: target.owner,
|
|
124
|
+
CODEHOST_REPO: target.repo,
|
|
125
|
+
CODEHOST_BRANCH: target.branch,
|
|
126
|
+
CODEHOST_HOST: host,
|
|
127
|
+
CODEHOST_HOME: deps.homeDir,
|
|
128
|
+
CODEHOST_WS: fromPosixPath(lockKey),
|
|
129
|
+
},
|
|
130
|
+
stdout: "pipe",
|
|
131
|
+
stderr: "pipe",
|
|
132
|
+
});
|
|
133
|
+
const timer = setTimeout(() => {
|
|
134
|
+
try {
|
|
135
|
+
proc.kill();
|
|
136
|
+
} catch {
|
|
137
|
+
// already gone
|
|
138
|
+
}
|
|
139
|
+
}, TIMEOUT_MS);
|
|
140
|
+
const pump = async (stream: ReadableStream<Uint8Array>) => {
|
|
141
|
+
const reader = stream.getReader();
|
|
142
|
+
for (;;) {
|
|
143
|
+
const { done, value } = await reader.read();
|
|
144
|
+
if (done) break;
|
|
145
|
+
controller.enqueue(value);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
await Promise.all([pump(proc.stdout), pump(proc.stderr)]);
|
|
149
|
+
code = await proc.exited;
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
say(`[codehost] provision error: ${String(err)}\n`);
|
|
153
|
+
} finally {
|
|
154
|
+
inFlight.delete(lockKey);
|
|
155
|
+
resolveDone(code);
|
|
156
|
+
say(`\n::codehost:exit=${code}\n`);
|
|
157
|
+
controller.close();
|
|
158
|
+
try {
|
|
159
|
+
deps.onProvisioned?.();
|
|
160
|
+
} catch {
|
|
161
|
+
// advertising is best-effort; never fail the provision response
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Attach to a running provision: wait for it, then emit the exit sentinel. */
|
|
169
|
+
function coalescedBody(existing: Promise<number>): ReadableStream<Uint8Array> {
|
|
170
|
+
const enc = new TextEncoder();
|
|
171
|
+
return new ReadableStream<Uint8Array>({
|
|
172
|
+
async start(controller) {
|
|
173
|
+
controller.enqueue(enc.encode("[codehost] provision already running for this workspace; waiting…\n"));
|
|
174
|
+
const code = await existing.catch(() => 1);
|
|
175
|
+
controller.enqueue(enc.encode(`\n::codehost:exit=${code}\n`));
|
|
176
|
+
controller.close();
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function json(status: number, obj: unknown, extra: Record<string, string> = {}): Response {
|
|
182
|
+
return new Response(JSON.stringify(obj), {
|
|
183
|
+
status,
|
|
184
|
+
headers: { "content-type": "application/json", "cache-control": "no-store", ...extra },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
clearDaemonPresence,
|
|
7
|
+
liveDaemon,
|
|
8
|
+
readRegisteredWorkspaces,
|
|
9
|
+
registerWorkspace,
|
|
10
|
+
writeDaemonPresence,
|
|
11
|
+
} from "./registry";
|
|
12
|
+
|
|
13
|
+
const tmp = () => mkdtempSync(join(tmpdir(), "codehost-registry-"));
|
|
14
|
+
|
|
15
|
+
describe("daemon presence", () => {
|
|
16
|
+
test("round-trips while the pid is alive; cleared file -> null", () => {
|
|
17
|
+
const file = join(tmp(), "daemon.json");
|
|
18
|
+
const p = { pid: process.pid, root: "/Users/x", token: "Str0ng-Token-99", startedAt: 1 };
|
|
19
|
+
writeDaemonPresence(p, file);
|
|
20
|
+
expect(liveDaemon(file)).toEqual(p);
|
|
21
|
+
clearDaemonPresence(file);
|
|
22
|
+
expect(liveDaemon(file)).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("a dead pid (stale kill -9 leftover) reads as no daemon", () => {
|
|
26
|
+
const file = join(tmp(), "daemon.json");
|
|
27
|
+
writeDaemonPresence({ pid: 99999999, root: "/x", token: "t0k-En-Stronk", startedAt: 1 }, file);
|
|
28
|
+
expect(liveDaemon(file)).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("garbage file reads as no daemon", () => {
|
|
32
|
+
const file = join(tmp(), "daemon.json");
|
|
33
|
+
writeFileSync(file, "not json");
|
|
34
|
+
expect(liveDaemon(file)).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("workspace registry", () => {
|
|
39
|
+
test("register is idempotent; missing dirs are filtered on read", () => {
|
|
40
|
+
const dir = tmp();
|
|
41
|
+
const file = join(dir, "workspaces.json");
|
|
42
|
+
const ws = join(dir, "proj");
|
|
43
|
+
mkdirSync(ws);
|
|
44
|
+
registerWorkspace(ws, file);
|
|
45
|
+
registerWorkspace(ws, file);
|
|
46
|
+
registerWorkspace(join(dir, "gone"), file); // never created on disk
|
|
47
|
+
const got = readRegisteredWorkspaces(file);
|
|
48
|
+
expect(got).toHaveLength(1);
|
|
49
|
+
expect(got[0].path).toBe(ws);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync, statSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
// One-daemon-per-host coordination (Phase 5 of the resource-model redesign).
|
|
6
|
+
// A single VS Code serve-web opens ANY local path via ?folder=, so one root
|
|
7
|
+
// daemon can carry every workspace on the machine: a later `codehost dev`
|
|
8
|
+
// REGISTERS its directory here and exits instead of spawning a second peer +
|
|
9
|
+
// VS Code. The daemon advertises registered dirs in meta.workspaces and
|
|
10
|
+
// re-reads this registry on fs.watch + the slow refresh tick.
|
|
11
|
+
|
|
12
|
+
export const CODEHOST_DIR = join(homedir(), ".codehost");
|
|
13
|
+
const DAEMON_FILE = join(CODEHOST_DIR, "daemon.json");
|
|
14
|
+
const WORKSPACES_FILE = join(CODEHOST_DIR, "workspaces.json");
|
|
15
|
+
|
|
16
|
+
/** Written by a foreground root daemon while it runs; staleness is detected by
|
|
17
|
+
* pid liveness, so a kill -9 leftover never blocks anything. */
|
|
18
|
+
export interface DaemonPresence {
|
|
19
|
+
pid: number;
|
|
20
|
+
/** Real OS path of the served root. */
|
|
21
|
+
root: string;
|
|
22
|
+
/** Room token the daemon registered with — later `dev` runs print a link
|
|
23
|
+
* into THIS room, since that's where the workspace will appear. */
|
|
24
|
+
token: string;
|
|
25
|
+
startedAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RegisteredWorkspace {
|
|
29
|
+
/** Real OS path. */
|
|
30
|
+
path: string;
|
|
31
|
+
addedAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function writeDaemonPresence(p: DaemonPresence, file: string = DAEMON_FILE): void {
|
|
35
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
36
|
+
writeFileSync(file, JSON.stringify(p, null, 2));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function clearDaemonPresence(file: string = DAEMON_FILE): void {
|
|
40
|
+
try {
|
|
41
|
+
rmSync(file);
|
|
42
|
+
} catch {
|
|
43
|
+
// already gone
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The live host daemon, or null (no file, unparsable, or its pid is dead). */
|
|
48
|
+
export function liveDaemon(file: string = DAEMON_FILE): DaemonPresence | null {
|
|
49
|
+
try {
|
|
50
|
+
const p = JSON.parse(readFileSync(file, "utf8")) as DaemonPresence;
|
|
51
|
+
if (typeof p.pid !== "number" || !p.root || !p.token) return null;
|
|
52
|
+
process.kill(p.pid, 0);
|
|
53
|
+
return p;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Add a directory to the host's workspace registry (idempotent). */
|
|
60
|
+
export function registerWorkspace(path: string, file: string = WORKSPACES_FILE): void {
|
|
61
|
+
const dir = resolve(path);
|
|
62
|
+
const all = readRegistry(file);
|
|
63
|
+
if (all.some((w) => w.path === dir)) return;
|
|
64
|
+
all.push({ path: dir, addedAt: Date.now() });
|
|
65
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
66
|
+
writeFileSync(file, JSON.stringify(all, null, 2));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Registered workspaces whose directories still exist. */
|
|
70
|
+
export function readRegisteredWorkspaces(file: string = WORKSPACES_FILE): RegisteredWorkspace[] {
|
|
71
|
+
return readRegistry(file).filter((w) => {
|
|
72
|
+
try {
|
|
73
|
+
return statSync(w.path).isDirectory();
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The registry path — daemons fs.watch its directory for instant re-advertise. */
|
|
81
|
+
export function workspacesFile(): string {
|
|
82
|
+
return WORKSPACES_FILE;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readRegistry(file: string): RegisteredWorkspace[] {
|
|
86
|
+
try {
|
|
87
|
+
if (!existsSync(file)) return [];
|
|
88
|
+
const arr = JSON.parse(readFileSync(file, "utf8"));
|
|
89
|
+
return Array.isArray(arr) ? arr.filter((w) => w && typeof w.path === "string") : [];
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/cli/run-server.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import { watch } from "node:fs";
|
|
2
|
+
import { basename, dirname } from "node:path";
|
|
1
3
|
import { type PeerMeta, newPeerId } from "../shared/signaling";
|
|
2
4
|
import { SignalingClient } from "../shared/signaling-client";
|
|
3
5
|
import { RtcDaemon } from "./rtc-daemon";
|
|
4
|
-
import { Tunnel } from "./tunnel";
|
|
6
|
+
import { type LocalRequest, Tunnel } from "./tunnel";
|
|
7
|
+
import { handleProvision, isProvisionPath, type ProvisionDeps } from "./provision-server";
|
|
8
|
+
import { type DaemonPlugin, routePlugins } from "./plugins/types";
|
|
5
9
|
|
|
6
10
|
export interface LaunchResult {
|
|
7
11
|
/** Local port to tunnel to. */
|
|
@@ -24,8 +28,25 @@ export interface RunServerOptions {
|
|
|
24
28
|
label: string;
|
|
25
29
|
/** Prepare the local target to tunnel, given the /vs/<peerId> base path. */
|
|
26
30
|
launch: (basePath: string) => Promise<LaunchResult>;
|
|
31
|
+
/** Enables `/__codehost/provision` on the tunnel (serve only — runs the home's
|
|
32
|
+
* setup.sh). Omitted by `expose`, which has no home/workspace. */
|
|
33
|
+
provision?: ProvisionDeps;
|
|
34
|
+
/** Recompute the advertised meta (e.g. re-enumerate workspaces). Polled on a
|
|
35
|
+
* slow interval and right after each provision; pushed to the room only when
|
|
36
|
+
* it actually changed. */
|
|
37
|
+
refreshMeta?: () => PeerMeta;
|
|
38
|
+
/** Daemon plugins: tunneled routes under /__codehost/<name>/ (their meta
|
|
39
|
+
* contributions are the caller's job, inside `meta`/`refreshMeta`). */
|
|
40
|
+
plugins?: DaemonPlugin[];
|
|
41
|
+
/** Files whose change should trigger an immediate `refreshMeta` (e.g. the
|
|
42
|
+
* host workspace registry). Watched via their parent directory so the file
|
|
43
|
+
* may not exist yet. */
|
|
44
|
+
watchFiles?: string[];
|
|
27
45
|
}
|
|
28
46
|
|
|
47
|
+
/** How often a daemon re-enumerates its workspaces (manual clones show up). */
|
|
48
|
+
const META_REFRESH_MS = 60_000;
|
|
49
|
+
|
|
29
50
|
/**
|
|
30
51
|
* Foreground server loop shared by `serve`, `dev`, and `expose`: register in the
|
|
31
52
|
* signaling room with the given meta and bridge each viewer's data channel to a
|
|
@@ -60,15 +81,54 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
|
|
|
60
81
|
onSignal: (from, data) => rtc.handleSignal(from, data),
|
|
61
82
|
});
|
|
62
83
|
|
|
84
|
+
// Re-advertise when the workspace set changes (provision, manual clone).
|
|
85
|
+
let lastMeta = JSON.stringify(opts.meta);
|
|
86
|
+
const refreshMeta = () => {
|
|
87
|
+
if (!opts.refreshMeta) return;
|
|
88
|
+
const meta = opts.refreshMeta();
|
|
89
|
+
const s = JSON.stringify(meta);
|
|
90
|
+
if (s === lastMeta) return;
|
|
91
|
+
lastMeta = s;
|
|
92
|
+
client.updateMeta(meta);
|
|
93
|
+
};
|
|
94
|
+
const provision: ProvisionDeps | undefined = opts.provision
|
|
95
|
+
? { ...opts.provision, onProvisioned: refreshMeta }
|
|
96
|
+
: undefined;
|
|
97
|
+
const plugins = opts.plugins ?? [];
|
|
98
|
+
const onLocal =
|
|
99
|
+
provision || plugins.length > 0
|
|
100
|
+
? (req: LocalRequest) => {
|
|
101
|
+
if (provision && isProvisionPath(req.path)) return handleProvision(req.path, provision);
|
|
102
|
+
return routePlugins(plugins, req);
|
|
103
|
+
}
|
|
104
|
+
: undefined;
|
|
105
|
+
|
|
63
106
|
rtc = new RtcDaemon({
|
|
64
107
|
sendSignal: (to, data) => client.sendSignal(to, data),
|
|
65
108
|
onChannel: (viewerId, channel) => {
|
|
66
109
|
console.log(`[codehost] viewer ${viewerId.slice(0, 8)} connected; bridging to :${target.port}`);
|
|
67
|
-
new Tunnel(channel, target.port, target.stripBasePath);
|
|
110
|
+
new Tunnel(channel, target.port, target.stripBasePath, onLocal);
|
|
68
111
|
},
|
|
69
112
|
});
|
|
70
113
|
|
|
71
114
|
client.connect();
|
|
115
|
+
if (opts.refreshMeta) {
|
|
116
|
+
setInterval(refreshMeta, META_REFRESH_MS);
|
|
117
|
+
// Instant re-advertise when a watched file changes (debounced — editors
|
|
118
|
+
// and fs.watch both fire in bursts).
|
|
119
|
+
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
120
|
+
for (const file of opts.watchFiles ?? []) {
|
|
121
|
+
try {
|
|
122
|
+
watch(dirname(file), (_event, filename) => {
|
|
123
|
+
if (filename && filename !== basename(file)) return;
|
|
124
|
+
if (pending) clearTimeout(pending);
|
|
125
|
+
pending = setTimeout(refreshMeta, 300);
|
|
126
|
+
});
|
|
127
|
+
} catch {
|
|
128
|
+
// missing dir / unsupported platform — the interval still covers it
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
72
132
|
|
|
73
133
|
const shutdown = () => {
|
|
74
134
|
console.log("\n[codehost] shutting down");
|