@neta-art/cohub-cli 7.0.1 → 7.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/dist/commands/desktop.d.ts +9 -0
- package/dist/commands/desktop.js +13 -5
- package/dist/commands/run.d.ts +11 -0
- package/dist/commands/run.js +4 -4
- package/dist/commands/runtime.js +254 -43
- package/dist/commands/sandboxd-binary.d.ts +2 -0
- package/dist/commands/sandboxd-binary.js +37 -16
- package/dist/index.js +4 -2
- package/dist/runtime/archive-store.d.ts +2 -0
- package/dist/runtime/archive-store.js +7 -1
- package/dist/runtime/connection.d.ts +3 -0
- package/dist/runtime/connection.js +242 -36
- package/dist/runtime/diagnostics.d.ts +104 -0
- package/dist/runtime/diagnostics.js +382 -0
- package/dist/runtime/harness.d.ts +3 -2
- package/dist/runtime/harness.js +39 -8
- package/dist/runtime/json-rpc.d.ts +2 -0
- package/dist/runtime/json-rpc.js +12 -1
- package/dist/runtime/projection-store.d.ts +34 -0
- package/dist/runtime/projection-store.js +103 -0
- package/dist/runtime/session-store.d.ts +26 -4
- package/dist/runtime/session-store.js +238 -60
- package/dist/runtime/space-binding.d.ts +45 -0
- package/dist/runtime/space-binding.js +305 -0
- package/dist/runtime/turn-projection.d.ts +43 -0
- package/dist/runtime/turn-projection.js +127 -0
- package/dist/space.d.ts +11 -3
- package/dist/space.js +18 -6
- package/package.json +3 -2
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;
|
package/dist/commands/desktop.js
CHANGED
|
@@ -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
|
-
/**
|
|
11
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
package/dist/commands/run.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/commands/run.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
|
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
|
`);
|
package/dist/commands/runtime.js
CHANGED
|
@@ -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
|
|
39
|
-
if (!(await stat(
|
|
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
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
245
|
+
spaceClient.getRuntime(),
|
|
246
|
+
store.archives.pendingCount(),
|
|
247
|
+
store.archives.failedCaptureCount(),
|
|
114
248
|
]);
|
|
115
|
-
outJson({
|
|
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
|
|
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
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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>;
|