@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.
- 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 +490 -51
- 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/generations.js +7 -18
- package/dist/commands/public.d.ts +1 -1
- package/dist/commands/public.js +9 -39
- package/dist/commands/rooms-rtc-binary.js +1 -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/dist/space-files/upload.js +2 -19
- package/package.json +3 -3
|
@@ -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,
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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;
|
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,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 =
|
|
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
|
|
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;
|
package/dist/commands/public.js
CHANGED
|
@@ -1,45 +1,12 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { lstat, readdir } from "node:fs/promises";
|
|
3
|
-
import { basename,
|
|
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
|
|
69
|
-
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
}
|