@gpzhang2001/sharpkit-sandbox 0.2.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/src/spec.ts ADDED
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Pure spec→argv builders for the docker CLI sandbox (decision D1: argv
3
+ * constructed directly, no dockerode/docker SDK). Every function here is a
4
+ * total pure function over its inputs — no I/O, no clock, no environment —
5
+ * so the docker contract is exhaustively unit-testable (M1 DoD: 100%
6
+ * branch coverage on this module). Behavioral parity references are the
7
+ * strix runtime sources: session_manager.py env/port handling and
8
+ * docker_client.py `_create_container` (caps, extra_hosts, resource/log
9
+ * limits, 127.0.0.1-ephemeral port publishing, network mode).
10
+ * @module @gpzhang2001/sharpkit-sandbox/spec
11
+ */
12
+
13
+ /** One host→container bind mount (`docker create -v source:target[:ro]`). */
14
+ export interface SandboxBindMount {
15
+ readonly source: string
16
+ readonly target: string
17
+ readonly readOnly: boolean
18
+ }
19
+
20
+ /** Opt-in resource limits (strix: STRIX_SANDBOX_{MEM_LIMIT,SHM_SIZE,CPUS,PIDS_LIMIT}). */
21
+ export interface SandboxResourceLimits {
22
+ /** Docker memory string, e.g. `"2g"`. */
23
+ readonly memLimit?: string | undefined
24
+ /** Docker shm size string, e.g. `"512m"`. */
25
+ readonly shmSize?: string | undefined
26
+ /** CPU count as a decimal fraction/multiple, e.g. `1.5`. */
27
+ readonly cpus?: number | undefined
28
+ /** Process-id limit inside the container. */
29
+ readonly pidsLimit?: number | undefined
30
+ }
31
+
32
+ /** Everything `docker create` needs; assembled by the service layer. */
33
+ export interface SandboxCreateSpec {
34
+ /** Image reference, e.g. `ghcr.io/gpzhang2001/sharpkit-sandbox:1.0.0-fork2`. */
35
+ readonly image: string
36
+ /** Keep-alive command after the image (`["tail","-f","/dev/null"]` in strix). */
37
+ readonly command: readonly string[]
38
+ /** Container environment (`-e k=v` per entry; insertion order preserved). */
39
+ readonly env: Readonly<Record<string, string>>
40
+ /** Bind mounts, already sorted shallowest-target-first (strix ordering). */
41
+ readonly bindMounts: readonly SandboxBindMount[]
42
+ /** Container-side Caido port; published to `127.0.0.1` ephemeral unless network is set. */
43
+ readonly caidoPort: number
44
+ /** Attach to an existing docker network and publish no ports (strix sandbox-network mode). */
45
+ readonly network?: string | undefined
46
+ /** Linux capabilities appended after any defaults (`--cap-add`). */
47
+ readonly caps?: readonly string[]
48
+ /** Extra /etc/hosts entries (`--add-host k=v`). */
49
+ readonly extraHosts?: Readonly<Record<string, string>>
50
+ /** Optional resource limits. */
51
+ readonly resourceLimits?: SandboxResourceLimits
52
+ /** Log rotation max-size; disables log opts when one of 0/off/none/unlimited (strix default "50m"). */
53
+ readonly logMaxSize?: string
54
+ /** Log rotation file count (strix default 3). */
55
+ readonly logMaxFile?: number
56
+ /** Container labels (`--label k=v`; strix: sharpkit-run-id / sharpkit-run-type). */
57
+ readonly labels?: Readonly<Record<string, string>>
58
+ }
59
+
60
+ /** Values that disable the json-file log rotation opts (strix `_apply_log_limits`). */
61
+ const LOG_DISABLED_SIZES = new Set(['0', 'off', 'none', 'unlimited'])
62
+
63
+ /**
64
+ * Build the `docker create` argv for a sandbox spec. Env/hosts/labels are
65
+ * emitted in sorted-key order for deterministic tests; mounts keep their
66
+ * (pre-sorted) order.
67
+ * @param spec - the assembled create spec.
68
+ * @returns the full argv, `["docker","create",…flags,image,…command]`.
69
+ */
70
+ export function buildCreateArgv(spec: SandboxCreateSpec): string[] {
71
+ const argv: string[] = ['docker', 'create']
72
+ for (const cap of spec.caps ?? []) argv.push('--cap-add', cap)
73
+ for (const key of Object.keys(spec.extraHosts ?? {}).sort()) argv.push('--add-host', `${key}=${spec.extraHosts?.[key]}`)
74
+ for (const key of Object.keys(spec.env).sort()) argv.push('-e', `${key}=${spec.env[key]}`)
75
+ for (const mount of spec.bindMounts) argv.push('-v', mount.readOnly ? `${mount.source}:${mount.target}:ro` : `${mount.source}:${mount.target}`)
76
+ if (spec.network !== undefined && spec.network !== '') {
77
+ // Sandbox-network mode: no published ports; consumers dial the container IP.
78
+ argv.push('--network', spec.network)
79
+ } else {
80
+ argv.push('-p', `127.0.0.1::${spec.caidoPort}`)
81
+ }
82
+ const limits = spec.resourceLimits
83
+ if (limits?.memLimit !== undefined && limits.memLimit !== '') argv.push('--memory', limits.memLimit)
84
+ if (limits?.shmSize !== undefined && limits.shmSize !== '') argv.push('--shm-size', limits.shmSize)
85
+ if (limits?.cpus !== undefined && limits.cpus > 0) argv.push('--cpus', String(limits.cpus))
86
+ if (limits?.pidsLimit !== undefined && Number.isInteger(limits.pidsLimit) && limits.pidsLimit > 0) argv.push('--pids-limit', String(limits.pidsLimit))
87
+ if (logRotationEnabled(spec.logMaxSize)) {
88
+ argv.push('--log-driver', 'json-file', '--log-opt', `max-size=${spec.logMaxSize}`, '--log-opt', `max-file=${spec.logMaxFile ?? 3}`)
89
+ }
90
+ for (const key of Object.keys(spec.labels ?? {}).sort()) argv.push('--label', `${key}=${spec.labels?.[key]}`)
91
+ argv.push(spec.image, ...spec.command)
92
+ return argv
93
+ }
94
+
95
+ /**
96
+ * Whether json-file rotation opts should be emitted for a max-size value.
97
+ * @param logMaxSize - the configured max-size; absent disables (docker default, unbounded).
98
+ * @returns true when rotation opts must be emitted.
99
+ */
100
+ export function logRotationEnabled(logMaxSize: string | undefined): boolean {
101
+ return logMaxSize !== undefined && logMaxSize !== '' && !LOG_DISABLED_SIZES.has(logMaxSize.toLowerCase())
102
+ }
103
+
104
+ /**
105
+ * Build the container environment (strix session_manager.py:316-329 parity).
106
+ * Proxy vars point at the in-container Caido so all container HTTP traffic is
107
+ * interceptable; NO_PROXY keeps CDP/localhost traffic out of the proxy.
108
+ * @param options - ports/identity inputs; uid/gid only on Linux (ownership remap).
109
+ * @returns the env record in stable insertion order.
110
+ */
111
+ export function buildContainerEnv(options: {
112
+ readonly caidoPort: number
113
+ readonly platform: NodeJS.Platform
114
+ readonly uid?: number | undefined
115
+ readonly gid?: number | undefined
116
+ }): Record<string, string> {
117
+ const proxy = `http://127.0.0.1:${options.caidoPort}`
118
+ const env: Record<string, string> = {
119
+ PYTHONUNBUFFERED: '1',
120
+ HOST_GATEWAY: 'host.docker.internal',
121
+ http_proxy: proxy,
122
+ https_proxy: proxy,
123
+ ALL_PROXY: proxy,
124
+ NO_PROXY: 'localhost,127.0.0.1',
125
+ }
126
+ if (options.platform === 'linux' && options.uid !== undefined && options.uid > 0) {
127
+ env.SHARPKIT_HOST_UID = String(options.uid)
128
+ env.SHARPKIT_HOST_GID = String(options.gid ?? options.uid)
129
+ }
130
+ return env
131
+ }
132
+
133
+ /** Options for a non-interactive `docker exec` argv. */
134
+ export interface SandboxExecArgvOptions {
135
+ readonly containerId: string
136
+ readonly command: string
137
+ /** Working directory inside the container (`-w`), typically under /workspace. */
138
+ readonly cwd?: string | undefined
139
+ }
140
+
141
+ /**
142
+ * Build the non-interactive exec argv: a fresh login shell per call
143
+ * (`bash -lc`), matching the S1 spike and the image's login-shell PATH fixups.
144
+ */
145
+ export function buildExecArgv(options: SandboxExecArgvOptions): string[] {
146
+ const argv = ['docker', 'exec', '-i']
147
+ if (options.cwd !== undefined) argv.push('-w', options.cwd)
148
+ argv.push(options.containerId, 'bash', '-lc', options.command)
149
+ return argv
150
+ }
151
+
152
+ /** Options for a PTY-backed interactive `docker exec` argv. */
153
+ export interface SandboxTtyArgvOptions {
154
+ readonly containerId: string
155
+ readonly command: string
156
+ readonly cwd?: string | undefined
157
+ }
158
+
159
+ /**
160
+ * Build the PTY exec argv (`docker exec -it`); the host-side PTY is provided
161
+ * by `ctx.subprocess.spawnTerminal` and Ctrl-C is delivered as `\x03`
162
+ * (spike finding D1.1).
163
+ */
164
+ export function buildExecTtyArgv(options: SandboxTtyArgvOptions): string[] {
165
+ const argv = ['docker', 'exec', '-it']
166
+ if (options.cwd !== undefined) argv.push('-w', options.cwd)
167
+ argv.push(options.containerId, 'bash', '-lc', options.command)
168
+ return argv
169
+ }
170
+
171
+ /** A resolved host-side endpoint of a published container port. */
172
+ export interface SandboxHostEndpoint {
173
+ readonly host: string
174
+ readonly port: number
175
+ }
176
+
177
+ /**
178
+ * Build the argv resolving a published port's host endpoint (`docker port`).
179
+ */
180
+ export function buildPortArgv(containerId: string, port: number): string[] {
181
+ return ['docker', 'port', containerId, String(port)]
182
+ }
183
+
184
+ /**
185
+ * Parse `docker port` output into endpoints, preferring IPv4 (strix publishes
186
+ * to 127.0.0.1). IPv6 literals arrive bracketed and are returned bracket-free
187
+ * with `bracketedIPv6` only when the raw host contains `:`.
188
+ * @param output - the raw `docker port` stdout (zero or more lines).
189
+ * @returns endpoints, IPv4 entries first; empty when nothing is published.
190
+ */
191
+ export function parsePortOutput(output: string): SandboxHostEndpoint[] {
192
+ const endpoints: SandboxHostEndpoint[] = []
193
+ for (const rawLine of output.split('\n')) {
194
+ const line = rawLine.trim()
195
+ if (line === '') continue
196
+ // Form: "127.0.0.1:49153" or "[::1]:49153".
197
+ const lastColon = line.lastIndexOf(':')
198
+ if (lastColon === -1) continue
199
+ const port = Number.parseInt(line.slice(lastColon + 1), 10)
200
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) continue
201
+ let host = line.slice(0, lastColon)
202
+ if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1)
203
+ if (host === '') continue
204
+ endpoints.push({ host, port })
205
+ }
206
+ const preferred = endpoints.filter(endpoint => !endpoint.host.includes(':'))
207
+ const ipv6 = endpoints.filter(endpoint => endpoint.host.includes(':'))
208
+ return [...preferred, ...ipv6]
209
+ }
210
+
211
+ /**
212
+ * Build the argv resolving the container's IP on a sandbox network (strix
213
+ * `StrixDockerSandboxSession._resolve_exposed_port`, network mode).
214
+ */
215
+ export function buildNetworkIpArgv(containerId: string, network: string): string[] {
216
+ return ['docker', 'inspect', '--format', `{{.NetworkSettings.Networks.${network}.IPAddress}}`, containerId]
217
+ }
218
+
219
+ /** Container-side path reference for `docker cp` (`<id>:<path>`). */
220
+ export function containerRef(containerId: string, containerPath: string): string {
221
+ return `${containerId}:${containerPath}`
222
+ }
223
+
224
+ /** Build the `docker cp` argv copying a host file into the container. */
225
+ export function buildPutFileArgv(hostPath: string, containerId: string, containerPath: string): string[] {
226
+ return ['docker', 'cp', hostPath, containerRef(containerId, containerPath)]
227
+ }
228
+
229
+ /** Build the `docker cp` argv copying a container file out to a host path. */
230
+ export function buildGetFileArgv(containerId: string, containerPath: string, hostPath: string): string[] {
231
+ return ['docker', 'cp', containerRef(containerId, containerPath), hostPath]
232
+ }
233
+
234
+ /** Build the graceful stop argv (`docker stop -t <seconds>`). */
235
+ export function buildStopArgv(containerId: string, graceMs: number): string[] {
236
+ const seconds = Math.max(0, Math.round(graceMs / 1000))
237
+ return ['docker', 'stop', '-t', String(seconds), containerId]
238
+ }
239
+
240
+ /** Build the plain remove argv; the caller escalates to force-remove on failure. */
241
+ export function buildRmArgv(containerId: string): string[] {
242
+ return ['docker', 'rm', containerId]
243
+ }
244
+
245
+ /** Build the force-remove argv (fallback path and failure cleanup). */
246
+ export function buildRmForceArgv(containerId: string): string[] {
247
+ return ['docker', 'rm', '-f', containerId]
248
+ }
249
+
250
+ /** Build the argv checking whether an image is present locally. */
251
+ export function buildImageInspectArgv(image: string): string[] {
252
+ return ['docker', 'image', 'inspect', image]
253
+ }
254
+
255
+ /** Build the pull argv (strix pulls only when the image is missing). */
256
+ export function buildPullArgv(image: string): string[] {
257
+ return ['docker', 'pull', image]
258
+ }
259
+
260
+ /** Build the start argv. */
261
+ export function buildStartArgv(containerId: string): string[] {
262
+ return ['docker', 'start', containerId]
263
+ }