@neta-art/cohub-cli 10.2.0 → 10.4.1
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/README.md +40 -2
- package/dist/board-export-resources.d.ts +8 -0
- package/dist/board-export-resources.js +39 -0
- package/dist/board-export.d.ts +6 -0
- package/dist/board-export.js +58 -8
- package/dist/board-kernel.d.ts +1 -1
- package/dist/board-kernel.js +211 -141
- package/dist/commands/boards/authoring.js +14 -16
- package/dist/commands/boards.d.ts +1 -0
- package/dist/commands/boards.js +44 -13
- package/dist/commands/rooms-connect.d.ts +17 -0
- package/dist/commands/rooms-connect.js +140 -0
- package/dist/commands/rooms-rtc-binary.d.ts +2 -0
- package/dist/commands/rooms-rtc-binary.js +107 -0
- package/dist/commands/rooms-rtc.d.ts +21 -0
- package/dist/commands/rooms-rtc.js +171 -0
- package/dist/commands/rooms.js +45 -0
- package/dist/commands/sandboxd-binary.d.ts +3 -3
- package/dist/commands/sandboxd-binary.js +27 -17
- package/dist/runtime/harness.d.ts +5 -0
- package/dist/runtime/harness.js +29 -3
- package/dist/runtime/json-rpc.js +1 -1
- package/dist/runtime/launch.js +21 -6
- package/dist/runtime/native/attach.js +5 -2
- package/dist/runtime/native/codex-link.d.ts +2 -0
- package/dist/runtime/native/codex-link.js +3 -1
- package/dist/runtime/native/execution.js +3 -3
- package/dist/runtime/native/ingest.js +4 -4
- package/dist/runtime/native/paths.d.ts +2 -0
- package/dist/runtime/native/paths.js +7 -0
- package/dist/runtime/native/pi-extension.js +12 -2
- package/dist/runtime/native/pi-link.js +21 -4
- package/dist/runtime/native/version.js +4 -1
- package/dist/runtime/supervisor.js +33 -4
- package/package.json +3 -2
package/dist/commands/rooms.js
CHANGED
|
@@ -136,6 +136,51 @@ An Agent waits for the room in a background command and wakes when it returns:
|
|
|
136
136
|
printView(view);
|
|
137
137
|
console.log(`\nWait for events: cohub rooms events ${view.room.id} --after ${view.sequence} --wait 10m`);
|
|
138
138
|
}));
|
|
139
|
+
rooms
|
|
140
|
+
.command("connect <room>")
|
|
141
|
+
.description("Stream room events and members over realtime; disconnecting keeps your seat")
|
|
142
|
+
.option("--after <sequence>", "Replay retained events after this sequence")
|
|
143
|
+
.option("--input", "Accept NDJSON publish/send/presence commands on stdin")
|
|
144
|
+
.option("--json", "Output newline-delimited JSON")
|
|
145
|
+
.action(async (roomId, opts) => {
|
|
146
|
+
try {
|
|
147
|
+
const { connectToRoom } = await import("./rooms-connect.js");
|
|
148
|
+
await connectToRoom(roomId, { ...opts, json: jsonRequested(opts) });
|
|
149
|
+
}
|
|
150
|
+
catch (cause) {
|
|
151
|
+
handleHttp(cause);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
rooms
|
|
155
|
+
.command("rtc <room>")
|
|
156
|
+
.description("Stream RTC media and data through the Go/Pion helper")
|
|
157
|
+
.option("--helper <path>", "Use a local cohub-rtc binary")
|
|
158
|
+
.option("--channel <name>", "Open a JSON/binary RTC data channel")
|
|
159
|
+
.option("--delivery <policy>", "Data channel delivery: reliable or latest", "reliable")
|
|
160
|
+
.option("--data-input <path>", "NDJSON channel input file or - for stdin")
|
|
161
|
+
.option("--audio <path>", "Audio input file or - for stdin")
|
|
162
|
+
.option("--audio-format <format>", "Audio input: pcm or opus (Ogg)", "pcm")
|
|
163
|
+
.option("--rate <hz>", "Audio sample rate", "48000")
|
|
164
|
+
.option("--channels <count>", "Audio channels", "1")
|
|
165
|
+
.option("--video <path>", "Video input file or - for stdin")
|
|
166
|
+
.option("--video-format <format>", "Video input: i420 or vp8 (IVF)", "i420")
|
|
167
|
+
.option("--size <widthxheight>", "Video frame dimensions")
|
|
168
|
+
.option("--fps <rate>", "Video frame rate", "30")
|
|
169
|
+
.option("--output <directory|->", "Receive framed media into unique .rtc files or binary stdout")
|
|
170
|
+
.option("--receive-format <format>", "Receive rtp packets or raw decoded frames", "rtp")
|
|
171
|
+
.option("--receive-size <widthxheight>", "Decoded I420 output dimensions", "640x360")
|
|
172
|
+
.option("--max-bytes <bytes>", "Stop after this many received bytes; 0 is unlimited", "1073741824")
|
|
173
|
+
.option("--participant <id>", "Only receive this participant's tracks")
|
|
174
|
+
.option("--json", "Output newline-delimited JSON")
|
|
175
|
+
.action(async (roomId, opts) => {
|
|
176
|
+
try {
|
|
177
|
+
const { runRoomRtc } = await import("./rooms-rtc.js");
|
|
178
|
+
await runRoomRtc(roomId, opts);
|
|
179
|
+
}
|
|
180
|
+
catch (cause) {
|
|
181
|
+
handleHttp(cause);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
139
184
|
rooms
|
|
140
185
|
.command("get <room>")
|
|
141
186
|
.description("Show the room, its members and your seat")
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
export declare const SANDBOXD_VERSION = "v2.
|
|
1
|
+
export declare const SANDBOXD_VERSION = "v2.68.0";
|
|
2
2
|
export declare class SandboxdDownloadError extends Error {
|
|
3
3
|
name: string;
|
|
4
4
|
}
|
|
5
|
-
export declare function validSandboxdArchiveEntries(entries: string[]): boolean;
|
|
6
|
-
export declare function validateSandboxdArchive(archivePath: string): Promise<string[]>;
|
|
5
|
+
export declare function validSandboxdArchiveEntries(entries: string[], goos: string): boolean;
|
|
6
|
+
export declare function validateSandboxdArchive(archivePath: string, goos: string): Promise<string[]>;
|
|
7
7
|
export type EnsureSandboxdOptions = {
|
|
8
8
|
version?: string;
|
|
9
9
|
onStatus?: (message: string) => void;
|
|
@@ -7,8 +7,9 @@ import { dirname, join } from "node:path";
|
|
|
7
7
|
import { pipeline } from "node:stream/promises";
|
|
8
8
|
import { Readable } from "node:stream";
|
|
9
9
|
// Pin only after all platform artifacts are published to the public CDN.
|
|
10
|
-
export const SANDBOXD_VERSION = "v2.
|
|
10
|
+
export const SANDBOXD_VERSION = "v2.68.0";
|
|
11
11
|
const BINARY_NAME = "cohub-sandboxd";
|
|
12
|
+
const binaryName = (goos) => goos === "windows" ? `${BINARY_NAME}.exe` : BINARY_NAME;
|
|
12
13
|
// Public CDN prefix hosting the release archives (the repo is private, so the
|
|
13
14
|
// GitHub Release assets are not publicly downloadable). Overridable for staging
|
|
14
15
|
// or self-hosting.
|
|
@@ -16,7 +17,7 @@ const cdnBaseUrl = () => (process.env.COHUB_SANDBOXD_CDN_BASE_URL?.trim() || "ht
|
|
|
16
17
|
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
17
18
|
const LOCK_STALE_MS = 5 * 60 * 1000;
|
|
18
19
|
// Map Node's platform/arch to the Go GOOS/GOARCH used in release asset names.
|
|
19
|
-
const GOOS_BY_PLATFORM = { darwin: "darwin", linux: "linux" };
|
|
20
|
+
const GOOS_BY_PLATFORM = { darwin: "darwin", linux: "linux", win32: "windows" };
|
|
20
21
|
const GOARCH_BY_ARCH = { x64: "amd64", arm64: "arm64" };
|
|
21
22
|
export class SandboxdDownloadError extends Error {
|
|
22
23
|
name = "SandboxdDownloadError";
|
|
@@ -30,7 +31,7 @@ const resolveTarget = () => {
|
|
|
30
31
|
return { goos, goarch };
|
|
31
32
|
};
|
|
32
33
|
const cacheDir = (version) => join(homedir(), ".cache", "cohub", "sandboxd", version);
|
|
33
|
-
const cachedBinaryPath = (version) => join(cacheDir(version),
|
|
34
|
+
const cachedBinaryPath = (version, target) => join(cacheDir(version), binaryName(target.goos));
|
|
34
35
|
const archiveName = (version, target) => `${BINARY_NAME}_${version}_${target.goos}_${target.goarch}.tar.gz`;
|
|
35
36
|
const isExecutableFile = async (path) => {
|
|
36
37
|
const info = await lstat(path).catch(() => null);
|
|
@@ -85,14 +86,23 @@ const fetchText = (url, accept) => withTimeout(`Download of ${url}`, async (sign
|
|
|
85
86
|
return (await response.text()).trim();
|
|
86
87
|
});
|
|
87
88
|
// Every published release ships the binary alongside its LICENSE and NOTICE.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return (entries.length ===
|
|
91
|
-
|
|
89
|
+
export function validSandboxdArchiveEntries(entries, goos) {
|
|
90
|
+
const expected = [binaryName(goos), "LICENSE", "NOTICE"];
|
|
91
|
+
return (entries.length === expected.length &&
|
|
92
|
+
expected.every((entry) => entries.includes(entry)));
|
|
92
93
|
}
|
|
94
|
+
// Git for Windows puts GNU tar on PATH, which treats `C:\...` as a remote host.
|
|
95
|
+
// Use the system bsdtar (Windows 10+) instead.
|
|
96
|
+
const tarCommand = () => {
|
|
97
|
+
if (process.platform !== "win32")
|
|
98
|
+
return "tar";
|
|
99
|
+
if (!process.env.SystemRoot)
|
|
100
|
+
throw new SandboxdDownloadError("SystemRoot is not set; cannot locate tar.exe");
|
|
101
|
+
return join(process.env.SystemRoot, "System32", "tar.exe");
|
|
102
|
+
};
|
|
93
103
|
// Reject unexpected paths before extracting the checksum-verified release.
|
|
94
104
|
const listTarGz = (archivePath, verbose = false) => new Promise((res, rej) => {
|
|
95
|
-
const child = spawn(
|
|
105
|
+
const child = spawn(tarCommand(), [verbose ? "-tvzf" : "-tzf", archivePath], { stdio: ["ignore", "pipe", "pipe"] });
|
|
96
106
|
let stdout = "";
|
|
97
107
|
let stderr = "";
|
|
98
108
|
child.stdout.on("data", (chunk) => {
|
|
@@ -106,9 +116,9 @@ const listTarGz = (archivePath, verbose = false) => new Promise((res, rej) => {
|
|
|
106
116
|
? res(stdout.split("\n").map((line) => line.trim()).filter(Boolean))
|
|
107
117
|
: rej(new SandboxdDownloadError(`tar listing failed: ${stderr.trim() || `exit ${code}`}`)));
|
|
108
118
|
});
|
|
109
|
-
export async function validateSandboxdArchive(archivePath) {
|
|
119
|
+
export async function validateSandboxdArchive(archivePath, goos) {
|
|
110
120
|
const entries = await listTarGz(archivePath);
|
|
111
|
-
if (!validSandboxdArchiveEntries(entries)) {
|
|
121
|
+
if (!validSandboxdArchiveEntries(entries, goos)) {
|
|
112
122
|
throw new SandboxdDownloadError(`Unexpected sandbox archive contents: ${entries.join(", ") || "(empty)"}`);
|
|
113
123
|
}
|
|
114
124
|
const details = await listTarGz(archivePath, true);
|
|
@@ -120,7 +130,7 @@ export async function validateSandboxdArchive(archivePath) {
|
|
|
120
130
|
// Extract the verified `.tar.gz` using the system tar (universally present on
|
|
121
131
|
// macOS and Linux), keeping the CLI free of native archive dependencies.
|
|
122
132
|
const extractTarGz = (archivePath, cwd) => new Promise((res, rej) => {
|
|
123
|
-
const child = spawn(
|
|
133
|
+
const child = spawn(tarCommand(), ["-xzf", archivePath, "-C", cwd], { stdio: ["ignore", "ignore", "pipe"] });
|
|
124
134
|
let stderr = "";
|
|
125
135
|
child.stderr.on("data", (chunk) => {
|
|
126
136
|
stderr += chunk.toString();
|
|
@@ -175,19 +185,19 @@ const downloadAndVerify = async (version, target) => {
|
|
|
175
185
|
if (actual !== expected) {
|
|
176
186
|
throw new SandboxdDownloadError(`Checksum mismatch for ${name} (expected ${expected}, got ${actual})`);
|
|
177
187
|
}
|
|
178
|
-
const entries = await validateSandboxdArchive(archivePath);
|
|
188
|
+
const entries = await validateSandboxdArchive(archivePath, target.goos);
|
|
179
189
|
await extractTarGz(archivePath, tempDir);
|
|
180
190
|
for (const entry of entries) {
|
|
181
191
|
if (!(await isSafeArchiveFile(join(tempDir, entry)))) {
|
|
182
192
|
throw new SandboxdDownloadError(`Unsafe sandbox archive entry: ${entry}`);
|
|
183
193
|
}
|
|
184
194
|
}
|
|
185
|
-
const extractedBinary = join(tempDir,
|
|
195
|
+
const extractedBinary = join(tempDir, binaryName(target.goos));
|
|
186
196
|
if (!(await isSafeArchiveFile(extractedBinary))) {
|
|
187
|
-
throw new SandboxdDownloadError(`Archive ${name} did not contain ${
|
|
197
|
+
throw new SandboxdDownloadError(`Archive ${name} did not contain ${binaryName(target.goos)}`);
|
|
188
198
|
}
|
|
189
199
|
// Atomically move into the version cache (fall back to copy across devices).
|
|
190
|
-
const dest = cachedBinaryPath(version);
|
|
200
|
+
const dest = cachedBinaryPath(version, target);
|
|
191
201
|
await mkdir(dirname(dest), { recursive: true });
|
|
192
202
|
await rename(extractedBinary, dest).catch(async (err) => {
|
|
193
203
|
if (err.code !== "EXDEV")
|
|
@@ -212,10 +222,10 @@ export const ensureSandboxdBinary = async (options = {}) => {
|
|
|
212
222
|
return override;
|
|
213
223
|
}
|
|
214
224
|
const version = options.version?.trim() || SANDBOXD_VERSION;
|
|
215
|
-
const
|
|
225
|
+
const target = resolveTarget();
|
|
226
|
+
const cached = cachedBinaryPath(version, target);
|
|
216
227
|
if (await isExecutableFile(cached))
|
|
217
228
|
return cached;
|
|
218
|
-
const target = resolveTarget();
|
|
219
229
|
return withLock(version, async () => {
|
|
220
230
|
// Re-check inside the lock: another process may have finished downloading.
|
|
221
231
|
if (await isExecutableFile(cached))
|
|
@@ -5,6 +5,11 @@ export type HarnessOptions = {
|
|
|
5
5
|
codex?: string;
|
|
6
6
|
};
|
|
7
7
|
export declare function harnessExecutableCandidates(binary: string, cwd: string, path: string, platform?: NodeJS.Platform, pathExt?: string | undefined): string[];
|
|
8
|
+
/**
|
|
9
|
+
* Windows npm/pnpm installs expose harnesses as .cmd shims, which cannot be spawned
|
|
10
|
+
* without a shell. Run an .exe directly, or the shim's Node entry with this Node.
|
|
11
|
+
*/
|
|
12
|
+
export declare function harnessCommand(binary: string, args: string[], cwd: string): [string, string[]];
|
|
8
13
|
/** Discover installed executables only; authentication/capability errors stay explicit. */
|
|
9
14
|
export declare function installedHarnesses(cwd: string, options: HarnessOptions, path?: string): Promise<("pi" | "codex")[]>;
|
|
10
15
|
/** Aborted streams leave tool calls with an empty id or name. */
|
package/dist/runtime/harness.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { access, stat } from "node:fs/promises";
|
|
2
|
-
import { constants } from "node:fs";
|
|
2
|
+
import { constants, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { delimiter, join, resolve, win32 } from "node:path";
|
|
4
4
|
import { JsonRpcProcess, record } from "./json-rpc.js";
|
|
5
5
|
import { codexModelCatalog, piModelCatalog } from "./model-catalog.js";
|
|
@@ -7,14 +7,40 @@ import { downloadPublicImage } from "../safe-remote-image.js";
|
|
|
7
7
|
export function harnessExecutableCandidates(binary, cwd, path, platform = process.platform, pathExt = process.env.PATHEXT) {
|
|
8
8
|
const windows = platform === "win32";
|
|
9
9
|
const paths = windows ? win32 : { delimiter, join, resolve };
|
|
10
|
+
// Windows runs only PATHEXT names; npm/pnpm also write an extensionless sh script beside the .cmd shim.
|
|
10
11
|
const extensions = windows && !win32.extname(binary)
|
|
11
|
-
?
|
|
12
|
+
? (pathExt || ".COM;.EXE;.BAT;.CMD").split(";").filter((ext) => /^\.[a-z0-9]+$/i.test(ext))
|
|
12
13
|
: [""];
|
|
13
14
|
const roots = binary.includes("/") || binary.includes("\\")
|
|
14
15
|
? [paths.resolve(cwd, binary)]
|
|
15
16
|
: path.split(paths.delimiter).map((directory) => directory.replace(/^"(.*)"$/, "$1")).filter(Boolean).map((directory) => paths.resolve(cwd, directory, binary));
|
|
16
17
|
return roots.flatMap((root) => extensions.map((ext) => `${root}${ext}`));
|
|
17
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Windows npm/pnpm installs expose harnesses as .cmd shims, which cannot be spawned
|
|
21
|
+
* without a shell. Run an .exe directly, or the shim's Node entry with this Node.
|
|
22
|
+
*/
|
|
23
|
+
export function harnessCommand(binary, args, cwd) {
|
|
24
|
+
if (process.platform !== "win32")
|
|
25
|
+
return [binary, args];
|
|
26
|
+
const path = harnessExecutableCandidates(binary, cwd, process.env.PATH ?? "").find((candidate) => statSync(candidate, { throwIfNoEntry: false })?.isFile());
|
|
27
|
+
if (!path)
|
|
28
|
+
throw new Error(`${binary} was not found on PATH`);
|
|
29
|
+
const extension = win32.extname(path).toLowerCase();
|
|
30
|
+
if (extension === ".exe" || extension === ".com")
|
|
31
|
+
return [path, args];
|
|
32
|
+
if ([".js", ".mjs", ".cjs"].includes(extension))
|
|
33
|
+
return [process.execPath, [path, ...args]];
|
|
34
|
+
if (extension === ".cmd") {
|
|
35
|
+
// npm (cmd-shim): SET "_prog=node" … "%dp0%\entry" %*; pnpm (@zkochan/cmd-shim): node "%~dp0\entry" %*
|
|
36
|
+
const shim = readFileSync(path, "utf8");
|
|
37
|
+
const runsNode = /SET "_prog=node"/i.test(shim) || /^\s*node\s+"%~dp0\\/im.test(shim);
|
|
38
|
+
const entry = runsNode ? /"%~?dp0%?\\([^"]+)"\s+%\*/i.exec(shim)?.[1] : undefined;
|
|
39
|
+
if (entry)
|
|
40
|
+
return [process.execPath, [win32.join(win32.dirname(path), entry), ...args]];
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`Cannot run ${path} without a shell; pass --pi/--codex with an .exe or Node script`);
|
|
43
|
+
}
|
|
18
44
|
/** Discover installed executables only; authentication/capability errors stay explicit. */
|
|
19
45
|
export async function installedHarnesses(cwd, options, path = process.env.PATH ?? "") {
|
|
20
46
|
const names = options.pi || options.codex ? ["pi", "codex"].filter((name) => options[name]) : ["pi", "codex"];
|
|
@@ -64,7 +90,7 @@ async function initializeCodex(rpc) {
|
|
|
64
90
|
export async function discoverHarnesses(harnesses, options, cwd) {
|
|
65
91
|
const models = [];
|
|
66
92
|
await Promise.all(harnesses.map(async (harness) => {
|
|
67
|
-
const rpc = new JsonRpcProcess(options[harness] || harness, harness === "pi" ? ["--mode", "rpc", "--no-session"] : ["app-server", "--listen", "stdio://"], cwd, harness);
|
|
93
|
+
const rpc = new JsonRpcProcess(...harnessCommand(options[harness] || harness, harness === "pi" ? ["--mode", "rpc", "--no-session"] : ["app-server", "--listen", "stdio://"], cwd), cwd, harness);
|
|
68
94
|
try {
|
|
69
95
|
if (harness === "pi") {
|
|
70
96
|
const result = await rpc.request("get_available_models");
|
package/dist/runtime/json-rpc.js
CHANGED
|
@@ -67,7 +67,7 @@ export class JsonRpcProcess {
|
|
|
67
67
|
closing = null;
|
|
68
68
|
constructor(binary, args, cwd, mode, context = {}) {
|
|
69
69
|
this.mode = mode;
|
|
70
|
-
this.child = spawn(binary, args, { cwd, env: { ...harnessEnvironment(), ...context }, stdio: "pipe", detached: process.platform !== "win32" });
|
|
70
|
+
this.child = spawn(binary, args, { cwd, env: { ...harnessEnvironment(), ...context }, stdio: "pipe", detached: process.platform !== "win32", windowsHide: true });
|
|
71
71
|
this.closed = new Promise((resolve) => this.child.once("close", () => resolve()));
|
|
72
72
|
const decoder = new JsonLineDecoder((value) => this.receive(value));
|
|
73
73
|
this.child.stdout.on("data", (chunk) => { try {
|
package/dist/runtime/launch.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { fork } from "node:child_process";
|
|
1
|
+
import { execFile, fork } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { stat } from "node:fs/promises";
|
|
4
|
-
import { basename, resolve } from "node:path";
|
|
4
|
+
import { basename, join, resolve } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
5
6
|
import { createInterface } from "node:readline/promises";
|
|
6
7
|
import { isLocalHarness } from "@neta-art/cohub";
|
|
7
8
|
import { discoverHarnesses, installedHarnesses } from "./harness.js";
|
|
@@ -23,11 +24,11 @@ export function parseRuntimeHarnesses(values) {
|
|
|
23
24
|
export function parseRuntimeDisplay(value, platform = process.platform, env = process.env) {
|
|
24
25
|
if (value === false)
|
|
25
26
|
return undefined;
|
|
26
|
-
if (value === undefined && platform !== "darwin" && (platform !== "linux" || !env.DISPLAY))
|
|
27
|
+
if (value === undefined && platform !== "darwin" && platform !== "win32" && (platform !== "linux" || !env.DISPLAY))
|
|
27
28
|
return undefined;
|
|
28
29
|
const spec = value === undefined || value === true ? "auto" : value.trim();
|
|
29
|
-
if (!/^(auto|macos|x11(:[\w.:-]+)?|xvfb(:\d{3,4}x\d{3,4})?)$/.test(spec)) {
|
|
30
|
-
throw new Error("Display must be auto, macos, x11[:display] or xvfb[:WIDTHxHEIGHT]");
|
|
30
|
+
if (!/^(auto|macos|windows|x11(:[\w.:-]+)?|xvfb(:\d{3,4}x\d{3,4})?)$/.test(spec)) {
|
|
31
|
+
throw new Error("Display must be auto, macos, windows, x11[:display] or xvfb[:WIDTHxHEIGHT]");
|
|
31
32
|
}
|
|
32
33
|
if (platform === "linux" && spec === "auto" && !env.DISPLAY) {
|
|
33
34
|
throw new Error(env.WAYLAND_DISPLAY
|
|
@@ -52,7 +53,9 @@ export async function startBackgroundRuntime(config) {
|
|
|
52
53
|
return new Promise((resolve, reject) => {
|
|
53
54
|
// Keep the same Node executable and loader (also works from source in tests).
|
|
54
55
|
const workerUrl = new URL(import.meta.url.endsWith(".ts") ? "./worker.ts" : "./worker.js", import.meta.url);
|
|
55
|
-
|
|
56
|
+
// fork forwards spawn options; windowsHide keeps a detached Windows worker from opening a console.
|
|
57
|
+
const forkOptions = { detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"], windowsHide: true };
|
|
58
|
+
const child = fork(workerUrl, [], forkOptions);
|
|
56
59
|
const diagnosticConsole = createDiagnosticConsole(config.verbose);
|
|
57
60
|
let last = null;
|
|
58
61
|
let settled = false;
|
|
@@ -89,7 +92,19 @@ export async function startBackgroundRuntime(config) {
|
|
|
89
92
|
child.send(config);
|
|
90
93
|
});
|
|
91
94
|
}
|
|
95
|
+
/** Collaborators execute as this OS user; an elevated Windows token would grant them administrator rights. */
|
|
96
|
+
async function assertNotElevatedOnWindows() {
|
|
97
|
+
if (process.platform !== "win32")
|
|
98
|
+
return;
|
|
99
|
+
if (!process.env.SystemRoot)
|
|
100
|
+
throw new Error("SystemRoot is not set; cannot check terminal elevation");
|
|
101
|
+
const { stdout } = await promisify(execFile)(join(process.env.SystemRoot, "System32", "whoami.exe"), ["/groups"], { encoding: "utf8", windowsHide: true });
|
|
102
|
+
// High (S-1-16-12288) and System (S-1-16-16384) integrity labels mark an elevated token.
|
|
103
|
+
if (/\bS-1-16-(?:12288|16384)\b/.test(stdout))
|
|
104
|
+
throw new Error("Run cohub runtime up from a non-elevated terminal");
|
|
105
|
+
}
|
|
92
106
|
export async function runtimeUp(program, dir, options) {
|
|
107
|
+
await assertNotElevatedOnWindows();
|
|
93
108
|
const requestedRoot = resolve(dir ?? process.cwd());
|
|
94
109
|
if (!(await stat(requestedRoot)).isDirectory())
|
|
95
110
|
throw new Error("Workspace is not a directory");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { serializeDiagnosticError } from "../diagnostics.js";
|
|
3
|
-
import { CODEX_SHARED_MIN_VERSION, supportsSharedServer } from "./codex-link.js";
|
|
3
|
+
import { CODEX_SHARED_MIN_VERSION, sharedServerAvailable, supportsSharedServer } from "./codex-link.js";
|
|
4
4
|
import { PI_MIN_VERSION } from "./pi-link.js";
|
|
5
5
|
import { atLeast, formatVersion, harnessVersion } from "./version.js";
|
|
6
6
|
import { readNativeConfig, runtimeStateRoot, writeNativeConfig } from "./config.js";
|
|
@@ -43,7 +43,10 @@ export async function ensureNativeSync(input, log = (line) => process.stderr.wri
|
|
|
43
43
|
let codexShared = previous?.codexShared ?? false;
|
|
44
44
|
if (input.harnesses.includes("codex") && !codexShared) {
|
|
45
45
|
const version = await harnessVersion(input.executables?.codex || "codex");
|
|
46
|
-
if (!
|
|
46
|
+
if (!sharedServerAvailable) {
|
|
47
|
+
log("Shared Codex sessions are unavailable on Windows; terminal Codex chats sync read-only\n");
|
|
48
|
+
}
|
|
49
|
+
else if (!supportsSharedServer(version)) {
|
|
47
50
|
log(`Codex ${formatVersion(version)} predates the shared app-server (${CODEX_SHARED_MIN_VERSION.join(".")}+); terminal Codex chats sync read-only\n`);
|
|
48
51
|
}
|
|
49
52
|
else {
|
|
@@ -18,6 +18,8 @@ export interface CodexChannel {
|
|
|
18
18
|
readonly shared: boolean;
|
|
19
19
|
readonly closed: boolean;
|
|
20
20
|
}
|
|
21
|
+
/** Codex's shared server listens on an AF_UNIX socket, which Node cannot reach on Windows. */
|
|
22
|
+
export declare const sharedServerAvailable: boolean;
|
|
21
23
|
export declare const supportsSharedServer: (version: Version | null) => boolean;
|
|
22
24
|
/** A private app-server over stdio, used when no shared one is available; it lives for one Turn. */
|
|
23
25
|
export declare function openPrivateServer(rpc: JsonRpcProcess): Promise<CodexChannel>;
|
|
@@ -15,7 +15,9 @@ const DAEMON_START_TIMEOUT_MS = 30_000;
|
|
|
15
15
|
const codexHome = () => process.env.CODEX_HOME?.trim() || join(homedir(), ".codex");
|
|
16
16
|
/** The socket every Codex client on this machine discovers; a terminal `codex` attaches to it. */
|
|
17
17
|
const codexControlSocket = () => join(codexHome(), "app-server-control", "app-server-control.sock");
|
|
18
|
-
|
|
18
|
+
/** Codex's shared server listens on an AF_UNIX socket, which Node cannot reach on Windows. */
|
|
19
|
+
export const sharedServerAvailable = process.platform !== "win32";
|
|
20
|
+
export const supportsSharedServer = (version) => sharedServerAvailable && atLeast(version, CODEX_SHARED_MIN_VERSION);
|
|
19
21
|
/** Start the shared app-server when none is running. */
|
|
20
22
|
async function ensureSharedServer(binary, cwd) {
|
|
21
23
|
if (existsSync(codexControlSocket()) && await probe())
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { imageForPi } from "../harness.js";
|
|
1
|
+
import { harnessCommand, imageForPi } from "../harness.js";
|
|
2
2
|
import { JsonRpcProcess, record, RpcProcessClosedError } from "../json-rpc.js";
|
|
3
3
|
import { describeError } from "../../network-error.js";
|
|
4
4
|
import { ProcessCleanupUncertainError } from "../process-group.js";
|
|
@@ -55,7 +55,7 @@ async function runPi(executor, turn, context) {
|
|
|
55
55
|
let begun = false;
|
|
56
56
|
try {
|
|
57
57
|
if (!link) {
|
|
58
|
-
host = new JsonRpcProcess(executor.executables.pi || "pi", ["--mode", "rpc", "--session", session.path, "-e", executor.piExtension], executor.cwd, "pi", turnEnvironment(input));
|
|
58
|
+
host = new JsonRpcProcess(...harnessCommand(executor.executables.pi || "pi", ["--mode", "rpc", "--session", session.path, "-e", executor.piExtension], executor.cwd), executor.cwd, "pi", turnEnvironment(input));
|
|
59
59
|
watchHost(host, executor.diagnostics, context);
|
|
60
60
|
const rpc = host;
|
|
61
61
|
// Nobody is at this Pi's terminal to answer an extension's question.
|
|
@@ -209,7 +209,7 @@ async function runCodex(executor, turn, context) {
|
|
|
209
209
|
let begun = false;
|
|
210
210
|
try {
|
|
211
211
|
if (!channel) {
|
|
212
|
-
host = new JsonRpcProcess(executor.executables.codex || "codex", ["app-server", "--listen", "stdio://"], executor.cwd, "codex", turnEnvironment(input));
|
|
212
|
+
host = new JsonRpcProcess(...harnessCommand(executor.executables.codex || "codex", ["app-server", "--listen", "stdio://"], executor.cwd), executor.cwd, "codex", turnEnvironment(input));
|
|
213
213
|
watchHost(host, executor.diagnostics, context);
|
|
214
214
|
channel = await openPrivateServer(host);
|
|
215
215
|
}
|
|
@@ -4,6 +4,7 @@ import { NATIVE_INGEST_MAX_TURNS, SETTLED_TURN_STATUSES } from "@neta-art/cohub"
|
|
|
4
4
|
import { acceptsDirectory, acceptsTranscript, readSessionHeader, readTranscript, transcriptRoots } from "./adapters.js";
|
|
5
5
|
import { nativeTurnId } from "./identity.js";
|
|
6
6
|
import { ensurePlainCodexRollout } from "./transcript.js";
|
|
7
|
+
import { containsNativePath } from "./paths.js";
|
|
7
8
|
const missing = (error) => error?.code === "ENOENT";
|
|
8
9
|
/** Transcripts read in parallel per lane. The live lane never waits behind a backfill. */
|
|
9
10
|
const LIVE_CONCURRENCY = 2;
|
|
@@ -39,9 +40,8 @@ const BOUNDARY_OVERLAP = Math.max(...Object.values(BOUNDARIES).flat().map((token
|
|
|
39
40
|
const IMPORT_FAILURES_KEPT = 100;
|
|
40
41
|
/** Headers are read this many at a time; each costs one small read. */
|
|
41
42
|
const HEADER_CONCURRENCY = 16;
|
|
42
|
-
const within = (root, cwd) => cwd === root || cwd.startsWith(`${root}/`);
|
|
43
43
|
/** Whether a session in `cwd` belongs to the directory bound at `root`. */
|
|
44
|
-
export const owns = (root, cwd, nested = []) => Boolean(cwd) &&
|
|
44
|
+
export const owns = (root, cwd, nested = []) => Boolean(cwd) && containsNativePath(root, cwd) && !nested.some((inner) => containsNativePath(inner, cwd));
|
|
45
45
|
/** Every transcript of the bound directory and its subdirectories, newest first. */
|
|
46
46
|
export async function discoverTranscripts(cwd, harnesses, options = {}) {
|
|
47
47
|
const found = [];
|
|
@@ -144,7 +144,7 @@ export class NativeIngest {
|
|
|
144
144
|
return owns(this.options.cwd, cwd, this.nested);
|
|
145
145
|
}
|
|
146
146
|
exclude(nested) {
|
|
147
|
-
this.nested = nested.filter((root) => root
|
|
147
|
+
this.nested = nested.filter((root) => !containsNativePath(root, this.options.cwd) && containsNativePath(this.options.cwd, root));
|
|
148
148
|
}
|
|
149
149
|
configure(harnesses, enabledAt) {
|
|
150
150
|
const added = harnesses.filter((harness) => !this.harnesses.includes(harness));
|
|
@@ -560,7 +560,7 @@ export class NativeIngest {
|
|
|
560
560
|
}
|
|
561
561
|
harnessFor(path) {
|
|
562
562
|
for (const harness of this.harnesses) {
|
|
563
|
-
if (transcriptRoots(harness).some((root) =>
|
|
563
|
+
if (transcriptRoots(harness).some((root) => containsNativePath(root, path)) && acceptsTranscript(path, harness, this.options.cwd))
|
|
564
564
|
return harness;
|
|
565
565
|
}
|
|
566
566
|
return null;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { posix, win32 } from "node:path";
|
|
2
|
+
/** Whether a native path is the root itself or one of its descendants. */
|
|
3
|
+
export function containsNativePath(root, path, platform = process.platform) {
|
|
4
|
+
const paths = platform === "win32" ? win32 : posix;
|
|
5
|
+
const relative = paths.relative(root, path);
|
|
6
|
+
return relative === "" || relative !== ".." && !relative.startsWith(`..${paths.sep}`) && !paths.isAbsolute(relative);
|
|
7
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Cohub control bridge for Pi. Self-contained: Node built-ins only, so it installs as one file.
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { existsSync, realpathSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
4
4
|
import { connect } from "node:net";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
@@ -103,7 +103,17 @@ export default function cohub(pi) {
|
|
|
103
103
|
const path = locateSocket(ctx.cwd);
|
|
104
104
|
if (!path)
|
|
105
105
|
return;
|
|
106
|
-
|
|
106
|
+
// On Windows the socket file names the Runtime's private named pipe.
|
|
107
|
+
let endpoint = path;
|
|
108
|
+
if (process.platform === "win32") {
|
|
109
|
+
try {
|
|
110
|
+
endpoint = readFileSync(path, "utf8").trim();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const current = connect(endpoint);
|
|
107
117
|
socket = current;
|
|
108
118
|
buffer = "";
|
|
109
119
|
current.on("connect", () => send({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { createServer } from "node:net";
|
|
2
|
-
import { chmod, mkdir, rm } from "node:fs/promises";
|
|
3
|
+
import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import { dirname } from "node:path";
|
|
4
5
|
import { COHUB_PI_PROTOCOL } from "./pi-extension.js";
|
|
5
6
|
const MAX_LINE_BYTES = 8 * 1024 * 1024;
|
|
@@ -68,17 +69,33 @@ export class PiLink {
|
|
|
68
69
|
}
|
|
69
70
|
async listen(signal) {
|
|
70
71
|
const { socketPath } = this.options;
|
|
72
|
+
const windows = process.platform === "win32";
|
|
73
|
+
// Windows named pipes share one global namespace: listen on a random name and
|
|
74
|
+
// publish it at socketPath, so another account cannot pre-claim the endpoint.
|
|
75
|
+
const endpoint = windows ? `\\\\.\\pipe\\cohub-pi-${randomUUID()}` : socketPath;
|
|
71
76
|
// The socket accepts commands that drive the user's agent: only this user may reach it.
|
|
72
77
|
await mkdir(dirname(socketPath), { recursive: true, mode: 0o700 });
|
|
73
|
-
if (
|
|
78
|
+
if (!windows)
|
|
74
79
|
await chmod(dirname(socketPath), 0o700);
|
|
75
80
|
await rm(socketPath, { force: true });
|
|
76
81
|
const server = createServer((socket) => this.accept(socket));
|
|
77
82
|
await new Promise((resolve, reject) => {
|
|
78
83
|
server.once("error", reject);
|
|
79
|
-
server.listen(
|
|
84
|
+
server.listen(endpoint, () => { server.removeListener("error", reject); resolve(); });
|
|
80
85
|
});
|
|
81
|
-
if (
|
|
86
|
+
if (windows) {
|
|
87
|
+
const temporary = `${socketPath}.${randomUUID()}.tmp`;
|
|
88
|
+
try {
|
|
89
|
+
await writeFile(temporary, endpoint, { mode: 0o600 });
|
|
90
|
+
await rename(temporary, socketPath);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
server.close();
|
|
94
|
+
await rm(temporary, { force: true });
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else
|
|
82
99
|
await chmod(socketPath, 0o600);
|
|
83
100
|
this.server = server;
|
|
84
101
|
signal.addEventListener("abort", () => {
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
+
import { harnessCommand } from "../harness.js";
|
|
3
4
|
/** A harness binary's own version, or `null` when it cannot say. */
|
|
4
5
|
export async function harnessVersion(binary) {
|
|
5
|
-
const { stdout } = await
|
|
6
|
+
const { stdout } = await Promise.resolve()
|
|
7
|
+
.then(() => promisify(execFile)(...harnessCommand(binary, ["--version"], process.cwd()), { encoding: "utf8", timeout: 15_000, windowsHide: true }))
|
|
8
|
+
.catch(() => ({ stdout: "" }));
|
|
6
9
|
const match = /(\d+)\.(\d+)\.(\d+)/.exec(stdout);
|
|
7
10
|
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
8
11
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createServer } from "node:net";
|
|
3
4
|
import { createInterface } from "node:readline";
|
|
4
5
|
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
+
import { PassThrough } from "node:stream";
|
|
5
7
|
import { resolveWebsocketUrl } from "@neta-art/cohub";
|
|
6
8
|
import { AuthRequiredError, resolveAccessToken } from "../auth.js";
|
|
7
9
|
import { createClient } from "../client.js";
|
|
@@ -22,6 +24,24 @@ export function sandboxOutputLevel(value, stream) {
|
|
|
22
24
|
return level;
|
|
23
25
|
return stream === "stderr" ? "warn" : "debug";
|
|
24
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Windows cannot hand sandboxd an extra inherited pipe, so control frames use a
|
|
29
|
+
* named pipe instead. Only the first client is accepted; the name is unguessable.
|
|
30
|
+
*/
|
|
31
|
+
async function listenWindowsControlPipe() {
|
|
32
|
+
const path = `\\\\.\\pipe\\cohub-sandboxd-${randomUUID()}`;
|
|
33
|
+
const stream = new PassThrough();
|
|
34
|
+
const server = createServer((socket) => {
|
|
35
|
+
server.close();
|
|
36
|
+
socket.on("error", () => stream.end());
|
|
37
|
+
socket.pipe(stream);
|
|
38
|
+
});
|
|
39
|
+
await new Promise((resolve, reject) => {
|
|
40
|
+
server.once("error", reject);
|
|
41
|
+
server.listen(path, () => { server.removeListener("error", reject); resolve(); });
|
|
42
|
+
});
|
|
43
|
+
return { path, stream, close: () => { server.close(); stream.end(); } };
|
|
44
|
+
}
|
|
25
45
|
export async function runRuntime(config, onState, externalSignal, onDiagnostic) {
|
|
26
46
|
const controller = new AbortController();
|
|
27
47
|
const signal = externalSignal ? AbortSignal.any([externalSignal, controller.signal]) : controller.signal;
|
|
@@ -119,12 +139,16 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
|
|
|
119
139
|
let probeTimer;
|
|
120
140
|
let managed = false;
|
|
121
141
|
let childStopped = false;
|
|
142
|
+
let controlPipe = null;
|
|
122
143
|
try {
|
|
123
144
|
const initialToken = await token();
|
|
124
145
|
signal.throwIfAborted();
|
|
146
|
+
if (process.platform === "win32")
|
|
147
|
+
controlPipe = await listenWindowsControlPipe();
|
|
125
148
|
const bridge = spawn(binary, ["--local", "--space", config.spaceId, "--root", config.root, "--relay", process.env.COHUB_RELAY_URL?.trim() || relay.toString()], {
|
|
126
|
-
stdio: ["pipe", "pipe", "pipe", "pipe"],
|
|
127
|
-
|
|
149
|
+
stdio: controlPipe ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe", "pipe"],
|
|
150
|
+
windowsHide: true,
|
|
151
|
+
env: { ...process.env, COHUB_RELAY_TOKEN: initialToken, COHUB_RUNTIME_ID: status.runtimeId, COHUB_LOG_FORMAT: "json", COHUB_RUNTIME_MANAGED: "1", COHUB_DISPLAY: config.display ?? "", ...(controlPipe ? { COHUB_RUNTIME_CONTROL_PIPE: controlPipe.path } : {}) },
|
|
128
152
|
});
|
|
129
153
|
bridge.stdin?.on("error", () => undefined);
|
|
130
154
|
const closed = new Promise((resolve) => {
|
|
@@ -153,7 +177,7 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
|
|
|
153
177
|
diagnostics.log(sandboxOutputLevel(parsed?.level, streamName), "sandboxd.log", { ...data, message: parsed?.msg ?? line }, { component: "sandboxd" });
|
|
154
178
|
});
|
|
155
179
|
}
|
|
156
|
-
const control = bridge.stdio[3];
|
|
180
|
+
const control = controlPipe ? controlPipe.stream : bridge.stdio[3];
|
|
157
181
|
const events = createInterface({ input: control });
|
|
158
182
|
events.on("line", (line) => {
|
|
159
183
|
try {
|
|
@@ -202,7 +226,11 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
|
|
|
202
226
|
};
|
|
203
227
|
probeTimer = setTimeout(() => void probe(), 1000);
|
|
204
228
|
const terminate = () => {
|
|
205
|
-
|
|
229
|
+
// Windows has no SIGTERM; closing the managed stdin lets sandboxd stop gracefully.
|
|
230
|
+
if (process.platform === "win32")
|
|
231
|
+
bridge.stdin?.end();
|
|
232
|
+
else
|
|
233
|
+
bridge.kill("SIGTERM");
|
|
206
234
|
const timeout = setTimeout(() => bridge.kill("SIGKILL"), 3000);
|
|
207
235
|
void closed.finally(() => clearTimeout(timeout));
|
|
208
236
|
};
|
|
@@ -218,6 +246,7 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
|
|
|
218
246
|
}
|
|
219
247
|
finally {
|
|
220
248
|
childStopped = true;
|
|
249
|
+
controlPipe?.close();
|
|
221
250
|
clearTimeout(refreshTimer);
|
|
222
251
|
clearTimeout(probeTimer);
|
|
223
252
|
update({ workspaceConnected: false });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neta-art/cohub-cli",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.4.1",
|
|
4
4
|
"description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"NOTICE"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@neta-art/cohub": "11.
|
|
21
|
+
"@neta-art/cohub": "11.3.1",
|
|
22
22
|
"@neta-art/generation": "^0.1.35",
|
|
23
23
|
"commander": "^15.0.0",
|
|
24
24
|
"sharp": "^0.35.4",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"scripts": {
|
|
43
43
|
"build": "tsc -p tsconfig.build.json && tsdown",
|
|
44
44
|
"verify:sandboxd": "node --import tsx scripts/verify-sandboxd-release.ts",
|
|
45
|
+
"verify:rtc": "node --import tsx scripts/verify-rtc-release.ts",
|
|
45
46
|
"verify:board-kernel": "node --import tsx scripts/verify-board-kernel.ts",
|
|
46
47
|
"test": "node ../../scripts/test/run.mjs 'tests/**/*.test.ts'",
|
|
47
48
|
"test:runtime": "node --import tsx --test tests/runtime-*.integration.ts",
|