@huynle/hub 0.6.1 → 0.7.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/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
// The node's only way to drive NVIDIA OpenShell (decision lk6gn3u7 §1): the `openshell` CLI, never a shell. Each call
|
|
2
|
+
// is spawn(binary, argv) with the global flags first (--gateway when configured, --workspace hub, --color never),
|
|
3
|
+
// `-o json` where the CLI has it, a time limit (the whole process group is killed) and cancellation. The first call
|
|
4
|
+
// checks `openshell --version` (0.1.x only: the CLI and its JSON change between minor versions). Errors are
|
|
5
|
+
// OpenShellErrors with a code and a short reason taken from the CLI's stderr, with the call's env values, the
|
|
6
|
+
// configured secrets and URL credentials removed: fit for the hub and a launch's reason.
|
|
7
|
+
//
|
|
8
|
+
// Names and labels are checked here, before anything runs, so nothing the hub sends can become a flag.
|
|
9
|
+
// node:child_process (not Bun.spawn) so the node also runs bundled for node.
|
|
10
|
+
import { spawn } from "node:child_process"
|
|
11
|
+
import path from "node:path"
|
|
12
|
+
|
|
13
|
+
/** The OpenShell workspace the hub's sandboxes live in. */
|
|
14
|
+
export const OPENSHELL_WORKSPACE = "hub"
|
|
15
|
+
/** The CLI versions this client speaks (`openshell --version`). */
|
|
16
|
+
export const SUPPORTED_VERSIONS = "0.1.x"
|
|
17
|
+
export const DEFAULT_TIMEOUT_MS = 60_000
|
|
18
|
+
/** Creating waits for the sandbox to come up (~20 s cold on the vm driver). */
|
|
19
|
+
export const CREATE_TIMEOUT_MS = 300_000
|
|
20
|
+
/** `sandbox exec` reads at most this much stdin. */
|
|
21
|
+
export const MAX_EXEC_STDIN = 4 * 1024 * 1024
|
|
22
|
+
/** The process limit beyond an exec's or a policy wait's own --timeout. */
|
|
23
|
+
const GRACE_MS = 30_000
|
|
24
|
+
const MAX_OUTPUT = 16 * 1024 * 1024
|
|
25
|
+
const REASON_MAX = 300
|
|
26
|
+
/** Inherited settings that would change what a call does; the client's flags decide instead. */
|
|
27
|
+
const DROPPED_ENV = new Set(["OPENSHELL_SANDBOX_POLICY", "OPENSHELL_WORKSPACE", "OPENSHELL_GATEWAY", "OPENSHELL_GATEWAY_ENDPOINT", "OPENSHELL_GATEWAY_INSECURE", "OPENSHELL_COLOR"])
|
|
28
|
+
|
|
29
|
+
const NAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/
|
|
30
|
+
const LABEL_KEY = /^([a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?\/)?[A-Za-z0-9]([A-Za-z0-9._-]{0,61}[A-Za-z0-9])?$/
|
|
31
|
+
const LABEL_VALUE = /^([A-Za-z0-9]([A-Za-z0-9._-]{0,61}[A-Za-z0-9])?)?$/
|
|
32
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
33
|
+
const RESOURCE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/
|
|
34
|
+
|
|
35
|
+
type Env = Readonly<Record<string, string | undefined>>
|
|
36
|
+
|
|
37
|
+
export type OpenShellErrorCode =
|
|
38
|
+
| "not_installed"
|
|
39
|
+
| "unsupported_version"
|
|
40
|
+
| "invalid"
|
|
41
|
+
| "not_found"
|
|
42
|
+
| "already_exists"
|
|
43
|
+
| "timeout"
|
|
44
|
+
| "cancelled"
|
|
45
|
+
| "bad_output"
|
|
46
|
+
| "failed"
|
|
47
|
+
|
|
48
|
+
export class OpenShellError extends Error {
|
|
49
|
+
override readonly name = "OpenShellError"
|
|
50
|
+
constructor(
|
|
51
|
+
message: string,
|
|
52
|
+
readonly code: OpenShellErrorCode,
|
|
53
|
+
readonly exitCode?: number,
|
|
54
|
+
) {
|
|
55
|
+
super(message)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type SandboxPhase = "Provisioning" | "Ready" | "Error" | "Deleting" | "Stopping" | "Stopped" | "Starting" | "Completed" | "Unspecified" | "Unknown"
|
|
60
|
+
|
|
61
|
+
export interface SandboxCondition {
|
|
62
|
+
readonly type: string
|
|
63
|
+
readonly status: string
|
|
64
|
+
readonly reason: string
|
|
65
|
+
readonly message: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A sandbox as `sandbox get/list/create -o json` reports it. */
|
|
69
|
+
export interface Sandbox {
|
|
70
|
+
readonly id: string
|
|
71
|
+
readonly name: string
|
|
72
|
+
readonly workspace: string
|
|
73
|
+
readonly phase: SandboxPhase
|
|
74
|
+
readonly labels: Readonly<Record<string, string>>
|
|
75
|
+
/** The main command's exit code once it ended, else null. */
|
|
76
|
+
readonly exitCode: number | null
|
|
77
|
+
readonly createdAt: string
|
|
78
|
+
readonly conditions: readonly SandboxCondition[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface GatewayStatus {
|
|
82
|
+
readonly gateway: string
|
|
83
|
+
readonly server: string
|
|
84
|
+
readonly connected: boolean
|
|
85
|
+
readonly authenticated: boolean
|
|
86
|
+
readonly version: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What `gateway info -o json` tells the node: the gateway's compute drivers (e.g. "vm"), its sandbox backends. */
|
|
90
|
+
export interface GatewayInfo {
|
|
91
|
+
readonly computeDrivers: readonly string[]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface CreateSandboxOptions {
|
|
95
|
+
readonly name: string
|
|
96
|
+
/** A container image reference or rootfs tar (`--from`); or a template; or neither (the gateway's default image). */
|
|
97
|
+
readonly from?: string
|
|
98
|
+
readonly template?: string
|
|
99
|
+
/** Policy YAML file (`--policy`). */
|
|
100
|
+
readonly policyFile?: string
|
|
101
|
+
/** Credential providers the node operator created (`--provider`). */
|
|
102
|
+
readonly providers?: readonly string[]
|
|
103
|
+
readonly labels?: Readonly<Record<string, string>>
|
|
104
|
+
/** Non-secret environment (`--env`); secrets go through providers. */
|
|
105
|
+
readonly env?: Readonly<Record<string, string>>
|
|
106
|
+
/** The main command, started detached. */
|
|
107
|
+
readonly command?: readonly string[]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ExecOptions {
|
|
111
|
+
readonly workdir?: string
|
|
112
|
+
/** OpenShell's own limit (seconds); the process gets this plus a grace period. */
|
|
113
|
+
readonly timeoutSec?: number
|
|
114
|
+
readonly env?: Readonly<Record<string, string>>
|
|
115
|
+
readonly stdin?: string | Uint8Array
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ExecResult {
|
|
119
|
+
/** The remote command's exit code (openshell exits with it; a CLI failure also shows here, usually as 1). */
|
|
120
|
+
readonly exitCode: number
|
|
121
|
+
readonly stdout: string
|
|
122
|
+
readonly stderr: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface PolicySetOptions {
|
|
126
|
+
/** Wait for the sandbox to load the policy. */
|
|
127
|
+
readonly wait?: boolean
|
|
128
|
+
readonly timeoutSec?: number
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Per-call cancellation. */
|
|
132
|
+
export interface CallOptions {
|
|
133
|
+
readonly signal?: AbortSignal
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** What the openshell runtime needs from OpenShell; OpenShellCli implements it over the CLI. */
|
|
137
|
+
export interface OpenShellClient {
|
|
138
|
+
version(options?: CallOptions): Promise<string>
|
|
139
|
+
status(options?: CallOptions): Promise<GatewayStatus>
|
|
140
|
+
/** Optional: clients without it report no backends. */
|
|
141
|
+
gatewayInfo?(options?: CallOptions): Promise<GatewayInfo>
|
|
142
|
+
create(sandbox: CreateSandboxOptions, options?: CallOptions): Promise<Sandbox>
|
|
143
|
+
/** undefined when there is no such sandbox. */
|
|
144
|
+
get(name: string, options?: CallOptions): Promise<Sandbox | undefined>
|
|
145
|
+
list(selector?: Readonly<Record<string, string>>, options?: CallOptions): Promise<Sandbox[]>
|
|
146
|
+
exec(name: string, command: readonly string[], exec?: ExecOptions & CallOptions): Promise<ExecResult>
|
|
147
|
+
upload(name: string, localPath: string, sandboxPath?: string, options?: CallOptions): Promise<void>
|
|
148
|
+
download(name: string, sandboxPath: string, localPath: string, options?: CallOptions): Promise<void>
|
|
149
|
+
stop(name: string, options?: CallOptions): Promise<void>
|
|
150
|
+
/** Asks for deletion (OpenShell deletes asynchronously: poll get until undefined); a missing sandbox is fine. */
|
|
151
|
+
delete(name: string, options?: CallOptions): Promise<void>
|
|
152
|
+
policySet(name: string, policyFile: string, policy?: PolicySetOptions & CallOptions): Promise<void>
|
|
153
|
+
/** Optional, read-only: the gateway's workspace names (`hub node openshell check`). */
|
|
154
|
+
workspaces?(options?: CallOptions): Promise<string[]>
|
|
155
|
+
/** Optional, read-only: the provider names in the client's workspace (`hub node openshell check`). */
|
|
156
|
+
providers?(options?: CallOptions): Promise<string[]>
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface OpenShellCliOptions {
|
|
160
|
+
/** The executable (default "openshell" on PATH). */
|
|
161
|
+
readonly binary?: string
|
|
162
|
+
/** A gateway name (`--gateway`); default: the CLI's active gateway. */
|
|
163
|
+
readonly gateway?: string
|
|
164
|
+
/** Default OPENSHELL_WORKSPACE ("hub"). */
|
|
165
|
+
readonly workspace?: string
|
|
166
|
+
/** Limit per call (default 60 s); create gets CREATE_TIMEOUT_MS unless createTimeoutMs is set. */
|
|
167
|
+
readonly timeoutMs?: number
|
|
168
|
+
readonly createTimeoutMs?: number
|
|
169
|
+
/** `sandbox list --page-size` (default 100). */
|
|
170
|
+
readonly pageSize?: number
|
|
171
|
+
/** The environment to start from (default process.env); OpenShell overrides are dropped from it. */
|
|
172
|
+
readonly env?: Env
|
|
173
|
+
/** Strings never to show in an error (tokens the caller knows about). */
|
|
174
|
+
readonly secrets?: readonly string[]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface RunOptions {
|
|
178
|
+
readonly timeoutMs?: number
|
|
179
|
+
readonly signal?: AbortSignal
|
|
180
|
+
readonly stdin?: string | Uint8Array
|
|
181
|
+
/** Extra strings to remove from errors (the call's env values). */
|
|
182
|
+
readonly hide?: readonly string[]
|
|
183
|
+
/** What the error names ("sandbox create"). */
|
|
184
|
+
readonly what: string
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface RunResult {
|
|
188
|
+
readonly exitCode: number
|
|
189
|
+
readonly stdout: string
|
|
190
|
+
readonly stderr: string
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---- checks ----
|
|
194
|
+
|
|
195
|
+
function invalid(message: string): OpenShellError {
|
|
196
|
+
return new OpenShellError(message, "invalid")
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function checkName(name: string): void {
|
|
200
|
+
if (!NAME.test(name)) throw invalid(`invalid sandbox name ${JSON.stringify(name)}: lowercase letters, digits and '-', at most 63`)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function checkLabels(labels: Readonly<Record<string, string>>): void {
|
|
204
|
+
for (const [k, v] of Object.entries(labels)) {
|
|
205
|
+
if (!LABEL_KEY.test(k)) throw invalid(`invalid label key ${JSON.stringify(k)}`)
|
|
206
|
+
if (!LABEL_VALUE.test(v)) throw invalid(`invalid value for label ${k}: letters, digits, '.', '_' and '-', at most 63`)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function checkResource(what: string, value: string): void {
|
|
211
|
+
if (!RESOURCE.test(value)) throw invalid(`invalid ${what} ${JSON.stringify(value)}`)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A path argument that cannot be read as a flag. */
|
|
215
|
+
function checkPath(what: string, value: string): void {
|
|
216
|
+
if (value === "" || value.startsWith("-") || value.includes("\0")) throw invalid(`invalid ${what} ${JSON.stringify(value)}`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function envArgs(flag: string, env: Readonly<Record<string, string>> | undefined): string[] {
|
|
220
|
+
const args: string[] = []
|
|
221
|
+
for (const [k, v] of Object.entries(env ?? {})) {
|
|
222
|
+
if (!ENV_NAME.test(k)) throw invalid(`invalid environment variable name ${JSON.stringify(k)}`)
|
|
223
|
+
if (v.includes("\0")) throw invalid(`invalid value for ${k}`)
|
|
224
|
+
args.push(flag, `${k}=${v}`)
|
|
225
|
+
}
|
|
226
|
+
return args
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- output ----
|
|
230
|
+
|
|
231
|
+
const ANSI = /\u001b\[[0-9;?]*[A-Za-z]/g
|
|
232
|
+
|
|
233
|
+
function escapeRegExp(s: string): string {
|
|
234
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Removes the given strings, URL credentials and bearer tokens. */
|
|
238
|
+
export function redact(text: string, hide: readonly string[] = []): string {
|
|
239
|
+
let out = text
|
|
240
|
+
for (const s of [...hide].filter((s) => s.length >= 3).sort((a, b) => b.length - a.length)) out = out.replace(new RegExp(escapeRegExp(s), "g"), "***")
|
|
241
|
+
return out.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, "$1***@").replace(/(bearer\s+)[^\s"']+/gi, "$1***")
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The gRPC message from a miette/tonic error, else the stderr's text, on one line. */
|
|
245
|
+
function reasonOf(stderr: string): string {
|
|
246
|
+
const plain = stderr.replace(ANSI, "")
|
|
247
|
+
const grpc = /message: "((?:[^"\\]|\\.)*)"/.exec(plain)
|
|
248
|
+
const text = grpc ? grpc[1]! : plain
|
|
249
|
+
return text
|
|
250
|
+
.split("\n")
|
|
251
|
+
.map((line) => line.replace(/^\s*(Error:)?\s*[×x]?\s*/, "").trim())
|
|
252
|
+
.filter(Boolean)
|
|
253
|
+
.join(" ")
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function cut(text: string): string {
|
|
257
|
+
return text.length > REASON_MAX ? `${text.slice(0, REASON_MAX)}…` : text
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function parseJSON<T>(what: string, stdout: string): T {
|
|
261
|
+
try {
|
|
262
|
+
return JSON.parse(stdout) as T
|
|
263
|
+
} catch {
|
|
264
|
+
throw new OpenShellError(`openshell ${what}: output is not JSON`, "bad_output")
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const str = (v: unknown): string => (typeof v === "string" ? v : "")
|
|
269
|
+
|
|
270
|
+
function toSandbox(raw: unknown, what: string): Sandbox {
|
|
271
|
+
if (!raw || typeof raw !== "object") throw new OpenShellError(`openshell ${what}: no sandbox in the output`, "bad_output")
|
|
272
|
+
const o = raw as Record<string, unknown>
|
|
273
|
+
if (typeof o.name !== "string" || typeof o.phase !== "string") throw new OpenShellError(`openshell ${what}: no sandbox in the output`, "bad_output")
|
|
274
|
+
const labels: Record<string, string> = {}
|
|
275
|
+
if (o.labels && typeof o.labels === "object") for (const [k, v] of Object.entries(o.labels)) if (typeof v === "string") labels[k] = v
|
|
276
|
+
const conditions = Array.isArray(o.conditions)
|
|
277
|
+
? o.conditions.map((c: Record<string, unknown>) => ({ type: str(c?.type), status: str(c?.status), reason: str(c?.reason), message: str(c?.message) }))
|
|
278
|
+
: []
|
|
279
|
+
return {
|
|
280
|
+
id: str(o.id),
|
|
281
|
+
name: o.name,
|
|
282
|
+
workspace: str(o.workspace),
|
|
283
|
+
phase: o.phase as SandboxPhase,
|
|
284
|
+
labels,
|
|
285
|
+
exitCode: typeof o.exit_code === "number" ? o.exit_code : null,
|
|
286
|
+
createdAt: str(o.created_at),
|
|
287
|
+
conditions,
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---- the client ----
|
|
292
|
+
|
|
293
|
+
export class OpenShellCli implements OpenShellClient {
|
|
294
|
+
readonly workspace: string
|
|
295
|
+
private readonly binary: string
|
|
296
|
+
private readonly timeoutMs: number
|
|
297
|
+
private readonly createTimeoutMs: number
|
|
298
|
+
private readonly pageSize: number
|
|
299
|
+
private readonly env: Record<string, string>
|
|
300
|
+
private readonly secrets: readonly string[]
|
|
301
|
+
private readonly globals: readonly string[]
|
|
302
|
+
private checked?: Promise<string>
|
|
303
|
+
|
|
304
|
+
constructor(options: OpenShellCliOptions = {}) {
|
|
305
|
+
this.binary = options.binary ?? "openshell"
|
|
306
|
+
this.workspace = options.workspace ?? OPENSHELL_WORKSPACE
|
|
307
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
308
|
+
this.createTimeoutMs = options.createTimeoutMs ?? Math.max(CREATE_TIMEOUT_MS, this.timeoutMs)
|
|
309
|
+
this.pageSize = options.pageSize ?? 100
|
|
310
|
+
this.secrets = options.secrets ?? []
|
|
311
|
+
if (options.gateway !== undefined) checkResource("gateway name", options.gateway)
|
|
312
|
+
checkResource("workspace", this.workspace)
|
|
313
|
+
this.globals = [...(options.gateway !== undefined ? ["--gateway", options.gateway] : []), "--workspace", this.workspace, "--color", "never"]
|
|
314
|
+
const env: Record<string, string> = {}
|
|
315
|
+
for (const [k, v] of Object.entries(options.env ?? process.env)) if (v !== undefined && !DROPPED_ENV.has(k)) env[k] = v
|
|
316
|
+
env.NO_COLOR = "1"
|
|
317
|
+
this.env = env
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The CLI's version; rejects with unsupported_version outside 0.1.x. Checked once (a failure is retried). */
|
|
321
|
+
version(options: CallOptions = {}): Promise<string> {
|
|
322
|
+
this.checked ??= this.checkVersion(options).catch((error: unknown) => {
|
|
323
|
+
this.checked = undefined
|
|
324
|
+
throw error
|
|
325
|
+
})
|
|
326
|
+
return this.checked
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async status(options: CallOptions = {}): Promise<GatewayStatus> {
|
|
330
|
+
const { stdout } = await this.call(["status", "-o", "json"], { what: "status", ...options })
|
|
331
|
+
const o = parseJSON<Record<string, unknown>>("status", stdout)
|
|
332
|
+
const auth = (o.authentication ?? {}) as Record<string, unknown>
|
|
333
|
+
return { gateway: str(o.gateway), server: str(o.server), connected: o.status === "connected", authenticated: auth.status === "authenticated", version: str(o.version) }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async gatewayInfo(options: CallOptions = {}): Promise<GatewayInfo> {
|
|
337
|
+
const { stdout } = await this.call(["gateway", "info", "-o", "json"], { what: "gateway info", ...options })
|
|
338
|
+
const o = parseJSON<{ compute_drivers?: unknown }>("gateway info", stdout)
|
|
339
|
+
if (!Array.isArray(o.compute_drivers)) throw new OpenShellError("openshell gateway info: no compute drivers in the output", "bad_output")
|
|
340
|
+
const computeDrivers = o.compute_drivers.map((d) => str((d as { name?: unknown } | null)?.name)).filter(Boolean)
|
|
341
|
+
return { computeDrivers }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
workspaces(options: CallOptions = {}): Promise<string[]> {
|
|
345
|
+
return this.names(["workspace", "list"], "workspaces", options)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
providers(options: CallOptions = {}): Promise<string[]> {
|
|
349
|
+
return this.names(["provider", "list"], "providers", options)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async create(sandbox: CreateSandboxOptions, options: CallOptions = {}): Promise<Sandbox> {
|
|
353
|
+
checkName(sandbox.name)
|
|
354
|
+
const args = ["sandbox", "create", "--name", sandbox.name]
|
|
355
|
+
if (sandbox.from !== undefined && sandbox.template !== undefined) throw invalid("give an image (from) or a template, not both")
|
|
356
|
+
if (sandbox.from !== undefined) {
|
|
357
|
+
checkPath("image", sandbox.from)
|
|
358
|
+
args.push("--from", sandbox.from)
|
|
359
|
+
}
|
|
360
|
+
if (sandbox.template !== undefined) {
|
|
361
|
+
checkResource("template", sandbox.template)
|
|
362
|
+
args.push("--template", sandbox.template)
|
|
363
|
+
}
|
|
364
|
+
if (sandbox.policyFile !== undefined) {
|
|
365
|
+
checkPath("policy file", sandbox.policyFile)
|
|
366
|
+
args.push("--policy", sandbox.policyFile)
|
|
367
|
+
}
|
|
368
|
+
for (const p of sandbox.providers ?? []) {
|
|
369
|
+
checkResource("provider", p)
|
|
370
|
+
args.push("--provider", p)
|
|
371
|
+
}
|
|
372
|
+
checkLabels(sandbox.labels ?? {})
|
|
373
|
+
for (const [k, v] of Object.entries(sandbox.labels ?? {})) args.push("--label", `${k}=${v}`)
|
|
374
|
+
args.push(...envArgs("--env", sandbox.env), "--no-auto-providers", "--no-tty", "--detach", "-o", "json")
|
|
375
|
+
if (sandbox.command?.length) args.push("--", ...sandbox.command)
|
|
376
|
+
const { stdout } = await this.call(args, { what: "sandbox create", timeoutMs: this.createTimeoutMs, hide: Object.values(sandbox.env ?? {}), ...options })
|
|
377
|
+
return toSandbox(parseJSON("sandbox create", stdout), "sandbox create")
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async get(name: string, options: CallOptions = {}): Promise<Sandbox | undefined> {
|
|
381
|
+
checkName(name)
|
|
382
|
+
try {
|
|
383
|
+
const { stdout } = await this.call(["sandbox", "get", name, "-o", "json"], { what: "sandbox get", ...options })
|
|
384
|
+
return toSandbox(parseJSON("sandbox get", stdout), "sandbox get")
|
|
385
|
+
} catch (error) {
|
|
386
|
+
if (error instanceof OpenShellError && error.code === "not_found") return undefined
|
|
387
|
+
throw error
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async list(selector: Readonly<Record<string, string>> = {}, options: CallOptions = {}): Promise<Sandbox[]> {
|
|
392
|
+
checkLabels(selector)
|
|
393
|
+
const select = Object.entries(selector).map(([k, v]) => `${k}=${v}`).join(",")
|
|
394
|
+
const sandboxes: Sandbox[] = []
|
|
395
|
+
let token = ""
|
|
396
|
+
for (let page = 0; page < 10_000; page++) {
|
|
397
|
+
const args = ["sandbox", "list", "--page-size", String(this.pageSize)]
|
|
398
|
+
if (token) args.push("--page-token", token)
|
|
399
|
+
if (select) args.push("--selector", select)
|
|
400
|
+
args.push("-o", "json")
|
|
401
|
+
const { stdout } = await this.call(args, { what: "sandbox list", ...options })
|
|
402
|
+
const o = parseJSON<{ sandboxes?: unknown; next_page_token?: unknown }>("sandbox list", stdout)
|
|
403
|
+
if (!Array.isArray(o.sandboxes)) throw new OpenShellError("openshell sandbox list: no sandboxes in the output", "bad_output")
|
|
404
|
+
for (const s of o.sandboxes) sandboxes.push(toSandbox(s, "sandbox list"))
|
|
405
|
+
token = str(o.next_page_token)
|
|
406
|
+
if (!token) return sandboxes
|
|
407
|
+
}
|
|
408
|
+
throw new OpenShellError("openshell sandbox list: too many pages", "bad_output")
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async exec(name: string, command: readonly string[], exec: ExecOptions & CallOptions = {}): Promise<ExecResult> {
|
|
412
|
+
checkName(name)
|
|
413
|
+
if (command.length === 0) throw invalid("exec needs a command")
|
|
414
|
+
const stdinSize = typeof exec.stdin === "string" ? Buffer.byteLength(exec.stdin) : (exec.stdin?.byteLength ?? 0)
|
|
415
|
+
if (stdinSize > MAX_EXEC_STDIN) throw invalid(`exec stdin is over ${MAX_EXEC_STDIN} bytes`)
|
|
416
|
+
const args = ["sandbox", "exec", "--name", name, "--no-tty", "--no-login-shell"]
|
|
417
|
+
if (exec.workdir !== undefined) {
|
|
418
|
+
checkPath("workdir", exec.workdir)
|
|
419
|
+
args.push("--workdir", exec.workdir)
|
|
420
|
+
}
|
|
421
|
+
const timeoutSec = exec.timeoutSec
|
|
422
|
+
if (timeoutSec !== undefined) {
|
|
423
|
+
if (!Number.isInteger(timeoutSec) || timeoutSec < 0) throw invalid("exec timeoutSec must be a whole number of seconds")
|
|
424
|
+
args.push("--timeout", String(timeoutSec))
|
|
425
|
+
}
|
|
426
|
+
args.push(...envArgs("--env", exec.env), "--", ...command)
|
|
427
|
+
const run: RunOptions = {
|
|
428
|
+
what: "sandbox exec",
|
|
429
|
+
timeoutMs: timeoutSec ? timeoutSec * 1000 + GRACE_MS : this.timeoutMs,
|
|
430
|
+
hide: Object.values(exec.env ?? {}),
|
|
431
|
+
...(exec.signal ? { signal: exec.signal } : {}),
|
|
432
|
+
...(exec.stdin !== undefined ? { stdin: exec.stdin } : {}),
|
|
433
|
+
}
|
|
434
|
+
await this.version(exec)
|
|
435
|
+
return this.run(args, run)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async upload(name: string, localPath: string, sandboxPath?: string, options: CallOptions = {}): Promise<void> {
|
|
439
|
+
checkName(name)
|
|
440
|
+
const local = path.resolve(localPath)
|
|
441
|
+
const args = ["sandbox", "upload", "--no-git-ignore", name, local]
|
|
442
|
+
if (sandboxPath !== undefined) {
|
|
443
|
+
checkPath("sandbox path", sandboxPath)
|
|
444
|
+
args.push(sandboxPath)
|
|
445
|
+
}
|
|
446
|
+
await this.call(args, { what: "sandbox upload", ...options })
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async download(name: string, sandboxPath: string, localPath: string, options: CallOptions = {}): Promise<void> {
|
|
450
|
+
checkName(name)
|
|
451
|
+
checkPath("sandbox path", sandboxPath)
|
|
452
|
+
await this.call(["sandbox", "download", name, sandboxPath, path.resolve(localPath)], { what: "sandbox download", ...options })
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async stop(name: string, options: CallOptions = {}): Promise<void> {
|
|
456
|
+
checkName(name)
|
|
457
|
+
await this.call(["sandbox", "stop", name], { what: "sandbox stop", ...options })
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async delete(name: string, options: CallOptions = {}): Promise<void> {
|
|
461
|
+
checkName(name)
|
|
462
|
+
await this.call(["sandbox", "delete", name], { what: "sandbox delete", ...options })
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async policySet(name: string, policyFile: string, policy: PolicySetOptions & CallOptions = {}): Promise<void> {
|
|
466
|
+
checkName(name)
|
|
467
|
+
checkPath("policy file", policyFile)
|
|
468
|
+
const args = ["policy", "set", "--policy", policyFile]
|
|
469
|
+
let timeoutMs = this.timeoutMs
|
|
470
|
+
if (policy.wait) {
|
|
471
|
+
args.push("--wait")
|
|
472
|
+
if (policy.timeoutSec !== undefined) {
|
|
473
|
+
if (!Number.isInteger(policy.timeoutSec) || policy.timeoutSec <= 0) throw invalid("policy timeoutSec must be a positive whole number")
|
|
474
|
+
args.push("--timeout", String(policy.timeoutSec))
|
|
475
|
+
}
|
|
476
|
+
timeoutMs = Math.max(timeoutMs, (policy.timeoutSec ?? 60) * 1000 + GRACE_MS)
|
|
477
|
+
}
|
|
478
|
+
args.push(name)
|
|
479
|
+
await this.call(args, { what: "policy set", timeoutMs, ...(policy.signal ? { signal: policy.signal } : {}) })
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** The `name`s of a paged `<command> -o json` listing whose items sit under `field`. */
|
|
483
|
+
private async names(command: readonly string[], field: string, options: CallOptions): Promise<string[]> {
|
|
484
|
+
const what = command.join(" ")
|
|
485
|
+
const names: string[] = []
|
|
486
|
+
let token = ""
|
|
487
|
+
for (let page = 0; page < 10_000; page++) {
|
|
488
|
+
const args = [...command, "--page-size", String(this.pageSize)]
|
|
489
|
+
if (token) args.push("--page-token", token)
|
|
490
|
+
args.push("-o", "json")
|
|
491
|
+
const { stdout } = await this.call(args, { what, ...options })
|
|
492
|
+
const o = parseJSON<Record<string, unknown> | null>(what, stdout)
|
|
493
|
+
const items = o && typeof o === "object" ? o[field] : undefined
|
|
494
|
+
if (!Array.isArray(items)) throw new OpenShellError(`openshell ${what}: no ${field} in the output`, "bad_output")
|
|
495
|
+
for (const item of items) {
|
|
496
|
+
const name = str((item as { name?: unknown } | null)?.name)
|
|
497
|
+
if (name) names.push(name)
|
|
498
|
+
}
|
|
499
|
+
token = str(o!.next_page_token)
|
|
500
|
+
if (!token) return names
|
|
501
|
+
}
|
|
502
|
+
throw new OpenShellError(`openshell ${what}: too many pages`, "bad_output")
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ---- running ----
|
|
506
|
+
|
|
507
|
+
private async checkVersion(options: CallOptions): Promise<string> {
|
|
508
|
+
const { stdout } = await this.call(["--version"], { what: "--version", ...options }, false)
|
|
509
|
+
const match = /(\d+)\.(\d+)\.(\d+)\S*/.exec(stdout)
|
|
510
|
+
if (!match) throw new OpenShellError("openshell --version: no version in the output", "bad_output")
|
|
511
|
+
if (match[1] !== "0" || match[2] !== "1") throw new OpenShellError(`openshell ${match[0]} is not supported (needs ${SUPPORTED_VERSIONS})`, "unsupported_version")
|
|
512
|
+
return match[0]
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Checks the version, runs, and turns a non-zero exit into an OpenShellError. */
|
|
516
|
+
private async call(args: readonly string[], options: RunOptions, gate = true): Promise<RunResult> {
|
|
517
|
+
if (gate) await this.version(options.signal ? { signal: options.signal } : {})
|
|
518
|
+
const result = await this.run(args, options)
|
|
519
|
+
if (result.exitCode !== 0) {
|
|
520
|
+
const reason = reasonOf(result.stderr)
|
|
521
|
+
const code: OpenShellErrorCode = /\bNotFound\b|\bnot found\b/i.test(reason + result.stderr) ? "not_found" : /\bAlreadyExists\b|already exists/i.test(reason + result.stderr) ? "already_exists" : "failed"
|
|
522
|
+
const message = cut(redact(reason, [...this.secrets, ...(options.hide ?? [])])) || "no error output"
|
|
523
|
+
throw new OpenShellError(`openshell ${options.what} failed (exit ${result.exitCode}): ${message}`, code, result.exitCode)
|
|
524
|
+
}
|
|
525
|
+
return result
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private run(args: readonly string[], options: RunOptions): Promise<RunResult> {
|
|
529
|
+
const what = `openshell ${options.what}`
|
|
530
|
+
return new Promise((resolve, reject) => {
|
|
531
|
+
if (options.signal?.aborted) return reject(new OpenShellError(`${what} cancelled`, "cancelled"))
|
|
532
|
+
let settled = false
|
|
533
|
+
const finish = (error: OpenShellError | undefined, result?: RunResult) => {
|
|
534
|
+
if (settled) return
|
|
535
|
+
settled = true
|
|
536
|
+
clearTimeout(timer)
|
|
537
|
+
options.signal?.removeEventListener("abort", onAbort)
|
|
538
|
+
if (error) reject(error)
|
|
539
|
+
else resolve(result!)
|
|
540
|
+
}
|
|
541
|
+
// Its own process group, so a timeout or cancel also kills what openshell started.
|
|
542
|
+
const child = spawn(this.binary, [...this.globals, ...args], { env: this.env, stdio: ["pipe", "pipe", "pipe"], detached: true, windowsHide: true })
|
|
543
|
+
const kill = () => {
|
|
544
|
+
try {
|
|
545
|
+
if (child.pid) process.kill(-child.pid, "SIGKILL")
|
|
546
|
+
} catch {
|
|
547
|
+
child.kill("SIGKILL")
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const out: Buffer[] = []
|
|
551
|
+
const err: Buffer[] = []
|
|
552
|
+
let size = 0
|
|
553
|
+
const collect = (into: Buffer[]) => (chunk: Buffer) => {
|
|
554
|
+
size += chunk.length
|
|
555
|
+
if (size > MAX_OUTPUT) {
|
|
556
|
+
kill()
|
|
557
|
+
return finish(new OpenShellError(`${what}: output over ${MAX_OUTPUT} bytes`, "bad_output"))
|
|
558
|
+
}
|
|
559
|
+
into.push(chunk)
|
|
560
|
+
}
|
|
561
|
+
child.stdout.on("data", collect(out))
|
|
562
|
+
child.stderr.on("data", collect(err))
|
|
563
|
+
child.stdin.on("error", () => {}) // the CLI may exit before reading stdin
|
|
564
|
+
child.stdin.end(options.stdin === undefined ? undefined : Buffer.from(options.stdin))
|
|
565
|
+
const timeoutMs = options.timeoutMs ?? this.timeoutMs
|
|
566
|
+
const timer = setTimeout(() => {
|
|
567
|
+
kill()
|
|
568
|
+
finish(new OpenShellError(`${what} timed out after ${Math.round(timeoutMs / 1000)} s`, "timeout"))
|
|
569
|
+
}, timeoutMs)
|
|
570
|
+
const onAbort = () => {
|
|
571
|
+
kill()
|
|
572
|
+
finish(new OpenShellError(`${what} cancelled`, "cancelled"))
|
|
573
|
+
}
|
|
574
|
+
options.signal?.addEventListener("abort", onAbort, { once: true })
|
|
575
|
+
child.on("error", (error: NodeJS.ErrnoException) => {
|
|
576
|
+
if (error.code === "ENOENT") finish(new OpenShellError(`openshell is not installed (${this.binary} not found)`, "not_installed"))
|
|
577
|
+
else finish(new OpenShellError(`${what} could not start: ${error.code ?? "error"}`, "failed"))
|
|
578
|
+
})
|
|
579
|
+
child.on("close", (code, signal) => {
|
|
580
|
+
finish(undefined, { exitCode: code ?? (signal ? 128 : 1), stdout: Buffer.concat(out).toString("utf8"), stderr: Buffer.concat(err).toString("utf8") })
|
|
581
|
+
})
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The `openshell` runtime of `hub node` (design §6.4): launches in NVIDIA OpenShell sandboxes. The CLI client
|
|
2
|
+
// (cli.ts), hub profiles (profiles.ts), the policy compiler (policy.ts) and the Runtime itself (runtime.ts).
|
|
3
|
+
export * from "./cli.ts"
|
|
4
|
+
export * from "./profiles.ts"
|
|
5
|
+
export * from "./policy.ts"
|
|
6
|
+
export * from "./runtime.ts"
|