@neta-art/cohub-cli 10.3.0 → 10.4.2

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.
@@ -1,6 +1,6 @@
1
1
  import { readBoardJsonObject } from "../../board-command-support.js";
2
2
  import { loadBoardDocument, resolveBundledFonts } from "../../board-export.js";
3
- import { boardPathTrack, boardSequencePatch, buildBoardScene, createBoardAnimationSampler, createNodeBoardRenderer, inspectBoardScene, measureBoardText, } from "../../board-kernel.js";
3
+ import { boardPathTrack, boardSequencePatch, buildBoardScene, createBoardAnimationSampler, createNodeBoardRenderer, inspectBoardItems, } from "../../board-kernel.js";
4
4
  import { parseBoardTimes } from "../../board-time.js";
5
5
  import { handleHttp, json as outJson } from "../../output.js";
6
6
  import { resolveSpace } from "../../space.js";
@@ -9,9 +9,14 @@ export function registerBoardAuthoringCommands(boards) {
9
9
  .command("inspect <board>")
10
10
  .description("Measure items and report visible layout hints without changing the Board")
11
11
  .option("--animation <id>", "Animation to sample")
12
- .option("--at <times>", "Time, comma-separated times, or start:end:step", "0")
12
+ .option("--at <times>", "Time, comma-separated times, or start:end:step (up to 100 samples)", "0")
13
13
  .option("--items <ids>", "Limit measurements to comma-separated item ids")
14
14
  .option("--json", "Output as JSON")
15
+ .addHelpText("after", `
16
+ Without --animation, inspects the static document, not playback.
17
+ Bounds use world units with x/y at the top left; worldBounds includes rotation.
18
+ Checks native layout only, not camera cropping or content drawn inside scripts.
19
+ Example: cohub boards inspect lesson.board --animation lecture --at 0:20s:1s --json`)
15
20
  .action(async (target, options) => {
16
21
  try {
17
22
  const { document } = await loadBoardDocument(await resolveSpace(boards), target);
@@ -21,20 +26,13 @@ export function registerBoardAuthoringCommands(boards) {
21
26
  try {
22
27
  const ids = options.items ? new Set(options.items.split(",")) : null;
23
28
  outJson({
24
- samples: times.map((time) => {
25
- const scene = sampler?.at(time).scene ?? buildBoardScene(document);
26
- return {
27
- time,
28
- diagnostics: inspectBoardScene(scene),
29
- items: scene.items
30
- .filter((item) => !ids || ids.has(item.id))
31
- .map((item) => ({
32
- id: item.id,
33
- bounds: item.frame,
34
- ...(item.type === "text" ? { text: measureBoardText(item.props) } : {}),
35
- })),
36
- };
37
- }),
29
+ mode: sampler ? "animation" : "static",
30
+ animation: options.animation ?? null,
31
+ coverage: "Native layout in world coordinates; excludes camera cropping and script-drawn content.",
32
+ samples: times.map((time) => ({
33
+ time,
34
+ ...inspectBoardItems(sampler?.at(time).scene ?? buildBoardScene(document), ids ?? undefined),
35
+ })),
38
36
  });
39
37
  }
40
38
  finally {
@@ -1,6 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import { parseBoardJsonObject } from "../board-command-support.js";
3
3
  export declare const parseJsonObject: typeof parseBoardJsonObject;
4
+ export declare function boardSchemaField(schema: Record<string, unknown> | undefined, path: string): unknown;
4
5
  export declare function parseRect(value: string, name?: string): {
5
6
  x: number;
6
7
  y: number;
@@ -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,7 +1,7 @@
1
1
  import { openAsBlob } from "node:fs";
2
2
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, extname, join } from "node:path";
4
- import { GenerationPolicyError, assertGenerationRequestAllowedByPolicy, parseGenerationPolicyFromEnv, } from "@neta-art/cohub";
4
+ import { GenerationPolicyError, assertGenerationRequestAllowedByPolicy, mimeTypeForPath, parseGenerationPolicyFromEnv, } from "@neta-art/cohub";
5
5
  import { probeMediaInfo } from "@neta-art/cohub/media";
6
6
  import { createClient } from "../client.js";
7
7
  import { resolveSpace } from "../space.js";
@@ -14,19 +14,6 @@ const rolesByMediaType = {
14
14
  audio: new Set(["reference_audio"]),
15
15
  };
16
16
  const mediaRoles = new Set([...frameMediaRoles, ...referenceMediaRoles]);
17
- const mimeByExt = {
18
- ".png": "image/png",
19
- ".jpg": "image/jpeg",
20
- ".jpeg": "image/jpeg",
21
- ".webp": "image/webp",
22
- ".gif": "image/gif",
23
- ".mp4": "video/mp4",
24
- ".webm": "video/webm",
25
- ".mov": "video/quicktime",
26
- ".mp3": "audio/mpeg",
27
- ".wav": "audio/wav",
28
- ".ogg": "audio/ogg",
29
- };
30
17
  function parseValue(value) {
31
18
  const trimmed = value.trim();
32
19
  if (trimmed === "true")
@@ -84,8 +71,8 @@ async function mediaContent({ type, value, role }, upload) {
84
71
  const block = (source) => ({ type, source, ...(role ? { meta: { role } } : {}) });
85
72
  if (/^https?:\/\//.test(value))
86
73
  return block({ type: "url", url: value });
87
- const mediaType = mimeByExt[extname(value).toLowerCase()];
88
- if (mediaType && upload) {
74
+ const mediaType = mimeTypeForPath(value);
75
+ if (mediaType && /^(image|video|audio)\//.test(mediaType) && upload) {
89
76
  try {
90
77
  return block({ type: "url", url: await upload(value, mediaType) });
91
78
  }
@@ -189,11 +176,13 @@ function slugOutputLabel(block) {
189
176
  return slugify(metaRole(block) ?? block.type);
190
177
  }
191
178
  const PROBE_TIMEOUT_MS = 3_000;
192
- /** Size, duration, and frames of URL outputs, probed in parallel; best effort. */
179
+ /** Size, duration, and frames of image, video, and audio URL outputs, probed in parallel; best effort. */
193
180
  async function probeOutputs(output) {
194
181
  const signal = AbortSignal.timeout(PROBE_TIMEOUT_MS);
195
182
  const probed = await Promise.all(output.map(async (block, index) => {
196
- if (block.type === "text" || block.source.type !== "url")
183
+ if (block.type !== "image" && block.type !== "video" && block.type !== "audio")
184
+ return null;
185
+ if (block.source.type !== "url")
197
186
  return null;
198
187
  const info = await probeMediaInfo(block.source.url, { type: block.type, signal });
199
188
  return Object.keys(info).length > 0 ? { index, ...info } : null;
@@ -1,4 +1,4 @@
1
- import type { CohubHttpClient } from "@neta-art/cohub";
1
+ import { type CohubHttpClient } from "@neta-art/cohub";
2
2
  import type { Command } from "commander";
3
3
  type LocalPublicFile = {
4
4
  id: string;
@@ -1,45 +1,12 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { lstat, readdir } from "node:fs/promises";
3
- import { basename, extname, relative, resolve } from "node:path";
3
+ import { basename, relative, resolve } from "node:path";
4
+ import { isTextMime, mimeTypeForPath, } from "@neta-art/cohub";
4
5
  import { createClient } from "../client.js";
5
6
  import { HttpPutError, putLocalFile } from "../http-put.js";
6
7
  import { error, handleHttp, json as outJson, jsonRequested } from "../output.js";
7
8
  import { resolveSpace } from "../space.js";
8
9
  const UPLOAD_CONCURRENCY = 4;
9
- const MIME_TYPES = {
10
- ".aac": "audio/aac",
11
- ".avi": "video/x-msvideo",
12
- ".avif": "image/avif",
13
- ".css": "text/css; charset=utf-8",
14
- ".csv": "text/csv; charset=utf-8",
15
- ".gif": "image/gif",
16
- ".htm": "text/html; charset=utf-8",
17
- ".html": "text/html; charset=utf-8",
18
- ".ico": "image/x-icon",
19
- ".jpeg": "image/jpeg",
20
- ".jpg": "image/jpeg",
21
- ".js": "text/javascript; charset=utf-8",
22
- ".json": "application/json; charset=utf-8",
23
- ".m4a": "audio/mp4",
24
- ".md": "text/markdown; charset=utf-8",
25
- ".mov": "video/quicktime",
26
- ".mp3": "audio/mpeg",
27
- ".mp4": "video/mp4",
28
- ".ogg": "audio/ogg",
29
- ".ogv": "video/ogg",
30
- ".pdf": "application/pdf",
31
- ".png": "image/png",
32
- ".svg": "image/svg+xml",
33
- ".txt": "text/plain; charset=utf-8",
34
- ".wasm": "application/wasm",
35
- ".wav": "audio/wav",
36
- ".webm": "video/webm",
37
- ".webp": "image/webp",
38
- ".woff": "font/woff",
39
- ".woff2": "font/woff2",
40
- ".xml": "application/xml; charset=utf-8",
41
- ".zip": "application/zip",
42
- };
43
10
  function hasControlCharacters(value) {
44
11
  return [...value].some((char) => {
45
12
  const code = char.charCodeAt(0);
@@ -65,8 +32,11 @@ function normalizePublicPath(input) {
65
32
  }
66
33
  return parts.join("/");
67
34
  }
68
- function mimeTypeForPath(path) {
69
- return MIME_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
35
+ function contentTypeForPath(path) {
36
+ const mimeType = mimeTypeForPath(path);
37
+ if (!mimeType)
38
+ return "application/octet-stream";
39
+ return isTextMime(mimeType) ? `${mimeType}; charset=utf-8` : mimeType;
70
40
  }
71
41
  async function walkDirectory(root, directory, destination) {
72
42
  const names = await readdir(directory);
@@ -90,7 +60,7 @@ async function walkDirectory(root, directory, destination) {
90
60
  localPath,
91
61
  publicPath,
92
62
  size: info.size,
93
- mimeType: mimeTypeForPath(localPath),
63
+ mimeType: contentTypeForPath(localPath),
94
64
  });
95
65
  }
96
66
  return files;
@@ -114,7 +84,7 @@ export async function collectPublicUpload(source, destination) {
114
84
  localPath,
115
85
  publicPath,
116
86
  size: info.size,
117
- mimeType: mimeTypeForPath(localPath),
87
+ mimeType: contentTypeForPath(localPath),
118
88
  }],
119
89
  };
120
90
  }
@@ -7,7 +7,7 @@ import { Readable, Transform } from "node:stream";
7
7
  import { pipeline } from "node:stream/promises";
8
8
  import { createGunzip } from "node:zlib";
9
9
  // Pin a global release tag after its helper artifacts are published.
10
- export const RTC_HELPER_VERSION = "v2.67.0";
10
+ export const RTC_HELPER_VERSION = "v2.68.0";
11
11
  const MAX_BINARY_BYTES = 128 * 1024 * 1024;
12
12
  async function digest(path) {
13
13
  const hash = createHash("sha256");
@@ -1,9 +1,9 @@
1
- export declare const SANDBOXD_VERSION = "v2.67.0";
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.67.0";
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), BINARY_NAME);
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
- const EXPECTED_ARCHIVE_ENTRIES = [BINARY_NAME, "LICENSE", "NOTICE"];
89
- export function validSandboxdArchiveEntries(entries) {
90
- return (entries.length === EXPECTED_ARCHIVE_ENTRIES.length &&
91
- EXPECTED_ARCHIVE_ENTRIES.every((entry) => entries.includes(entry)));
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("tar", [verbose ? "-tvzf" : "-tzf", archivePath], { stdio: ["ignore", "pipe", "pipe"] });
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("tar", ["-xzf", archivePath, "-C", cwd], { stdio: ["ignore", "ignore", "pipe"] });
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, BINARY_NAME);
195
+ const extractedBinary = join(tempDir, binaryName(target.goos));
186
196
  if (!(await isSafeArchiveFile(extractedBinary))) {
187
- throw new SandboxdDownloadError(`Archive ${name} did not contain ${BINARY_NAME}`);
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 cached = cachedBinaryPath(version);
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. */
@@ -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
- ? ["", ...(pathExt || ".COM;.EXE;.BAT;.CMD").split(";").filter((ext) => /^\.[a-z0-9]+$/i.test(ext))]
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");
@@ -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 {
@@ -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
- const child = fork(workerUrl, [], { detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"] });
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 (!supportsSharedServer(version)) {
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
- export const supportsSharedServer = (version) => atLeast(version, CODEX_SHARED_MIN_VERSION);
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
  }