@neta-art/cohub-cli 7.0.0 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { createReadStream, createWriteStream } from "node:fs";
4
- import { chmod, copyFile, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
4
+ import { chmod, copyFile, mkdir, mkdtemp, rename, rm, stat, lstat } from "node:fs/promises";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
  import { pipeline } from "node:stream/promises";
@@ -40,9 +40,13 @@ const cacheDir = (version) => join(homedir(), ".cache", "cohub", "sandboxd", ver
40
40
  const cachedBinaryPath = (version) => join(cacheDir(version), BINARY_NAME);
41
41
  const archiveName = (version, target) => `${BINARY_NAME}_${version}_${target.goos}_${target.goarch}.tar.gz`;
42
42
  const isExecutableFile = async (path) => {
43
- const info = await stat(path).catch(() => null);
43
+ const info = await lstat(path).catch(() => null);
44
44
  return Boolean(info?.isFile());
45
45
  };
46
+ const isSafeArchiveFile = async (path) => {
47
+ const info = await lstat(path).catch(() => null);
48
+ return Boolean(info?.isFile() && info.nlink === 1);
49
+ };
46
50
  const sha256File = async (path) => {
47
51
  const hash = createHash("sha256");
48
52
  await pipeline(createReadStream(path), hash);
@@ -87,11 +91,18 @@ const fetchText = (url, accept) => withTimeout(`Download of ${url}`, async (sign
87
91
  throw new SandboxdDownloadError(`Download failed (${response.status}) for ${url}`);
88
92
  return (await response.text()).trim();
89
93
  });
90
- // List the entries of a `.tar.gz` without extracting, so we can reject archives
91
- // that contain anything other than the expected single binary (path traversal,
92
- // symlinks, extra entries) before touching the filesystem.
93
- const listTarGz = (archivePath) => new Promise((res, rej) => {
94
- const child = spawn("tar", ["-tzf", archivePath], { stdio: ["ignore", "pipe", "pipe"] });
94
+ // v1.82.4 is already public as a binary-only archive. The native watcher
95
+ // release adds the two notices; the old shape is accepted only for this pin.
96
+ const CURRENT_BINARY_ONLY_VERSION = "v1.82.4";
97
+ export function validSandboxdArchiveEntries(entries, version = SANDBOXD_VERSION) {
98
+ const binaryOnly = version === CURRENT_BINARY_ONLY_VERSION && entries.length === 1 && entries[0] === BINARY_NAME;
99
+ const expected = [BINARY_NAME, "LICENSE", "NOTICE"];
100
+ const withNotices = entries.length === expected.length && expected.every((entry) => entries.includes(entry));
101
+ return binaryOnly || withNotices;
102
+ }
103
+ // Reject unexpected paths before extracting the checksum-verified release.
104
+ const listTarGz = (archivePath, verbose = false) => new Promise((res, rej) => {
105
+ const child = spawn("tar", [verbose ? "-tvzf" : "-tzf", archivePath], { stdio: ["ignore", "pipe", "pipe"] });
95
106
  let stdout = "";
96
107
  let stderr = "";
97
108
  child.stdout.on("data", (chunk) => {
@@ -105,7 +116,18 @@ const listTarGz = (archivePath) => new Promise((res, rej) => {
105
116
  ? res(stdout.split("\n").map((line) => line.trim()).filter(Boolean))
106
117
  : rej(new SandboxdDownloadError(`tar listing failed: ${stderr.trim() || `exit ${code}`}`)));
107
118
  });
108
- // Extract a single-file `.tar.gz` using the system tar (universally present on
119
+ export async function validateSandboxdArchive(archivePath, version = SANDBOXD_VERSION) {
120
+ const entries = await listTarGz(archivePath);
121
+ if (!validSandboxdArchiveEntries(entries, version)) {
122
+ throw new SandboxdDownloadError(`Unexpected sandbox archive contents: ${entries.join(", ") || "(empty)"}`);
123
+ }
124
+ const details = await listTarGz(archivePath, true);
125
+ if (details.length !== entries.length || details.some((line) => !line.startsWith("-") || /(?: link to | -> | == )/.test(line))) {
126
+ throw new SandboxdDownloadError("Sandbox archive must contain only regular files");
127
+ }
128
+ return entries;
129
+ }
130
+ // Extract the verified `.tar.gz` using the system tar (universally present on
109
131
  // macOS and Linux), keeping the CLI free of native archive dependencies.
110
132
  const extractTarGz = (archivePath, cwd) => new Promise((res, rej) => {
111
133
  const child = spawn("tar", ["-xzf", archivePath, "-C", cwd], { stdio: ["ignore", "ignore", "pipe"] });
@@ -163,16 +185,15 @@ const downloadAndVerify = async (version, target) => {
163
185
  if (actual !== expected) {
164
186
  throw new SandboxdDownloadError(`Checksum mismatch for ${name} (expected ${expected}, got ${actual})`);
165
187
  }
166
- // Verify the archive contains exactly the expected binary before extracting,
167
- // guarding against path traversal / unexpected entries from a tampered CDN.
168
- const entries = await listTarGz(archivePath);
169
- if (entries.length !== 1 || entries[0] !== BINARY_NAME) {
170
- throw new SandboxdDownloadError(`Unexpected archive contents for ${name}: ${entries.join(", ") || "(empty)"}`);
171
- }
172
- // Extract the single binary from the archive.
188
+ const entries = await validateSandboxdArchive(archivePath, version);
173
189
  await extractTarGz(archivePath, tempDir);
190
+ for (const entry of entries) {
191
+ if (!(await isSafeArchiveFile(join(tempDir, entry)))) {
192
+ throw new SandboxdDownloadError(`Unsafe sandbox archive entry: ${entry}`);
193
+ }
194
+ }
174
195
  const extractedBinary = join(tempDir, BINARY_NAME);
175
- if (!(await isExecutableFile(extractedBinary))) {
196
+ if (!(await isSafeArchiveFile(extractedBinary))) {
176
197
  throw new SandboxdDownloadError(`Archive ${name} did not contain ${BINARY_NAME}`);
177
198
  }
178
199
  // Atomically move into the version cache (fall back to copy across devices).
package/dist/index.js CHANGED
@@ -37,7 +37,7 @@ program
37
37
  .summary("Work with Cohub from your terminal")
38
38
  .description("Send prompts, manage Space files, and publish public output.")
39
39
  .version(VERSION, "-v, --version", "Show version")
40
- .option("-s, --space <id>", "Target Space ID (defaults to your Home space)")
40
+ .option("-s, --space <id>", "Target Space ID")
41
41
  .option("--json", "Print machine-readable JSON when supported")
42
42
  .helpOption("-h, --help", "Show help")
43
43
  .addHelpText("after", `
@@ -54,6 +54,7 @@ Common commands:
54
54
  cohub completion "Summarize AGENTS.md" --system-prompt AGENTS.md --stream
55
55
  cohub run -- git status
56
56
  cohub runtime up ./my-project
57
+ cohub runtime logs --follow
57
58
  cohub search "release notes"
58
59
  cohub -s <space-id> boards inspect <board-id>
59
60
  cohub -s <space-id> spaces turns ls --author others
@@ -68,13 +69,14 @@ Common commands:
68
69
  cohub generate "A calm lake at sunrise" --model <model> --output lake.png
69
70
 
70
71
  Target space:
71
- -s <space-id>, then COHUB_SPACE_ID, then your Home space
72
+ -s <space-id>, then COHUB_SPACE_ID, then the current directory Runtime binding, then Home
72
73
 
73
74
  Environment:
74
75
  COHUB_SPACE_ID Target Space ID when -s is omitted
75
76
  COHUB_EXECUTION_TOKEN Use this token instead of the stored Logto session
76
77
  ENV=dev Use the development Cohub environment
77
78
  HTTPS_PROXY Honored for API and uploads (also HTTP_PROXY, NO_PROXY)
79
+ Runtime logs ~/.local/state/cohub/runtime/<space-id>/diagnostics
78
80
  `);
79
81
  registerAuth(program);
80
82
  registerBoards(program);
@@ -23,7 +23,9 @@ export declare class RuntimeArchiveStore {
23
23
  private readonly transport?;
24
24
  private flushing;
25
25
  private readonly capturing;
26
+ private errorReporter;
26
27
  constructor(root: string, transport?: ArchiveTransport | undefined);
28
+ setErrorReporter(reporter: ((error: unknown, index?: HarnessArchiveIndex) => void) | null): void;
27
29
  pendingCount(): Promise<number>;
28
30
  failedCaptureCount(): Promise<number>;
29
31
  hasCapture(turnId: string): Promise<boolean>;
@@ -44,10 +44,14 @@ export class RuntimeArchiveStore {
44
44
  transport;
45
45
  flushing = null;
46
46
  capturing = new Map();
47
+ errorReporter = null;
47
48
  constructor(root, transport) {
48
49
  this.root = root;
49
50
  this.transport = transport;
50
51
  }
52
+ setErrorReporter(reporter) {
53
+ this.errorReporter = reporter;
54
+ }
51
55
  async pendingCount() {
52
56
  const pending = new Set();
53
57
  for (const directory of ["pending", "captures"]) {
@@ -110,7 +114,7 @@ export class RuntimeArchiveStore {
110
114
  const saved = await this.readIndex(this.version(turnId));
111
115
  if (saved) {
112
116
  if (saved.sessionId !== state.sessionId || saved.harness !== state.harness)
113
- throw new Error("Archive identity mismatch / 归档身份不匹配");
117
+ throw new Error("Archive identity mismatch");
114
118
  const committed = await stat(join(this.root, "ready", `${turnId}.json`)).catch((error) => { if (missing(error))
115
119
  return null; throw error; });
116
120
  if (!committed)
@@ -125,7 +129,7 @@ export class RuntimeArchiveStore {
125
129
  try {
126
130
  const before = await file.stat();
127
131
  if (!before.isFile() || !before.size)
128
- throw new Error("Native archive is empty / 原生归档为空");
132
+ throw new Error("Native archive is empty");
129
133
  const buffer = Buffer.alloc(RUNTIME_ARCHIVE_SEGMENT_BYTES);
130
134
  let offset = 0;
131
135
  let digest = createHash("sha256");
@@ -135,7 +139,7 @@ export class RuntimeArchiveStore {
135
139
  while (offset < previous.sizeBytes) {
136
140
  const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, previous.sizeBytes - offset), offset);
137
141
  if (!bytesRead)
138
- throw new Error("Native file changed during capture / 归档时文件发生变化");
142
+ throw new Error("Native file changed during capture");
139
143
  digest.update(buffer.subarray(0, bytesRead));
140
144
  offset += bytesRead;
141
145
  }
@@ -151,7 +155,7 @@ export class RuntimeArchiveStore {
151
155
  while (offset < before.size) {
152
156
  const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, before.size - offset), offset);
153
157
  if (!bytesRead)
154
- throw new Error("Native file changed during capture / 归档时文件发生变化");
158
+ throw new Error("Native file changed during capture");
155
159
  const bytes = buffer.subarray(0, bytesRead);
156
160
  digest.update(bytes);
157
161
  const segment = { offset, sizeBytes: bytesRead, sha256: hash(bytes), md5: hash(bytes, "md5") };
@@ -161,7 +165,7 @@ export class RuntimeArchiveStore {
161
165
  }
162
166
  const after = await stat(state.path);
163
167
  if (before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs)
164
- throw new Error("Native file changed during capture / 归档时文件发生变化");
168
+ throw new Error("Native file changed during capture");
165
169
  if (process.platform !== "win32") {
166
170
  const directory = await open(join(this.root, "objects"), "r");
167
171
  try {
@@ -219,7 +223,7 @@ export class RuntimeArchiveStore {
219
223
  queue.push(index);
220
224
  }
221
225
  if (pending.size && !queue.length)
222
- throw new Error("Cyclic archive outbox / 归档队列引用循环");
226
+ throw new Error("Cyclic archive outbox");
223
227
  for (let cursor = 0; cursor < queue.length; cursor++) {
224
228
  signal.throwIfAborted();
225
229
  const index = queue[cursor];
@@ -228,19 +232,19 @@ export class RuntimeArchiveStore {
228
232
  try {
229
233
  const { uploads } = await transport.prepareRuntimeArchive(index, { signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]) });
230
234
  if (uploads.length > index.segments.length)
231
- throw new Error("Upload plan mismatch / 上传计划不匹配");
235
+ throw new Error("Upload plan mismatch");
232
236
  const expected = new Set(index.segments.map((segment) => JSON.stringify(segment)));
233
237
  for (const { segment, uploadUrl, headers } of uploads) {
234
238
  if (!expected.has(JSON.stringify(segment)))
235
- throw new Error("Upload segment mismatch / 上传分段不匹配");
239
+ throw new Error("Upload segment mismatch");
236
240
  signal.throwIfAborted();
237
241
  const bytes = await readFile(this.blob(segment.sha256));
238
242
  if (bytes.length !== segment.sizeBytes || hash(bytes) !== segment.sha256)
239
- throw new Error("Local archive segment is corrupt / 本地归档分段已损坏");
243
+ throw new Error("Local archive segment is corrupt");
240
244
  const response = await (transport.fetchObject ?? fetch)(uploadUrl, { method: "PUT", headers, body: bytes, redirect: "error", signal: AbortSignal.any([signal, AbortSignal.timeout(60_000)]) });
241
245
  // An immutable segment may already exist after a lost acknowledgement. Commit verifies it.
242
246
  if (!response.ok && ![409, 412].includes(response.status))
243
- throw new Error(`Archive upload failed / 归档上传失败: ${response.status}`);
247
+ throw new Error(`Archive upload failed: ${response.status}`);
244
248
  }
245
249
  await transport.commitRuntimeArchive(index, { signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]) });
246
250
  await atomicRuntimeJson(join(this.root, "ready", `${index.turnId}.json`), { turnId: index.turnId, sha256: index.sha256 });
@@ -248,14 +252,16 @@ export class RuntimeArchiveStore {
248
252
  queue.push(...children.get(index.turnId) ?? []);
249
253
  }
250
254
  catch (error) {
251
- if (!signal.aborted)
252
- console.error("Archive pending; native segments retained / 归档待重试,原始分段已保留:", error);
255
+ if (!signal.aborted) {
256
+ this.errorReporter?.(error, index);
257
+ console.error("Archive pending; native segments retained:", error);
258
+ }
253
259
  }
254
260
  }
255
261
  }
256
262
  async restore(reference, target, signal) {
257
263
  if (!this.transport)
258
- throw new Error("Archive transport unavailable / 归档传输不可用");
264
+ throw new Error("Archive transport unavailable");
259
265
  const timeout = (ms) => signal ? AbortSignal.any([signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms);
260
266
  const pages = [];
261
267
  const visited = new Set();
@@ -263,18 +269,18 @@ export class RuntimeArchiveStore {
263
269
  while (turnId) {
264
270
  signal?.throwIfAborted();
265
271
  if (visited.has(turnId))
266
- throw new Error("Cyclic archive / 归档引用循环");
272
+ throw new Error("Cyclic archive");
267
273
  visited.add(turnId);
268
274
  const page = await this.transport.getRuntimeArchive(reference.sessionId, turnId, { signal: timeout(30_000) });
269
275
  const index = harnessArchiveIndexSchema.parse(page.index);
270
276
  if (index.turnId !== turnId || index.sessionId !== reference.sessionId || index.harness !== reference.harness)
271
- throw new Error("Archive identity mismatch / 归档身份不匹配");
277
+ throw new Error("Archive identity mismatch");
272
278
  pages.push({ ...page, index });
273
279
  turnId = index.parentTurnId;
274
280
  }
275
281
  const head = pages[0]?.index;
276
282
  if (!head)
277
- throw new Error("Archive missing / 归档不存在");
283
+ throw new Error("Archive missing");
278
284
  await mkdir(dirname(target), { recursive: true, mode: 0o700 });
279
285
  const temporary = `${target}.${randomUUID()}.restoring`;
280
286
  const file = await open(temporary, "wx", 0o600);
@@ -284,53 +290,53 @@ export class RuntimeArchiveStore {
284
290
  for (const page of pages.reverse()) {
285
291
  validateArchiveBoundary(page.index, parent);
286
292
  if (page.segments.length !== page.index.segments.length)
287
- throw new Error("Missing archive segments / 归档分段缺失");
293
+ throw new Error("Missing archive segments");
288
294
  for (const [ordinal, expected] of page.index.segments.entries()) {
289
295
  signal?.throwIfAborted();
290
296
  const cached = await readFile(this.blob(expected.sha256)).catch((error) => { if (missing(error))
291
297
  return null; throw error; });
292
298
  if (cached) {
293
299
  if (cached.length !== expected.sizeBytes || hash(cached) !== expected.sha256)
294
- throw new Error("Cached archive segment is corrupt / 缓存归档分段已损坏");
300
+ throw new Error("Cached archive segment is corrupt");
295
301
  digest.update(cached);
296
302
  await file.writeFile(cached);
297
303
  continue;
298
304
  }
299
305
  let link = page.segments[ordinal];
300
306
  if (!link || JSON.stringify(link.segment) !== JSON.stringify(expected))
301
- throw new Error("Archive segment identity mismatch / 归档分段标识不匹配");
307
+ throw new Error("Archive segment identity mismatch");
302
308
  let response = await (this.transport.fetchObject ?? fetch)(link.downloadUrl, { redirect: "error", signal: timeout(60_000) });
303
309
  if ([401, 403].includes(response.status)) {
304
310
  const refreshed = await this.transport.getRuntimeArchive(reference.sessionId, page.index.turnId, { signal: timeout(30_000) });
305
311
  link = refreshed.segments[ordinal];
306
312
  if (!link || JSON.stringify(link.segment) !== JSON.stringify(expected))
307
- throw new Error("Archive segment missing / 归档分段缺失");
313
+ throw new Error("Archive segment missing");
308
314
  response = await (this.transport.fetchObject ?? fetch)(link.downloadUrl, { redirect: "error", signal: timeout(60_000) });
309
315
  }
310
316
  if (!response.ok || !response.body)
311
- throw new Error(`Archive download failed / 归档下载失败: ${response.status}`);
317
+ throw new Error(`Archive download failed: ${response.status}`);
312
318
  const segmentHash = createHash("sha256");
313
319
  let size = 0;
314
320
  const chunks = [];
315
321
  for await (const chunk of response.body) {
316
322
  size += chunk.length;
317
323
  if (size > expected.sizeBytes)
318
- throw new Error("Archive size mismatch / 归档大小不匹配");
324
+ throw new Error("Archive size mismatch");
319
325
  chunks.push(chunk);
320
326
  segmentHash.update(chunk);
321
327
  digest.update(chunk);
322
328
  await file.writeFile(chunk);
323
329
  }
324
330
  if (size !== expected.sizeBytes || segmentHash.digest("hex") !== expected.sha256)
325
- throw new Error("Archive checksum mismatch / 归档校验失败");
331
+ throw new Error("Archive checksum mismatch");
326
332
  await this.saveBlob(expected.sha256, Buffer.concat(chunks));
327
333
  }
328
334
  if (digest.copy().digest("hex") !== page.index.sha256)
329
- throw new Error("Archive version checksum mismatch / 归档版本校验失败");
335
+ throw new Error("Archive version checksum mismatch");
330
336
  parent = page.index;
331
337
  }
332
338
  if ((await file.stat()).size !== head.sizeBytes)
333
- throw new Error("Archive length mismatch / 归档长度不匹配");
339
+ throw new Error("Archive length mismatch");
334
340
  await file.sync();
335
341
  await file.close();
336
342
  await link(temporary, target);
@@ -1,6 +1,7 @@
1
1
  import { type RuntimeCapabilities } from "@neta-art/cohub";
2
2
  import { type HarnessOptions } from "./harness.js";
3
3
  import { type RuntimeSessionStore } from "./session-store.js";
4
+ import { type RuntimeDiagnostics } from "./diagnostics.js";
4
5
  export type RuntimeConnectionOptions = {
5
6
  spaceId: string;
6
7
  cwd: string;
@@ -11,6 +12,8 @@ export type RuntimeConnectionOptions = {
11
12
  signal: AbortSignal;
12
13
  store: RuntimeSessionStore;
13
14
  onReady: () => void;
15
+ runtimeId?: string;
16
+ diagnostics?: RuntimeDiagnostics;
14
17
  leaseConflictTimeoutMs?: number;
15
18
  };
16
19
  export declare function serveRuntime(options: RuntimeConnectionOptions): Promise<void>;