@neta-art/cohub-cli 10.3.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 +30 -0
- 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 +109 -39
- package/dist/commands/boards/authoring.js +14 -16
- package/dist/commands/boards.d.ts +1 -0
- package/dist/commands/boards.js +27 -1
- 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 +2 -2
package/dist/commands/boards.js
CHANGED
|
@@ -4,6 +4,7 @@ import { mkdir } from "node:fs/promises";
|
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
import { BOARD_PRESET_NAMES, BOARD_SCHEMA_TARGETS, BOARD_SCRIPT_API, boardJsonSchema, boardPresetTracks, boardSchemaOverview, isBoardPresetName, listBoardPresets, } from "@neta-art/cohub/board";
|
|
6
6
|
import { BOARD_CREATE_INPUT_MAX_BYTES, BOARD_TRANSACTION_INPUT_MAX_BYTES, parseBoardJsonObject, readBoardJsonObject, resolveBoardId, writeBoardOutput, } from "../board-command-support.js";
|
|
7
|
+
import { BOARD_EXPORT_IMAGE_MEMORY_MIB } from "../board-export-resources.js";
|
|
7
8
|
import { formatBoardTime, parseBoardTime, parseBoardTimes } from "../board-time.js";
|
|
8
9
|
import { createClient, createRealtimeClient } from "../client.js";
|
|
9
10
|
import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
|
|
@@ -11,6 +12,16 @@ import { resolveSpace } from "../space.js";
|
|
|
11
12
|
import { registerBoardExampleCommands } from "./boards/examples.js";
|
|
12
13
|
import { registerBoardAuthoringCommands } from "./boards/authoring.js";
|
|
13
14
|
export const parseJsonObject = parseBoardJsonObject;
|
|
15
|
+
export function boardSchemaField(schema, path) {
|
|
16
|
+
let field = schema;
|
|
17
|
+
for (const part of path.split(".")) {
|
|
18
|
+
const properties = field?.properties;
|
|
19
|
+
if (!part || !properties || !Object.hasOwn(properties, part))
|
|
20
|
+
throw new Error(`Unknown schema field ${path}.`);
|
|
21
|
+
field = properties[part];
|
|
22
|
+
}
|
|
23
|
+
return field;
|
|
24
|
+
}
|
|
14
25
|
function withJson(command) {
|
|
15
26
|
return command.option("--json", "Output as JSON");
|
|
16
27
|
}
|
|
@@ -152,6 +163,7 @@ function registerExportCommand(boards) {
|
|
|
152
163
|
.option("--format <format>", `Override the format (${BOARD_EXPORT_FORMATS.join(", ")}, ${BOARD_VIDEO_FORMATS.join(", ")})`)
|
|
153
164
|
.option("--quality <q>", "JPEG/WebP quality from 0 to 1", "0.92")
|
|
154
165
|
.option("--fps <rate>", "Video frame rate; defaults to the --at step")
|
|
166
|
+
.option("--image-memory <mib>", "Decoded image memory budget in MiB", String(BOARD_EXPORT_IMAGE_MEMORY_MIB))
|
|
155
167
|
.option("--no-images", "Draw placeholders instead of downloading images")
|
|
156
168
|
.option("--force", "Replace existing output files"))
|
|
157
169
|
.addHelpText("after", `
|
|
@@ -180,6 +192,11 @@ Examples:
|
|
|
180
192
|
if (!jsonRequested(options) && (completed === total || completed % 30 === 0))
|
|
181
193
|
process.stderr.write(`Rendered ${completed}/${total} frames\n`);
|
|
182
194
|
};
|
|
195
|
+
const imageMemoryMiB = parseNumber(options.imageMemory ?? String(BOARD_EXPORT_IMAGE_MEMORY_MIB), "--image-memory", { min: 1, max: 65536, integer: true });
|
|
196
|
+
const onPlan = ({ width, height, frames }) => {
|
|
197
|
+
if (!jsonRequested(options))
|
|
198
|
+
process.stderr.write(`Rendering ${width}x${height}, ${frames} frame(s)\n`);
|
|
199
|
+
};
|
|
183
200
|
const pathVideoFormat = videoFormatFromPath(out);
|
|
184
201
|
const requestedFormat = options.format?.toLowerCase();
|
|
185
202
|
if (requestedFormat && ![...BOARD_EXPORT_FORMATS, ...BOARD_VIDEO_FORMATS].includes(requestedFormat))
|
|
@@ -204,6 +221,8 @@ Examples:
|
|
|
204
221
|
target,
|
|
205
222
|
camera,
|
|
206
223
|
onProgress: progress,
|
|
224
|
+
onPlan,
|
|
225
|
+
imageMemoryMiB,
|
|
207
226
|
signal: controller.signal,
|
|
208
227
|
force: options.force,
|
|
209
228
|
region: parseExportRegion(options),
|
|
@@ -238,6 +257,8 @@ Examples:
|
|
|
238
257
|
target,
|
|
239
258
|
camera,
|
|
240
259
|
onProgress: progress,
|
|
260
|
+
onPlan,
|
|
261
|
+
imageMemoryMiB,
|
|
241
262
|
signal: controller.signal,
|
|
242
263
|
onFrame: async (frame, index) => {
|
|
243
264
|
const path = sequence ? framePath(out, index) : out;
|
|
@@ -448,17 +469,22 @@ Times are milliseconds; 500ms, 12.5s and 2m also work on the command line.`);
|
|
|
448
469
|
});
|
|
449
470
|
boards.command("schema [target]")
|
|
450
471
|
.description("Print JSON Schema for a part of a Board")
|
|
472
|
+
.option("--field <path>", "Print one field, e.g. props.decoration or props.geometry")
|
|
451
473
|
.addHelpText("after", `
|
|
452
474
|
Without a target: units, item types, colors and every animatable property.
|
|
453
475
|
With a target: its fields and what they mean; script also lists the module API.
|
|
454
476
|
Targets: ${BOARD_SCHEMA_TARGETS.join(", ")}`)
|
|
455
|
-
.action((target) => {
|
|
477
|
+
.action((target, options) => {
|
|
456
478
|
try {
|
|
479
|
+
if (options.field && !target)
|
|
480
|
+
throw new Error("--field needs a schema target, e.g. schema frame --field props.decoration.");
|
|
457
481
|
if (!target)
|
|
458
482
|
return outJson({ ...boardSchemaOverview(), presets: listBoardPresets() });
|
|
459
483
|
const schema = boardJsonSchema(target);
|
|
460
484
|
if (!schema)
|
|
461
485
|
throw new Error(`Unknown target ${target}; expected ${BOARD_SCHEMA_TARGETS.join(", ")}.`);
|
|
486
|
+
if (options.field)
|
|
487
|
+
return outJson(boardSchemaField(schema.schema, options.field));
|
|
462
488
|
outJson(target === "script" ? { ...schema, api: BOARD_SCRIPT_API } : schema);
|
|
463
489
|
}
|
|
464
490
|
catch (cause) {
|
|
@@ -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
|
}
|