@neta-art/cohub-cli 7.0.1 → 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.
package/README.md CHANGED
@@ -72,7 +72,8 @@ cohub -s <spaceId> spaces invites revoke <code> --yes
72
72
  cohub -s <spaceId> run -- git status
73
73
  ```
74
74
 
75
- Many space-scoped commands need a target Space:
75
+ Many space-scoped commands need a target Space. Without an explicit target, commands
76
+ also use the Space remembered for the current directory before falling back to Home:
76
77
 
77
78
  ```bash
78
79
  cohub -s <spaceId> spaces prompt "message" --json
@@ -88,14 +89,25 @@ cohub runtime up ./project --harness pi --harness codex
88
89
  cohub runtime up ./project --space <spaceId> --harness codex
89
90
  cohub -s <spaceId> spaces prompt "Continue" --harness codex
90
91
  cohub runtime status --space <spaceId>
92
+ cohub runtime logs --space <spaceId> --follow
91
93
  ```
92
94
 
95
+ Runtime diagnostics stay as redacted JSONL under the local Runtime state directory and
96
+ are never uploaded automatically. Use `runtime logs --json` to export a failure report;
97
+ events carry the local `runtimeId` plus server `requestId` / `traceparent` when a turn has
98
+ reached the Agent trace.
99
+
93
100
  The Runtime uses WebSockets, native Pi RPC / Codex app-server, and existing cloud
94
101
  streaming. Local executables and credentials are required. `sandbox up` has been
95
102
  removed. Reconnection and result reconciliation are automatic; no recovery command is needed.
96
103
  When a result genuinely cannot be determined, the affected Chat offers an explicit stop
97
104
  confirmation. See [Runtime details](../../docs/local-runtime.md).
98
105
 
106
+ When `--space` is omitted, Runtime remembers a Space for the canonical local directory,
107
+ account, and environment in `~/.config/cohub/runtime-spaces.json`. The first start creates
108
+ and records a local Space; later starts reuse it. An explicit `--space` or `COHUB_SPACE_ID`
109
+ overrides and updates the directory binding.
110
+
99
111
  ## Chats and prompts
100
112
 
101
113
  Use `spaces prompt` for immediate sends, delayed sends, one-time schedules, recurring schedules, new Chats, and existing Chats.
@@ -1,5 +1,14 @@
1
1
  import { resolveOpenSurface } from "@neta-art/cohub";
2
2
  import type { Command } from "commander";
3
3
  export { resolveOpenSurface };
4
+ /**
5
+ * Optional disambiguation for file:// vs app://. Unlike ordinary
6
+ * Space-scoped commands, an unbound directory must not fall back to Home:
7
+ * the plain target should remain eligible for App resolution.
8
+ */
9
+ export declare function resolveOptionalSpaceId(command: Command, options?: {
10
+ cwd?: string;
11
+ bindingsPath?: string;
12
+ }): Promise<string | undefined>;
4
13
  export declare function registerDesktop(program: Command): void;
5
14
  export declare function registerLegacyUi(program: Command): void;
@@ -2,13 +2,18 @@ import { readFileSync } from "node:fs";
2
2
  import { HttpError, parseAppRef, resolveOpenSurface, DESKTOP_COMMAND_DEFAULT_TIMEOUT_MS, DESKTOP_COMMAND_MAX_TIMEOUT_MS, } from "@neta-art/cohub";
3
3
  import { createClient } from "../client.js";
4
4
  import { error, handleHttp, json as outJson, jsonRequested, ok } from "../output.js";
5
+ import { resolveBoundSpace } from "../space.js";
5
6
  import { getAppByRef } from "../app-ref.js";
6
7
  export { resolveOpenSurface };
7
8
  const FILE_SCHEME = "file://";
8
9
  const APP_SCHEME = "app://";
9
10
  const LEGACY_WORK_SCHEME = "work://";
10
- /** Optional disambiguation for file:// vs app:// — do not fall back to Home. */
11
- function optionalSpaceId(command) {
11
+ /**
12
+ * Optional disambiguation for file:// vs app://. Unlike ordinary
13
+ * Space-scoped commands, an unbound directory must not fall back to Home:
14
+ * the plain target should remain eligible for App resolution.
15
+ */
16
+ export async function resolveOptionalSpaceId(command, options = {}) {
12
17
  let current = command;
13
18
  while (current) {
14
19
  const opts = current.opts();
@@ -16,7 +21,10 @@ function optionalSpaceId(command) {
16
21
  return opts.space.trim();
17
22
  current = current.parent ?? null;
18
23
  }
19
- return process.env.COHUB_SPACE_ID?.trim() || undefined;
24
+ const fromEnvironment = process.env.COHUB_SPACE_ID?.trim();
25
+ if (fromEnvironment)
26
+ return fromEnvironment;
27
+ return (await resolveBoundSpace(options)) ?? undefined;
20
28
  }
21
29
  function parseFilePath(value) {
22
30
  const path = value.slice(FILE_SCHEME.length).trim();
@@ -86,7 +94,7 @@ async function resolveOpenTarget(client, command, value) {
86
94
  return { kind: "file", path: parseFilePath(value) };
87
95
  if (hasAppScheme(value))
88
96
  return resolveAppTarget(client, value);
89
- const spaceId = optionalSpaceId(command);
97
+ const spaceId = await resolveOptionalSpaceId(command);
90
98
  if (spaceId) {
91
99
  try {
92
100
  await client.space(spaceId).files.read(value);
@@ -190,7 +198,7 @@ const OPEN_NOTES = `
190
198
  Notes:
191
199
  - Use file:// and app:// to make the target explicit; the legacy work://
192
200
  scheme is still accepted.
193
- - A plain target checks the current Space for a file before resolving an app.
201
+ - A plain target checks the explicit Space or current directory Runtime binding for a file before resolving an app; it does not fall back to Home.
194
202
  - Opening a window is idempotent; repeating it re-activates the same tab.
195
203
  - --as picks the surface: window (a preview tab) or overlay (a transparent
196
204
  layer above the workspace). Without it, an App published with
@@ -1,2 +1,13 @@
1
+ type RunCliOptions = {
2
+ spaceId: string;
3
+ json: boolean;
4
+ async: boolean;
5
+ command: string;
6
+ };
7
+ export declare function parseRunCliOptions(argv: string[], spaceOptions?: {
8
+ cwd?: string;
9
+ bindingsPath?: string;
10
+ }): Promise<RunCliOptions>;
1
11
  export declare function printRunHelp(): void;
2
12
  export declare function maybeHandleRunCommand(argv: string[]): Promise<boolean>;
13
+ export {};
@@ -1,6 +1,6 @@
1
1
  import { createClient } from "../client.js";
2
2
  import { error, handleHttp, json as outJson, spinner } from "../output.js";
3
- import { missingSpaceError, resolveDefaultSpace } from "../space.js";
3
+ import { resolveSpaceTarget } from "../space.js";
4
4
  const DEFAULT_WAIT_TIMEOUT_MS = (6 * 60 * 60 + 60) * 1000;
5
5
  const DEFAULT_POLL_INTERVAL_MS = 1500;
6
6
  function shellQuote(value) {
@@ -44,7 +44,7 @@ function parseSpaceId(tokens) {
44
44
  }
45
45
  return undefined;
46
46
  }
47
- async function parseRunCliOptions(argv) {
47
+ export async function parseRunCliOptions(argv, spaceOptions = {}) {
48
48
  const runIndex = topLevelRunIndex(argv);
49
49
  if (runIndex < 0)
50
50
  return error("Invalid invocation", "Use `cohub run [options] <command>`");
@@ -104,7 +104,7 @@ async function parseRunCliOptions(argv) {
104
104
  if (!command) {
105
105
  return error("No command", "Pass --command <shell command>, or use `--` followed by the command.");
106
106
  }
107
- const spaceId = explicitSpaceId || (await resolveDefaultSpace().catch(handleHttp)) || missingSpaceError();
107
+ const spaceId = await resolveSpaceTarget(explicitSpaceId, spaceOptions);
108
108
  return { spaceId, json, async, command };
109
109
  }
110
110
  export function printRunHelp() {
@@ -125,7 +125,7 @@ Examples:
125
125
  cohub -s <spaceId> run -- git status -sb
126
126
 
127
127
  Notes:
128
- - Without -s or COHUB_SPACE_ID, the command targets your Home space.
128
+ - Without -s or COHUB_SPACE_ID, the command uses the current directory Runtime binding, then Home.
129
129
  - Use --command for commands that contain leading flags, or use -- before the shell command.
130
130
  - The command runs in /workspace.
131
131
  `);
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { stat } from "node:fs/promises";
3
4
  import { basename, resolve } from "node:path";
4
5
  import { createInterface } from "node:readline/promises";
@@ -6,12 +7,77 @@ import { isLocalHarness, resolveCohubEnvironment, resolveWebsocketUrl } from "@n
6
7
  import { requireAccessToken } from "../auth.js";
7
8
  import { createClient } from "../client.js";
8
9
  import { error, json as outJson, jsonRequested } from "../output.js";
9
- import { resolveSpace } from "../space.js";
10
+ import { currentIdentityKey, explicitSpace, resolveSpace } from "../space.js";
11
+ import { canonicalRuntimeRoot, resolveRuntimeSpace } from "../runtime/space-binding.js";
10
12
  import { discoverHarnesses } from "../runtime/harness.js";
11
13
  import { serveRuntime } from "../runtime/connection.js";
12
14
  import { RuntimeSessionStore } from "../runtime/session-store.js";
13
15
  import { ensureSandboxdBinary } from "./sandboxd-binary.js";
16
+ import { readRuntimeDiagnosticEvents, RuntimeDiagnosticReader, RuntimeDiagnostics, runtimeDiagnosticsDirectory, serializeDiagnosticError, } from "../runtime/diagnostics.js";
14
17
  export const resolveLocalSpaceName = (root, name) => name?.trim() || basename(root) || "local-space";
18
+ function sandboxOutputLevel(value, stream) {
19
+ const level = typeof value === "string" ? value.toLowerCase() : "";
20
+ if (level.includes("error"))
21
+ return "error";
22
+ if (level.includes("warn"))
23
+ return "warn";
24
+ return stream === "stderr" ? "error" : "debug";
25
+ }
26
+ function captureSandboxOutput(stream, streamName, diagnostics) {
27
+ if (!stream)
28
+ return;
29
+ let pending = "";
30
+ const consume = (chunk) => {
31
+ pending += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
32
+ let newline = pending.indexOf("\n");
33
+ while (newline >= 0) {
34
+ const line = pending.slice(0, newline).trim();
35
+ pending = pending.slice(newline + 1);
36
+ if (line)
37
+ recordSandboxOutput(line, streamName, diagnostics);
38
+ newline = pending.indexOf("\n");
39
+ }
40
+ };
41
+ stream.on("data", consume);
42
+ stream.on("end", () => {
43
+ if (pending.trim())
44
+ recordSandboxOutput(pending.trim(), streamName, diagnostics);
45
+ });
46
+ }
47
+ function recordSandboxOutput(line, streamName, diagnostics) {
48
+ let parsed = null;
49
+ try {
50
+ const value = JSON.parse(line);
51
+ if (value && typeof value === "object" && !Array.isArray(value))
52
+ parsed = value;
53
+ }
54
+ catch {
55
+ // Older or third-party binaries may still emit text logs.
56
+ }
57
+ const level = sandboxOutputLevel(parsed?.level, streamName);
58
+ const message = typeof parsed?.msg === "string" ? parsed.msg : line;
59
+ const data = parsed
60
+ ? Object.fromEntries(Object.entries(parsed).filter(([key]) => !["msg", "level", "time"].includes(key)))
61
+ : { message: line };
62
+ diagnostics.log(level, "sandboxd.log", {
63
+ stream: streamName,
64
+ message,
65
+ ...(level === "error" ? { error: { message } } : {}),
66
+ ...data,
67
+ }, { component: "sandboxd" });
68
+ }
69
+ function printDiagnostic(event) {
70
+ const scope = [event.component, event.event].filter(Boolean).join(".");
71
+ const context = [
72
+ event.connectionId && `connection=${event.connectionId}`,
73
+ event.sessionId && `session=${event.sessionId}`,
74
+ event.turnId && `turn=${event.turnId}`,
75
+ event.traceContext?.requestId && `request=${event.traceContext.requestId}`,
76
+ event.traceContext?.traceId && `trace=${event.traceContext.traceId}`,
77
+ ].filter(Boolean).join(" ");
78
+ const data = event.data && Object.keys(event.data).length > 0 ? ` ${JSON.stringify(event.data)}` : "";
79
+ process.stdout.write(`${event.timestamp} ${event.level.toUpperCase().padEnd(5)} ${scope}${context ? ` ${context}` : ""}${data}${event.error ? ` ${JSON.stringify(event.error)}` : ""}\n`);
80
+ }
15
81
  export function parseRuntimeHarnesses(values) {
16
82
  const names = values.flatMap((value) => value.split(",")).map((name) => name.trim()).filter(Boolean);
17
83
  if (names.some((name) => !isLocalHarness(name)))
@@ -35,9 +101,10 @@ export function registerRuntime(program) {
35
101
  process.once("SIGINT", stop);
36
102
  process.once("SIGTERM", stop);
37
103
  try {
38
- const root = resolve(dir ?? process.cwd());
39
- if (!(await stat(root)).isDirectory())
104
+ const requestedRoot = resolve(dir ?? process.cwd());
105
+ if (!(await stat(requestedRoot)).isDirectory())
40
106
  throw new Error("Workspace is not a directory");
107
+ const root = await canonicalRuntimeRoot(requestedRoot);
41
108
  const harnesses = parseRuntimeHarnesses(options.harness);
42
109
  if (!options.yes) {
43
110
  if (!process.stdin.isTTY)
@@ -54,47 +121,110 @@ export function registerRuntime(program) {
54
121
  }
55
122
  const capabilities = await discoverHarnesses(harnesses, options, root);
56
123
  const client = createClient();
57
- const requested = options.space?.trim() || program.opts().space?.trim();
58
- const spaceId = requested || (await client.spaces.create({ name: resolveLocalSpaceName(root, options.name), config: { sandbox: { provider: "local" } } })).space.id;
59
- const sandbox = (await client.space(spaceId).sandbox.get()).sandbox;
60
- if (sandbox?.provider !== "local")
61
- throw new Error("Space does not have a local Runtime");
62
- const binary = await ensureSandboxdBinary();
63
- const wsBase = resolveWebsocketUrl({ url: process.env.COHUB_WS_URL });
64
- const url = new URL(wsBase);
65
- url.pathname = "/runtime/relay";
66
- const relay = new URL(wsBase);
67
- relay.pathname = "/sandbox/relay";
68
- let bridge = null;
69
- let bridgeClosed = Promise.resolve();
70
- const token = await requireAccessToken();
124
+ const requested = options.space?.trim() || explicitSpace(program);
125
+ const validateLocalRuntime = async (spaceId) => {
126
+ const sandbox = (await client.space(spaceId).sandbox.get()).sandbox;
127
+ if (sandbox?.provider !== "local")
128
+ throw new Error("Space does not have a local Runtime");
129
+ };
130
+ const { spaceId } = await resolveRuntimeSpace({
131
+ root,
132
+ identityKey: currentIdentityKey(),
133
+ explicitSpaceId: requested,
134
+ createSpace: async () => (await client.spaces.create({
135
+ name: resolveLocalSpaceName(root, options.name),
136
+ config: { sandbox: { provider: "local" } },
137
+ })).space.id,
138
+ validateSpace: validateLocalRuntime,
139
+ });
140
+ const spaceClient = client.space(spaceId);
141
+ const store = new RuntimeSessionStore(spaceId, { projectionSource: spaceClient });
142
+ const runtimeId = randomUUID();
143
+ const diagnostics = new RuntimeDiagnostics({ root: store.root, spaceId, runtimeId });
144
+ store.setDiagnostics(diagnostics);
145
+ diagnostics.log("info", "runtime.cli_started", {
146
+ platform: process.platform,
147
+ arch: process.arch,
148
+ node: process.versions.node,
149
+ harnesses,
150
+ proxyConfigured: ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY"].some((key) => Boolean(process.env[key]?.trim())),
151
+ });
71
152
  try {
72
- await serveRuntime({
73
- spaceId, cwd: root, url: url.toString(), capabilities, harnesses: options,
74
- token: requireAccessToken, signal: controller.signal, store: new RuntimeSessionStore(spaceId, undefined, client.space(spaceId)),
75
- onReady: () => {
76
- if (!bridge) {
77
- bridge = spawn(binary, ["--local", "--space", spaceId, "--root", root, "--relay", process.env.COHUB_RELAY_URL?.trim() || relay.toString()], { stdio: ["ignore", "inherit", "inherit"], env: { ...process.env, COHUB_RELAY_TOKEN: token } });
78
- bridgeClosed = new Promise((resolveClosed) => bridge?.once("close", () => resolveClosed()));
79
- bridge.on("error", (cause) => { console.error(cause); controller.abort(); });
80
- bridge.once("exit", () => controller.abort());
81
- }
82
- const webUrl = `${resolveCohubEnvironment() === "prod" ? "https://cohub.live" : "https://dev.cohub.live"}/spaces/${spaceId}`;
83
- if (jsonRequested(options))
84
- outJson({ spaceId, root, harnesses, url: webUrl });
85
- else
86
- console.error(`Runtime connected: ${webUrl}`);
87
- },
153
+ const binary = await ensureSandboxdBinary({
154
+ onStatus: (message) => diagnostics.log("info", "sandboxd.download", { message }, { component: "sandboxd" }),
88
155
  });
156
+ const wsBase = resolveWebsocketUrl({ url: process.env.COHUB_WS_URL });
157
+ const url = new URL(wsBase);
158
+ url.pathname = "/runtime/relay";
159
+ const relay = new URL(wsBase);
160
+ relay.pathname = "/sandbox/relay";
161
+ let bridge = null;
162
+ let bridgeClosed = Promise.resolve();
163
+ let announced = false;
164
+ const token = await requireAccessToken();
165
+ try {
166
+ await serveRuntime({
167
+ spaceId,
168
+ cwd: root,
169
+ url: url.toString(),
170
+ capabilities,
171
+ harnesses: options,
172
+ runtimeId,
173
+ diagnostics,
174
+ token: requireAccessToken,
175
+ signal: controller.signal,
176
+ store,
177
+ onReady: () => {
178
+ if (!bridge) {
179
+ bridge = spawn(binary, ["--local", "--space", spaceId, "--root", root, "--relay", process.env.COHUB_RELAY_URL?.trim() || relay.toString()], {
180
+ stdio: ["ignore", "pipe", "pipe"],
181
+ env: {
182
+ ...process.env,
183
+ COHUB_RELAY_TOKEN: token,
184
+ COHUB_RUNTIME_ID: runtimeId,
185
+ COHUB_LOG_FORMAT: "json",
186
+ },
187
+ });
188
+ captureSandboxOutput(bridge.stdout, "stdout", diagnostics);
189
+ captureSandboxOutput(bridge.stderr, "stderr", diagnostics);
190
+ bridgeClosed = new Promise((resolveClosed) => bridge?.once("close", () => resolveClosed()));
191
+ bridge.on("error", (cause) => {
192
+ diagnostics.log("error", "sandboxd.process_error", { error: serializeDiagnosticError(cause) }, { component: "sandboxd" });
193
+ console.error(cause);
194
+ controller.abort();
195
+ });
196
+ bridge.once("exit", (code, signal) => {
197
+ diagnostics.log(code === 0 ? "info" : "error", "sandboxd.process_exit", { code, signal }, { component: "sandboxd" });
198
+ controller.abort();
199
+ });
200
+ }
201
+ if (announced)
202
+ return;
203
+ announced = true;
204
+ const webUrl = `${resolveCohubEnvironment() === "prod" ? "https://cohub.live" : "https://dev.cohub.live"}/spaces/${spaceId}`;
205
+ if (jsonRequested(options))
206
+ outJson({ spaceId, root, harnesses, runtimeId, diagnosticsPath: diagnostics.logPath, url: webUrl });
207
+ else
208
+ console.error(`Runtime connected: ${webUrl} (runtimeId=${runtimeId}, logs=${diagnostics.logPath})`);
209
+ },
210
+ });
211
+ }
212
+ finally {
213
+ if (bridge) {
214
+ const child = bridge;
215
+ child.kill("SIGTERM");
216
+ const timeout = setTimeout(() => child.kill("SIGKILL"), 3000);
217
+ await bridgeClosed;
218
+ clearTimeout(timeout);
219
+ }
220
+ }
221
+ }
222
+ catch (cause) {
223
+ diagnostics.log("error", "runtime.start_failed", { error: serializeDiagnosticError(cause) });
224
+ throw cause;
89
225
  }
90
226
  finally {
91
- if (bridge) {
92
- const child = bridge;
93
- child.kill("SIGTERM");
94
- const timeout = setTimeout(() => child.kill("SIGKILL"), 3000);
95
- await bridgeClosed;
96
- clearTimeout(timeout);
97
- }
227
+ await diagnostics.close().catch((error) => console.error("Runtime diagnostics close failed:", error));
98
228
  }
99
229
  }
100
230
  catch (cause) {
@@ -108,10 +238,91 @@ export function registerRuntime(program) {
108
238
  });
109
239
  runtime.command("status").description("Runtime status").option("-s, --space <id>", "Target Space").action(async (options) => {
110
240
  const spaceId = options.space?.trim() || await resolveSpace(program);
111
- const { archives } = new RuntimeSessionStore(spaceId);
241
+ const client = createClient();
242
+ const spaceClient = client.space(spaceId);
243
+ const store = new RuntimeSessionStore(spaceId, { projectionSource: spaceClient });
112
244
  const [status, pendingLocalArchives, failedLocalArchives] = await Promise.all([
113
- createClient().space(spaceId).getRuntime(), archives.pendingCount(), archives.failedCaptureCount(),
245
+ spaceClient.getRuntime(),
246
+ store.archives.pendingCount(),
247
+ store.archives.failedCaptureCount(),
114
248
  ]);
115
- outJson({ ...status, pendingLocalArchives, failedLocalArchives });
249
+ outJson({
250
+ ...status,
251
+ diagnosticsPath: runtimeDiagnosticsDirectory(store.root),
252
+ pendingLocalArchives,
253
+ failedLocalArchives,
254
+ });
255
+ });
256
+ runtime.command("logs")
257
+ .description("Read local Runtime diagnostics")
258
+ .option("-s, --space <id>", "Target Space")
259
+ .option("-l, --limit <count>", "Number of events", "100")
260
+ .option("--follow", "Keep watching for new events")
261
+ .option("--json", "Print raw diagnostic events")
262
+ .action(async (options) => {
263
+ const spaceId = options.space?.trim() || await resolveSpace(program);
264
+ const store = new RuntimeSessionStore(spaceId, { projectionSource: createClient().space(spaceId) });
265
+ const limit = Number(options.limit ?? "100");
266
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000)
267
+ return error("Invalid diagnostic limit", "Use an integer between 1 and 10000 / 使用 1 到 10000 之间的整数");
268
+ const asJson = jsonRequested(options);
269
+ const reader = options.follow ? new RuntimeDiagnosticReader(store.root) : null;
270
+ const render = async () => {
271
+ const fresh = options.follow
272
+ ? await reader?.read({ limit }) ?? []
273
+ : await readRuntimeDiagnosticEvents(store.root, { limit });
274
+ if (options.follow && fresh.length === 0)
275
+ return;
276
+ if (asJson && options.follow) {
277
+ for (const event of fresh)
278
+ process.stdout.write(`${JSON.stringify(event)}\n`);
279
+ }
280
+ else if (asJson) {
281
+ outJson(fresh);
282
+ }
283
+ else if (fresh.length === 0) {
284
+ process.stdout.write("No Runtime diagnostics / 未找到 Runtime 诊断记录\n");
285
+ }
286
+ else {
287
+ for (const event of fresh)
288
+ printDiagnostic(event);
289
+ }
290
+ };
291
+ try {
292
+ await render();
293
+ if (!options.follow)
294
+ return;
295
+ await new Promise((resolve) => {
296
+ let timer = null;
297
+ let stopped = false;
298
+ const stop = () => {
299
+ stopped = true;
300
+ if (timer)
301
+ clearTimeout(timer);
302
+ process.removeListener("SIGINT", stop);
303
+ process.removeListener("SIGTERM", stop);
304
+ resolve();
305
+ };
306
+ const poll = async () => {
307
+ if (stopped)
308
+ return;
309
+ try {
310
+ await render();
311
+ }
312
+ catch {
313
+ stop();
314
+ return;
315
+ }
316
+ if (!stopped)
317
+ timer = setTimeout(() => void poll(), 2_000);
318
+ };
319
+ timer = setTimeout(() => void poll(), 2_000);
320
+ process.once("SIGINT", stop);
321
+ process.once("SIGTERM", stop);
322
+ });
323
+ }
324
+ catch (cause) {
325
+ error("Runtime logs failed", cause instanceof Error ? cause.message : String(cause));
326
+ }
116
327
  });
117
328
  }
@@ -2,6 +2,8 @@ export declare const SANDBOXD_VERSION = "v1.82.4";
2
2
  export declare class SandboxdDownloadError extends Error {
3
3
  name: string;
4
4
  }
5
+ export declare function validSandboxdArchiveEntries(entries: string[], version?: string): boolean;
6
+ export declare function validateSandboxdArchive(archivePath: string, version?: string): Promise<string[]>;
5
7
  export type EnsureSandboxdOptions = {
6
8
  version?: string;
7
9
  onStatus?: (message: string) => void;
@@ -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>;