From 625b6210a41fbfe66882f1d10e687c2c0a703545 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Jun 2026 19:30:23 +0000 Subject: [PATCH 1/4] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/start/issues/132 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..63fba3e --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-06-09T19:30:23.589Z for PR creation at branch issue-132-81888ef731d7 for issue https://github.com/link-foundation/start/issues/132 \ No newline at end of file From ce3fb92c8be388702a365dba8b1fae7e14866f9a Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Jun 2026 19:48:06 +0000 Subject: [PATCH 2/4] feat: add Docker isolation image and runtime controls Add --volume/-v, --mount, --env/-e, and --privileged options to the Docker isolation backend in both the JavaScript and Rust implementations. These flags are threaded into the underlying 'docker run' invocation and recorded in --status/--list metadata, so callers can mount tool credentials, inject environment variables, and run Docker-in-Docker images (e.g. konard/hive-mind-dind:latest) without wrapping 'docker run' themselves. Options are validated to be docker-only and forwarded across nested isolation levels. Closes #132 --- README.md | 13 ++ js/.changeset/docker-runtime-controls.md | 5 + js/src/bin/cli.js | 24 +++ js/src/lib/args-parser.js | 99 ++++++++++++ js/src/lib/command-builder.js | 17 +++ js/src/lib/isolation.js | 31 +++- js/src/lib/usage.js | 6 + js/test/docker-runtime-options.js | 185 +++++++++++++++++++++++ rust/changelog.d/133.md | 5 + rust/src/bin/main.rs | 46 ++++++ rust/src/lib/args_parser.rs | 92 +++++++++++ rust/src/lib/args_parser_cases.rs | 149 ++++++++++++++++++ rust/src/lib/isolation.rs | 39 +++++ rust/src/lib/isolation_cases.rs | 33 ++++ rust/src/lib/usage.rs | 6 + 15 files changed, 749 insertions(+), 1 deletion(-) create mode 100644 js/.changeset/docker-runtime-controls.md create mode 100644 js/test/docker-runtime-options.js create mode 100644 rust/changelog.d/133.md diff --git a/README.md b/README.md index c89f3f9..ad5731c 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,15 @@ $ --isolated docker --image ghcr.io/link-foundation/box-js:latest -- bun --versi # Run a multi-runtime AI coding experiment in the full box image $ --isolated docker --image ghcr.io/link-foundation/box:latest -- bash -lc 'node --version && python --version && rustc --version' +# Mount tool credentials and pass environment variables into the container +$ -i docker --image konard/hive-mind-dind:latest \ + -v ~/.config/gh:/root/.config/gh \ + -v ~/.claude:/root/.claude \ + -e GH_TOKEN=$GH_TOKEN -- gh repo list + +# Run a Docker-in-Docker image in privileged mode +$ -i docker --image konard/hive-mind-dind:latest --privileged -- solve + # Run on remote server via SSH $ --isolated ssh --endpoint user@remote.server -- npm test @@ -298,6 +307,10 @@ This is useful for: | `--detached, -d` | Run in detached/background mode | | `--session, -s` | Custom session/container name | | `--image` | Docker image (optional; defaults to OS-matched image) | +| `--volume, -v` | Docker bind mount/volume `host:container[:mode]` (repeatable, docker only) | +| `--mount` | Docker `--mount` spec (repeatable, docker only) | +| `--env, -e` | Environment variable `KEY=VALUE` for the container (repeatable, docker only) | +| `--privileged` | Run docker container in privileged mode (docker only) | | `--endpoint` | SSH endpoint (required for ssh, e.g., user@host) | | `--isolated-user, -u [name]` | Create isolated user with same permissions (screen/tmux) | | `--keep-user` | Keep isolated user after command completes (don't delete) | diff --git a/js/.changeset/docker-runtime-controls.md b/js/.changeset/docker-runtime-controls.md new file mode 100644 index 0000000..1c1ba51 --- /dev/null +++ b/js/.changeset/docker-runtime-controls.md @@ -0,0 +1,5 @@ +--- +'start-command': minor +--- + +Add Docker isolation runtime controls: `--volume`/`-v`, `--mount`, `--env`/`-e`, and `--privileged`. These are threaded into the underlying `docker run` invocation and recorded in `--status`/`--list` metadata, allowing callers to mount tool credentials, pass environment variables, and run Docker-in-Docker images without wrapping `docker run` themselves. diff --git a/js/src/bin/cli.js b/js/src/bin/cli.js index 1a37f57..7bf1f19 100644 --- a/js/src/bin/cli.js +++ b/js/src/bin/cli.js @@ -470,6 +470,18 @@ async function runWithIsolation( if (effectiveImage) { extraLines.push(`[Isolation] Image: ${effectiveImage}`); } + if (options.volumes && options.volumes.length > 0) { + extraLines.push(`[Isolation] Volumes: ${options.volumes.join(', ')}`); + } + if (options.mounts && options.mounts.length > 0) { + extraLines.push(`[Isolation] Mounts: ${options.mounts.join(', ')}`); + } + if (options.env && options.env.length > 0) { + extraLines.push(`[Isolation] Env: ${options.env.join(', ')}`); + } + if (options.privileged) { + extraLines.push(`[Isolation] Privileged: true`); + } if (options.endpoint) { extraLines.push(`[Isolation] Endpoint: ${options.endpoint}`); } @@ -496,6 +508,14 @@ async function runWithIsolation( isolationMode: mode, sessionName, image: effectiveImage, + volumes: + options.volumes && options.volumes.length > 0 + ? options.volumes + : null, + mounts: + options.mounts && options.mounts.length > 0 ? options.mounts : null, + env: options.env && options.env.length > 0 ? options.env : null, + privileged: options.privileged || null, endpoint: options.endpoint, user: options.user, keepAlive: options.keepAlive, @@ -570,6 +590,10 @@ async function runWithIsolation( user: createdUser, keepAlive: options.keepAlive, autoRemoveDockerContainer: options.autoRemoveDockerContainer, + volumes: options.volumes, + mounts: options.mounts, + env: options.env, + privileged: options.privileged, shell: options.shell, logPath: logFilePath, }); diff --git a/js/src/lib/args-parser.js b/js/src/lib/args-parser.js index 43c18a9..d87740f 100644 --- a/js/src/lib/args-parser.js +++ b/js/src/lib/args-parser.js @@ -11,6 +11,10 @@ * --detached, -d Run in detached mode (background) * --session, -s Session name for isolation * --image Docker image (optional, defaults to OS-matched image) + * --volume, -v Docker bind mount/volume (repeatable, docker only) + * --mount Docker --mount spec (repeatable, docker only) + * --env, -e Environment variable for docker container (repeatable, docker only) + * --privileged Run docker container in privileged mode (docker only) * --endpoint SSH endpoint (required for ssh isolation, e.g., user@host) * --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified) * --keep-user Keep isolated user after command completes (don't delete) @@ -167,6 +171,10 @@ function parseArgs(args) { sessionId: null, // Session ID (UUID) for tracking - auto-generated if not provided image: null, // Docker image (current level) imageStack: null, // Docker images for each level (with nulls for non-docker levels) + volumes: [], // Docker bind mounts/volumes (-v/--volume), applied to docker levels + mounts: [], // Docker --mount specs, applied to docker levels + env: [], // Docker environment variables (-e/--env, KEY=VALUE), applied to docker levels + privileged: false, // Run docker container in privileged mode endpoint: null, // SSH endpoint (current level, e.g., user@host) endpointStack: null, // SSH endpoints for each level (with nulls for non-ssh levels) user: false, // Create isolated user @@ -326,6 +334,62 @@ function parseOption(args, index, options) { return 1; } + // --volume or -v (for docker) - repeatable bind mount / volume + if (arg === '--volume' || arg === '-v') { + if (index + 1 < args.length && !args[index + 1].startsWith('-')) { + options.volumes.push(args[index + 1]); + return 2; + } else { + throw new Error( + `Option ${arg} requires a volume argument (host:container[:mode])` + ); + } + } + + // --volume= or -v= + if (arg.startsWith('--volume=') || arg.startsWith('-v=')) { + options.volumes.push(arg.slice(arg.indexOf('=') + 1)); + return 1; + } + + // --mount (for docker) - repeatable mount spec + if (arg === '--mount') { + if (index + 1 < args.length && !args[index + 1].startsWith('-')) { + options.mounts.push(args[index + 1]); + return 2; + } else { + throw new Error(`Option ${arg} requires a mount spec argument`); + } + } + + // --mount= + if (arg.startsWith('--mount=')) { + options.mounts.push(arg.slice('--mount='.length)); + return 1; + } + + // --env or -e (for docker) - repeatable environment variable + if (arg === '--env' || arg === '-e') { + if (index + 1 < args.length && !args[index + 1].startsWith('-')) { + options.env.push(args[index + 1]); + return 2; + } else { + throw new Error(`Option ${arg} requires a KEY=VALUE argument`); + } + } + + // --env= or -e= + if (arg.startsWith('--env=') || arg.startsWith('-e=')) { + options.env.push(arg.slice(arg.indexOf('=') + 1)); + return 1; + } + + // --privileged (for docker) + if (arg === '--privileged') { + options.privileged = true; + return 1; + } + // --endpoint (for ssh) - supports sequence for stacked isolation if (arg === '--endpoint') { if (index + 1 < args.length && !args[index + 1].startsWith('-')) { @@ -556,6 +620,35 @@ function parseOption(args, index, options) { return 0; } +/** + * Throw if docker runtime options (--volume, --mount, --env, --privileged) + * are present but the isolation configuration does not include docker. + * @param {object} options - Parsed options + * @throws {Error} If a docker-only option is set without docker isolation + */ +function validateDockerRuntimeOptionsRequireDocker(options) { + if (options.volumes && options.volumes.length > 0) { + throw new Error( + '--volume option is only valid when isolation stack includes docker' + ); + } + if (options.mounts && options.mounts.length > 0) { + throw new Error( + '--mount option is only valid when isolation stack includes docker' + ); + } + if (options.env && options.env.length > 0) { + throw new Error( + '--env option is only valid when isolation stack includes docker' + ); + } + if (options.privileged) { + throw new Error( + '--privileged option is only valid when isolation stack includes docker' + ); + } +} + /** * Validate parsed options * @param {object} options - Parsed options @@ -678,6 +771,11 @@ function validateOptions(options) { ); } + // Docker runtime options (--volume, --mount, --env, --privileged) require docker + if (!stack.includes('docker')) { + validateDockerRuntimeOptionsRequireDocker(options); + } + // User isolation is not supported with Docker as first level if (options.user && currentBackend === 'docker') { throw new Error( @@ -702,6 +800,7 @@ function validateOptions(options) { '--endpoint option is only valid when isolation stack includes ssh' ); } + validateDockerRuntimeOptionsRequireDocker(options); } // Session name is only valid with isolation diff --git a/js/src/lib/command-builder.js b/js/src/lib/command-builder.js index ceb7237..66eb20c 100644 --- a/js/src/lib/command-builder.js +++ b/js/src/lib/command-builder.js @@ -54,6 +54,23 @@ function buildNextLevelCommand(options, command) { } } + // Docker runtime options are flat (not per-level); forward them only when a + // remaining level still uses docker so the nested $ invocation can apply them. + if (remainingStack.includes('docker')) { + for (const volume of options.volumes || []) { + parts.push(`--volume "${volume}"`); + } + for (const mount of options.mounts || []) { + parts.push(`--mount "${mount}"`); + } + for (const envVar of options.env || []) { + parts.push(`--env "${envVar}"`); + } + if (options.privileged) { + parts.push('--privileged'); + } + } + // Pass through global flags if (options.detached) { parts.push('--detached'); diff --git a/js/src/lib/isolation.js b/js/src/lib/isolation.js index 6c2190f..f33aede 100644 --- a/js/src/lib/isolation.js +++ b/js/src/lib/isolation.js @@ -486,10 +486,35 @@ const { canRunLinuxDockerImages, } = require('./docker-utils'); +/** + * Build the docker run runtime argument list contributed by configurable + * container options: privileged mode, environment variables, volumes/bind + * mounts, and --mount specs. Returned in a stable order so they can be spliced + * into the `docker run` argv before the image name. + * @param {object} options - Options (privileged, env, volumes, mounts) + * @returns {string[]} Docker CLI arguments + */ +function buildDockerRuntimeArgs(options = {}) { + const args = []; + if (options.privileged) { + args.push('--privileged'); + } + for (const envVar of options.env || []) { + args.push('-e', envVar); + } + for (const volume of options.volumes || []) { + args.push('-v', volume); + } + for (const mount of options.mounts || []) { + args.push('--mount', mount); + } + return args; +} + /** * Run command in Docker container * @param {string} command - Command to execute - * @param {object} options - Options (image, session/name, detached, user, keepAlive, autoRemoveDockerContainer) + * @param {object} options - Options (image, session/name, detached, user, keepAlive, autoRemoveDockerContainer, volumes, mounts, env, privileged) * @returns {Promise<{success: boolean, containerName: string, message: string}>} */ function runInDocker(command, options = {}) { @@ -558,6 +583,8 @@ function runInDocker(command, options = {}) { dockerArgs.push('--user', options.user); } + dockerArgs.push(...buildDockerRuntimeArgs(options)); + const effectiveCommand = options.keepAlive ? `${command}; exec ${shellToUse}` : command; @@ -640,6 +667,7 @@ function runInDocker(command, options = {}) { if (options.user) { dockerArgs.push('--user', options.user); } + dockerArgs.push(...buildDockerRuntimeArgs(options)); if (DEBUG) { console.log(`[DEBUG] shell: ${shellToUse}`); } @@ -784,6 +812,7 @@ module.exports = { runInScreen, runInTmux, runInDocker, + buildDockerRuntimeArgs, runInSsh, runIsolated, runAsIsolatedUser, diff --git a/js/src/lib/usage.js b/js/src/lib/usage.js index 579ca46..8d82060 100644 --- a/js/src/lib/usage.js +++ b/js/src/lib/usage.js @@ -10,6 +10,10 @@ Options: --session-id Session UUID for tracking (auto-generated if not provided) --session-name Alias for --session-id --image Docker image (optional, defaults to OS-matched image) + --volume, -v Docker bind mount/volume host:container[:mode] (repeatable, docker only) + --mount Docker --mount spec (repeatable, docker only) + --env, -e Environment variable for docker container (repeatable, docker only) + --privileged Run docker container in privileged mode (docker only) --endpoint SSH endpoint (required for ssh isolation, e.g., user@host) --isolated-user, -u [name] Create isolated user with same permissions --keep-user Keep isolated user after command completes @@ -33,6 +37,8 @@ Examples: $ -i screen -d bun start $ --isolated docker -- echo "hi" # uses OS-matched default image $ --isolated docker --image ghcr.io/link-foundation/box-js:latest -- bun --version + $ -i docker -v ~/.config/gh:/root/.config/gh -e TOKEN=abc -- gh repo list + $ -i docker --image konard/hive-mind-dind:latest --privileged -- solve ... $ --isolated ssh --endpoint user@remote.server -- ls -la $ --isolated-user -- npm test # Create isolated user $ -u myuser -- npm start # Custom username diff --git a/js/test/docker-runtime-options.js b/js/test/docker-runtime-options.js new file mode 100644 index 0000000..b40a80f --- /dev/null +++ b/js/test/docker-runtime-options.js @@ -0,0 +1,185 @@ +#!/usr/bin/env bun +/** + * Tests for Docker runtime options: --volume/-v, --mount, --env/-e, --privileged + * + * Reproduces issue #132: callers need to configure bind mounts, volumes, + * environment variables, and privileged mode for the docker isolation backend + * so they can mount tool credentials and run Docker-in-Docker images without + * wrapping `docker run` themselves. + */ + +const { describe, it } = require('node:test'); +const assert = require('assert'); +const { parseArgs } = require('../src/lib/args-parser'); +const { buildDockerRuntimeArgs } = require('../src/lib/isolation'); + +describe('Docker runtime options parsing', () => { + it('should parse repeatable --volume and -v', () => { + const result = parseArgs([ + '--isolated', + 'docker', + '--volume', + '/host/a:/container/a', + '-v', + '/host/b:/container/b:ro', + '--', + 'ls', + ]); + assert.deepStrictEqual(result.wrapperOptions.volumes, [ + '/host/a:/container/a', + '/host/b:/container/b:ro', + ]); + }); + + it('should parse --volume=value and -v=value', () => { + const result = parseArgs([ + '-i', + 'docker', + '--volume=/host/a:/c/a', + '-v=/host/b:/c/b', + '--', + 'ls', + ]); + assert.deepStrictEqual(result.wrapperOptions.volumes, [ + '/host/a:/c/a', + '/host/b:/c/b', + ]); + }); + + it('should parse repeatable --mount', () => { + const result = parseArgs([ + '-i', + 'docker', + '--mount', + 'type=bind,src=/h,dst=/c', + '--mount=type=volume,src=vol,dst=/data', + '--', + 'ls', + ]); + assert.deepStrictEqual(result.wrapperOptions.mounts, [ + 'type=bind,src=/h,dst=/c', + 'type=volume,src=vol,dst=/data', + ]); + }); + + it('should parse repeatable --env and -e', () => { + const result = parseArgs([ + '-i', + 'docker', + '--env', + 'FOO=bar', + '-e', + 'GH_TOKEN=secret', + '--env=BAZ=qux', + '--', + 'env', + ]); + assert.deepStrictEqual(result.wrapperOptions.env, [ + 'FOO=bar', + 'GH_TOKEN=secret', + 'BAZ=qux', + ]); + }); + + it('should parse --privileged', () => { + const result = parseArgs(['-i', 'docker', '--privileged', '--', 'ls']); + assert.strictEqual(result.wrapperOptions.privileged, true); + }); + + it('should default runtime options to empty/false', () => { + const result = parseArgs(['-i', 'docker', '--', 'ls']); + assert.deepStrictEqual(result.wrapperOptions.volumes, []); + assert.deepStrictEqual(result.wrapperOptions.mounts, []); + assert.deepStrictEqual(result.wrapperOptions.env, []); + assert.strictEqual(result.wrapperOptions.privileged, false); + }); + + it('should throw when --volume requires an argument', () => { + assert.throws(() => { + parseArgs(['-i', 'docker', '--volume', '--', 'ls']); + }, /requires a volume argument/); + }); + + it('should throw when --env requires an argument', () => { + assert.throws(() => { + parseArgs(['-i', 'docker', '--env', '--', 'ls']); + }, /requires a KEY=VALUE argument/); + }); +}); + +describe('Docker runtime options validation', () => { + it('should reject --volume with non-docker backend', () => { + assert.throws(() => { + parseArgs(['-i', 'tmux', '-v', '/a:/b', '--', 'ls']); + }, /--volume option is only valid when isolation stack includes docker/); + }); + + it('should reject --mount without isolation', () => { + assert.throws(() => { + parseArgs(['--mount', 'type=bind,src=/a,dst=/b', '--', 'ls']); + }, /--mount option is only valid when isolation stack includes docker/); + }); + + it('should reject --env with non-docker backend', () => { + assert.throws(() => { + parseArgs(['-i', 'ssh', '--endpoint', 'u@h', '-e', 'A=1', '--', 'ls']); + }, /--env option is only valid when isolation stack includes docker/); + }); + + it('should reject --privileged without docker', () => { + assert.throws(() => { + parseArgs(['--privileged', '--', 'ls']); + }, /--privileged option is only valid when isolation stack includes docker/); + }); + + it('should accept runtime options when stack includes docker', () => { + const result = parseArgs([ + '-i', + 'screen docker', + '-v', + '/a:/b', + '-e', + 'A=1', + '--privileged', + '--', + 'ls', + ]); + assert.deepStrictEqual(result.wrapperOptions.volumes, ['/a:/b']); + assert.deepStrictEqual(result.wrapperOptions.env, ['A=1']); + assert.strictEqual(result.wrapperOptions.privileged, true); + }); +}); + +describe('buildDockerRuntimeArgs', () => { + it('should build empty args by default', () => { + assert.deepStrictEqual(buildDockerRuntimeArgs({}), []); + }); + + it('should add --privileged first', () => { + assert.deepStrictEqual(buildDockerRuntimeArgs({ privileged: true }), [ + '--privileged', + ]); + }); + + it('should expand env, volumes, and mounts in order', () => { + const args = buildDockerRuntimeArgs({ + privileged: true, + env: ['FOO=bar', 'GH_TOKEN=secret'], + volumes: ['/h/a:/c/a', '/h/b:/c/b:ro'], + mounts: ['type=bind,src=/h,dst=/c'], + }); + assert.deepStrictEqual(args, [ + '--privileged', + '-e', + 'FOO=bar', + '-e', + 'GH_TOKEN=secret', + '-v', + '/h/a:/c/a', + '-v', + '/h/b:/c/b:ro', + '--mount', + 'type=bind,src=/h,dst=/c', + ]); + }); +}); diff --git a/rust/changelog.d/133.md b/rust/changelog.d/133.md new file mode 100644 index 0000000..52aa5be --- /dev/null +++ b/rust/changelog.d/133.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +Add Docker isolation runtime controls: `--volume`/`-v`, `--mount`, `--env`/`-e`, and `--privileged`. These are threaded into the underlying `docker run` invocation and recorded in `--status`/`--list` metadata, allowing callers to mount tool credentials, pass environment variables, and run Docker-in-Docker images without wrapping `docker run` themselves. diff --git a/rust/src/bin/main.rs b/rust/src/bin/main.rs index b59a261..06e3a87 100644 --- a/rust/src/bin/main.rs +++ b/rust/src/bin/main.rs @@ -516,6 +516,27 @@ fn run_with_isolation( if let Some(ref image) = effective_image { extra_lines.push(format!("[Isolation] Image: {}", image)); } + if !wrapper_options.volumes.is_empty() { + extra_lines.push(format!( + "[Isolation] Volumes: {}", + wrapper_options.volumes.join(", ") + )); + } + if !wrapper_options.mounts.is_empty() { + extra_lines.push(format!( + "[Isolation] Mounts: {}", + wrapper_options.mounts.join(", ") + )); + } + if !wrapper_options.env.is_empty() { + extra_lines.push(format!( + "[Isolation] Env: {}", + wrapper_options.env.join(", ") + )); + } + if wrapper_options.privileged { + extra_lines.push("[Isolation] Privileged: true".to_string()); + } if let Some(ref endpoint) = wrapper_options.endpoint { extra_lines.push(format!("[Isolation] Endpoint: {}", endpoint)); } @@ -572,6 +593,27 @@ fn run_with_isolation( if let Some(ref v) = effective_image { opts_map.insert("image".into(), str_val(v)); } + if !wrapper_options.volumes.is_empty() { + opts_map.insert( + "volumes".into(), + serde_json::Value::Array(wrapper_options.volumes.iter().map(|s| str_val(s)).collect()), + ); + } + if !wrapper_options.mounts.is_empty() { + opts_map.insert( + "mounts".into(), + serde_json::Value::Array(wrapper_options.mounts.iter().map(|s| str_val(s)).collect()), + ); + } + if !wrapper_options.env.is_empty() { + opts_map.insert( + "env".into(), + serde_json::Value::Array(wrapper_options.env.iter().map(|s| str_val(s)).collect()), + ); + } + if wrapper_options.privileged { + opts_map.insert("privileged".into(), serde_json::Value::Bool(true)); + } if let Some(ref v) = wrapper_options.endpoint { opts_map.insert("endpoint".into(), str_val(v)); } @@ -622,6 +664,10 @@ fn run_with_isolation( let options = IsolationOptions { session: Some(session_name.clone()), image: effective_image.clone(), + volumes: wrapper_options.volumes.clone(), + mounts: wrapper_options.mounts.clone(), + env: wrapper_options.env.clone(), + privileged: wrapper_options.privileged, endpoint: wrapper_options.endpoint.clone(), detached: mode == "detached", user: created_user.clone(), diff --git a/rust/src/lib/args_parser.rs b/rust/src/lib/args_parser.rs index 974690a..8068e73 100644 --- a/rust/src/lib/args_parser.rs +++ b/rust/src/lib/args_parser.rs @@ -10,6 +10,10 @@ //! --detached, -d Run in detached mode (background) //! --session, -s Session name for isolation //! --image Docker image (optional, defaults to OS-matched image) +//! --volume, -v Docker bind mount/volume (repeatable, docker only) +//! --mount Docker --mount spec (repeatable, docker only) +//! --env, -e Environment variable for docker container (repeatable, docker only) +//! --privileged Run docker container in privileged mode (docker only) //! --endpoint SSH endpoint (required for ssh isolation, e.g., user@host) //! --isolated-user, -u [username] Create isolated user with same permissions //! --keep-user Keep isolated user after command completes @@ -65,6 +69,14 @@ pub struct WrapperOptions { pub session_id: Option, /// Docker image pub image: Option, + /// Docker bind mounts/volumes (-v/--volume), applied to docker isolation + pub volumes: Vec, + /// Docker --mount specs, applied to docker isolation + pub mounts: Vec, + /// Docker environment variables (-e/--env, KEY=VALUE), applied to docker isolation + pub env: Vec, + /// Run docker container in privileged mode + pub privileged: bool, /// SSH endpoint (e.g., user@host) pub endpoint: Option, /// Create isolated user @@ -108,6 +120,10 @@ impl Default for WrapperOptions { session: None, session_id: None, image: None, + volumes: Vec::new(), + mounts: Vec::new(), + env: Vec::new(), + privileged: false, endpoint: None, user: false, user_name: None, @@ -278,6 +294,67 @@ fn parse_option( return Ok(1); } + // --volume or -v (for docker) - repeatable bind mount / volume + if arg == "--volume" || arg == "-v" { + if index + 1 < args.len() && !args[index + 1].starts_with('-') { + options.volumes.push(args[index + 1].clone()); + return Ok(2); + } else { + return Err(format!( + "Option {} requires a volume argument (host:container[:mode])", + arg + )); + } + } + + // --volume= or -v= + if arg.starts_with("--volume=") || arg.starts_with("-v=") { + options + .volumes + .push(arg[arg.find('=').unwrap() + 1..].to_string()); + return Ok(1); + } + + // --mount (for docker) - repeatable mount spec + if arg == "--mount" { + if index + 1 < args.len() && !args[index + 1].starts_with('-') { + options.mounts.push(args[index + 1].clone()); + return Ok(2); + } else { + return Err(format!("Option {} requires a mount spec argument", arg)); + } + } + + // --mount= + if let Some(value) = arg.strip_prefix("--mount=") { + options.mounts.push(value.to_string()); + return Ok(1); + } + + // --env or -e (for docker) - repeatable environment variable + if arg == "--env" || arg == "-e" { + if index + 1 < args.len() && !args[index + 1].starts_with('-') { + options.env.push(args[index + 1].clone()); + return Ok(2); + } else { + return Err(format!("Option {} requires a KEY=VALUE argument", arg)); + } + } + + // --env= or -e= + if arg.starts_with("--env=") || arg.starts_with("-e=") { + options + .env + .push(arg[arg.find('=').unwrap() + 1..].to_string()); + return Ok(1); + } + + // --privileged (for docker) + if arg == "--privileged" { + options.privileged = true; + return Ok(1); + } + // --endpoint (for ssh) if arg == "--endpoint" { if index + 1 < args.len() && !args[index + 1].starts_with('-') { @@ -547,6 +624,21 @@ pub fn validate_options(options: &mut WrapperOptions) -> Result<(), String> { return Err("--image option is only valid with --isolated docker".to_string()); } + // Docker runtime options (--volume, --mount, --env, --privileged) are only valid with docker + let is_docker = options.isolated.as_deref() == Some("docker"); + if !options.volumes.is_empty() && !is_docker { + return Err("--volume option is only valid with --isolated docker".to_string()); + } + if !options.mounts.is_empty() && !is_docker { + return Err("--mount option is only valid with --isolated docker".to_string()); + } + if !options.env.is_empty() && !is_docker { + return Err("--env option is only valid with --isolated docker".to_string()); + } + if options.privileged && !is_docker { + return Err("--privileged option is only valid with --isolated docker".to_string()); + } + // Endpoint is only valid with ssh if options.endpoint.is_some() && options.isolated.as_deref() != Some("ssh") { return Err("--endpoint option is only valid with --isolated ssh".to_string()); diff --git a/rust/src/lib/args_parser_cases.rs b/rust/src/lib/args_parser_cases.rs index 33e007f..c75240a 100644 --- a/rust/src/lib/args_parser_cases.rs +++ b/rust/src/lib/args_parser_cases.rs @@ -104,6 +104,155 @@ fn test_docker_with_image() { assert_eq!(result.wrapper_options.image, Some("node:20".to_string())); } +#[test] +fn test_docker_volumes_repeatable() { + let args: Vec = vec![ + "--isolated", + "docker", + "--volume", + "/host/a:/container/a", + "-v", + "/host/b:/container/b:ro", + "--", + "ls", + ] + .into_iter() + .map(String::from) + .collect(); + let result = parse_args(&args).unwrap(); + assert_eq!( + result.wrapper_options.volumes, + vec![ + "/host/a:/container/a".to_string(), + "/host/b:/container/b:ro".to_string() + ] + ); +} + +#[test] +fn test_docker_volume_eq_form() { + let args: Vec = vec![ + "-i", + "docker", + "--volume=/host/a:/c/a", + "-v=/host/b:/c/b", + "--", + "ls", + ] + .into_iter() + .map(String::from) + .collect(); + let result = parse_args(&args).unwrap(); + assert_eq!( + result.wrapper_options.volumes, + vec!["/host/a:/c/a".to_string(), "/host/b:/c/b".to_string()] + ); +} + +#[test] +fn test_docker_mounts_repeatable() { + let args: Vec = vec![ + "-i", + "docker", + "--mount", + "type=bind,src=/h,dst=/c", + "--mount=type=volume,src=vol,dst=/data", + "--", + "ls", + ] + .into_iter() + .map(String::from) + .collect(); + let result = parse_args(&args).unwrap(); + assert_eq!( + result.wrapper_options.mounts, + vec![ + "type=bind,src=/h,dst=/c".to_string(), + "type=volume,src=vol,dst=/data".to_string() + ] + ); +} + +#[test] +fn test_docker_env_repeatable() { + let args: Vec = vec![ + "-i", + "docker", + "--env", + "FOO=bar", + "-e", + "GH_TOKEN=secret", + "--env=BAZ=qux", + "--", + "env", + ] + .into_iter() + .map(String::from) + .collect(); + let result = parse_args(&args).unwrap(); + assert_eq!( + result.wrapper_options.env, + vec![ + "FOO=bar".to_string(), + "GH_TOKEN=secret".to_string(), + "BAZ=qux".to_string() + ] + ); +} + +#[test] +fn test_docker_privileged() { + let args: Vec = vec!["-i", "docker", "--privileged", "--", "ls"] + .into_iter() + .map(String::from) + .collect(); + let result = parse_args(&args).unwrap(); + assert!(result.wrapper_options.privileged); +} + +#[test] +fn test_docker_runtime_options_default_empty() { + let args: Vec = vec!["-i", "docker", "--", "ls"] + .into_iter() + .map(String::from) + .collect(); + let result = parse_args(&args).unwrap(); + assert!(result.wrapper_options.volumes.is_empty()); + assert!(result.wrapper_options.mounts.is_empty()); + assert!(result.wrapper_options.env.is_empty()); + assert!(!result.wrapper_options.privileged); +} + +#[test] +fn test_volume_requires_argument() { + let args: Vec = vec!["-i", "docker", "--volume", "--", "ls"] + .into_iter() + .map(String::from) + .collect(); + let err = parse_args(&args).unwrap_err(); + assert!(err.contains("requires a volume argument")); +} + +#[test] +fn test_volume_rejected_for_non_docker() { + let args: Vec = vec!["-i", "tmux", "-v", "/a:/b", "--", "ls"] + .into_iter() + .map(String::from) + .collect(); + let err = parse_args(&args).unwrap_err(); + assert!(err.contains("--volume option is only valid with --isolated docker")); +} + +#[test] +fn test_privileged_rejected_without_docker() { + let args: Vec = vec!["--privileged", "--", "ls"] + .into_iter() + .map(String::from) + .collect(); + let err = parse_args(&args).unwrap_err(); + assert!(err.contains("--privileged option is only valid with --isolated docker")); +} + #[test] fn test_ssh_requires_endpoint() { let args: Vec = vec!["--isolated", "ssh", "--", "npm", "test"] diff --git a/rust/src/lib/isolation.rs b/rust/src/lib/isolation.rs index 169c84f..1cb0248 100644 --- a/rust/src/lib/isolation.rs +++ b/rust/src/lib/isolation.rs @@ -36,6 +36,14 @@ pub struct IsolationOptions { pub session: Option, /// Docker image pub image: Option, + /// Docker bind mounts/volumes (-v/--volume) + pub volumes: Vec, + /// Docker --mount specs + pub mounts: Vec, + /// Docker environment variables (-e/--env, KEY=VALUE) + pub env: Vec, + /// Run docker container in privileged mode + pub privileged: bool, /// SSH endpoint pub endpoint: Option, /// Run in detached mode @@ -57,6 +65,10 @@ impl Default for IsolationOptions { IsolationOptions { session: None, image: None, + volumes: Vec::new(), + mounts: Vec::new(), + env: Vec::new(), + privileged: false, endpoint: None, detached: false, user: None, @@ -612,6 +624,29 @@ pub fn docker_pull_image(image: &str) -> (bool, String) { (success, output) } +/// Build the extra `docker run` arguments contributed by runtime options +/// (--privileged, --env/-e, --volume/-v, --mount). Returned references borrow +/// from `options`, which outlives the `docker run` invocation. +fn build_docker_runtime_args(options: &IsolationOptions) -> Vec<&str> { + let mut args: Vec<&str> = Vec::new(); + if options.privileged { + args.push("--privileged"); + } + for env_var in &options.env { + args.push("-e"); + args.push(env_var); + } + for volume in &options.volumes { + args.push("-v"); + args.push(volume); + } + for mount in &options.mounts { + args.push("--mount"); + args.push(mount); + } + args +} + /// Run command in Docker container pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResult { if !is_command_available("docker") { @@ -681,6 +716,8 @@ pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResu args.push(user); } + args.extend(build_docker_runtime_args(options)); + args.push(&image); args.push(&shell_to_use); if let Some(flag) = shell_interactive_flag { @@ -771,6 +808,8 @@ pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResu args.push(user); } + args.extend(build_docker_runtime_args(options)); + if is_debug() { eprintln!("[DEBUG] shell: {}", shell_to_use); } diff --git a/rust/src/lib/isolation_cases.rs b/rust/src/lib/isolation_cases.rs index 5f8c2f5..7795866 100644 --- a/rust/src/lib/isolation_cases.rs +++ b/rust/src/lib/isolation_cases.rs @@ -212,3 +212,36 @@ fn test_run_in_screen_detached_writes_to_provided_log_path() { .args(["-S", &session_name, "-X", "quit"]) .output(); } + +#[test] +fn test_build_docker_runtime_args_empty() { + let opts = IsolationOptions::default(); + assert!(build_docker_runtime_args(&opts).is_empty()); +} + +#[test] +fn test_build_docker_runtime_args_order() { + let opts = IsolationOptions { + privileged: true, + env: vec!["FOO=bar".to_string(), "GH_TOKEN=secret".to_string()], + volumes: vec!["/h/a:/c/a".to_string(), "/h/b:/c/b:ro".to_string()], + mounts: vec!["type=bind,src=/h,dst=/c".to_string()], + ..Default::default() + }; + assert_eq!( + build_docker_runtime_args(&opts), + vec![ + "--privileged", + "-e", + "FOO=bar", + "-e", + "GH_TOKEN=secret", + "-v", + "/h/a:/c/a", + "-v", + "/h/b:/c/b:ro", + "--mount", + "type=bind,src=/h,dst=/c", + ] + ); +} diff --git a/rust/src/lib/usage.rs b/rust/src/lib/usage.rs index 4c679e7..50db4d9 100644 --- a/rust/src/lib/usage.rs +++ b/rust/src/lib/usage.rs @@ -17,6 +17,10 @@ Options: --session-id Session UUID for tracking (auto-generated if not provided) --session-name Alias for --session-id --image Docker image (optional, defaults to OS-matched image) + --volume, -v Docker bind mount/volume host:container[:mode] (repeatable, docker only) + --mount Docker --mount spec (repeatable, docker only) + --env, -e Environment variable for docker container (repeatable, docker only) + --privileged Run docker container in privileged mode (docker only) --endpoint SSH endpoint (required for ssh isolation, e.g., user@host) --isolated-user, -u [name] Create isolated user with same permissions --keep-user Keep isolated user after command completes @@ -40,6 +44,8 @@ Examples: start -i screen -d bun start start --isolated docker -- echo 'hi' # uses OS-matched default image start --isolated docker --image oven/bun:latest -- bun install + start -i docker -v ~/.config/gh:/root/.config/gh -e TOKEN=abc -- gh repo list + start -i docker --image konard/hive-mind-dind:latest --privileged -- solve ... start --isolated ssh --endpoint user@remote.server -- ls -la start --isolated-user -- npm test start -u myuser -- npm start From 5ec790c3c6f0a03be66f579649a2334762b33319 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Jun 2026 20:02:43 +0000 Subject: [PATCH 3/4] refactor: extract docker runtime helpers to satisfy file-size limit; add parity tests - Move docker runtime status/metadata + isolation options map helpers into rust/src/lib/isolation_metadata.rs (keeps isolation.rs and main.rs <1000 lines) - Extract buildDockerRuntimeStatusLines/buildDockerRuntimeMetadata in JS isolation.js and use them from cli.js (keeps cli.js <1000 lines) - Add Rust + JS unit tests for the new helpers to restore test parity (>=90%) --- js/src/bin/cli.js | 24 +---- js/src/lib/isolation.js | 42 ++++++++ js/test/docker-runtime-options.js | 62 +++++++++++- rust/src/bin/main.rs | 80 +++------------ rust/src/lib/args_parser_cases.rs | 117 +++++++++++++++++++++ rust/src/lib/isolation_metadata.rs | 116 +++++++++++++++++++++ rust/src/lib/isolation_metadata_cases.rs | 124 +++++++++++++++++++++++ rust/src/lib/mod.rs | 4 + 8 files changed, 484 insertions(+), 85 deletions(-) create mode 100644 rust/src/lib/isolation_metadata.rs create mode 100644 rust/src/lib/isolation_metadata_cases.rs diff --git a/js/src/bin/cli.js b/js/src/bin/cli.js index 7bf1f19..6f58159 100644 --- a/js/src/bin/cli.js +++ b/js/src/bin/cli.js @@ -22,6 +22,8 @@ const { appendLogFile, createLogPath, getDefaultDockerImage, + buildDockerRuntimeStatusLines, + buildDockerRuntimeMetadata, } = require('../lib/isolation'); const { createIsolatedUser, @@ -470,18 +472,7 @@ async function runWithIsolation( if (effectiveImage) { extraLines.push(`[Isolation] Image: ${effectiveImage}`); } - if (options.volumes && options.volumes.length > 0) { - extraLines.push(`[Isolation] Volumes: ${options.volumes.join(', ')}`); - } - if (options.mounts && options.mounts.length > 0) { - extraLines.push(`[Isolation] Mounts: ${options.mounts.join(', ')}`); - } - if (options.env && options.env.length > 0) { - extraLines.push(`[Isolation] Env: ${options.env.join(', ')}`); - } - if (options.privileged) { - extraLines.push(`[Isolation] Privileged: true`); - } + extraLines.push(...buildDockerRuntimeStatusLines(options)); if (options.endpoint) { extraLines.push(`[Isolation] Endpoint: ${options.endpoint}`); } @@ -508,14 +499,7 @@ async function runWithIsolation( isolationMode: mode, sessionName, image: effectiveImage, - volumes: - options.volumes && options.volumes.length > 0 - ? options.volumes - : null, - mounts: - options.mounts && options.mounts.length > 0 ? options.mounts : null, - env: options.env && options.env.length > 0 ? options.env : null, - privileged: options.privileged || null, + ...buildDockerRuntimeMetadata(options), endpoint: options.endpoint, user: options.user, keepAlive: options.keepAlive, diff --git a/js/src/lib/isolation.js b/js/src/lib/isolation.js index f33aede..2938c89 100644 --- a/js/src/lib/isolation.js +++ b/js/src/lib/isolation.js @@ -511,6 +511,46 @@ function buildDockerRuntimeArgs(options = {}) { return args; } +/** + * Build the human-readable `[Isolation]` status lines for docker runtime + * options (volumes, mounts, env, privileged). Empty collections and a falsy + * privileged flag contribute no lines. + * @param {object} options - Options (volumes, mounts, env, privileged) + * @returns {string[]} Status lines for the start block / log header + */ +function buildDockerRuntimeStatusLines(options = {}) { + const lines = []; + if (options.volumes && options.volumes.length > 0) { + lines.push(`[Isolation] Volumes: ${options.volumes.join(', ')}`); + } + if (options.mounts && options.mounts.length > 0) { + lines.push(`[Isolation] Mounts: ${options.mounts.join(', ')}`); + } + if (options.env && options.env.length > 0) { + lines.push(`[Isolation] Env: ${options.env.join(', ')}`); + } + if (options.privileged) { + lines.push(`[Isolation] Privileged: true`); + } + return lines; +} + +/** + * Build the execution-record metadata for docker runtime options, normalizing + * empty collections and a falsy privileged flag to `null`. + * @param {object} options - Options (volumes, mounts, env, privileged) + * @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean}} + */ +function buildDockerRuntimeMetadata(options = {}) { + return { + volumes: + options.volumes && options.volumes.length > 0 ? options.volumes : null, + mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null, + env: options.env && options.env.length > 0 ? options.env : null, + privileged: options.privileged || null, + }; +} + /** * Run command in Docker container * @param {string} command - Command to execute @@ -813,6 +853,8 @@ module.exports = { runInTmux, runInDocker, buildDockerRuntimeArgs, + buildDockerRuntimeStatusLines, + buildDockerRuntimeMetadata, runInSsh, runIsolated, runAsIsolatedUser, diff --git a/js/test/docker-runtime-options.js b/js/test/docker-runtime-options.js index b40a80f..cd952c8 100644 --- a/js/test/docker-runtime-options.js +++ b/js/test/docker-runtime-options.js @@ -11,7 +11,11 @@ const { describe, it } = require('node:test'); const assert = require('assert'); const { parseArgs } = require('../src/lib/args-parser'); -const { buildDockerRuntimeArgs } = require('../src/lib/isolation'); +const { + buildDockerRuntimeArgs, + buildDockerRuntimeStatusLines, + buildDockerRuntimeMetadata, +} = require('../src/lib/isolation'); describe('Docker runtime options parsing', () => { it('should parse repeatable --volume and -v', () => { @@ -183,3 +187,59 @@ describe('buildDockerRuntimeArgs', () => { ]); }); }); + +describe('buildDockerRuntimeStatusLines', () => { + it('should return no lines for empty options', () => { + assert.deepStrictEqual(buildDockerRuntimeStatusLines({}), []); + }); + + it('should format volumes, mounts, env, and privileged lines', () => { + const lines = buildDockerRuntimeStatusLines({ + volumes: ['/h:/c:ro'], + mounts: ['type=bind,src=/h,dst=/c'], + env: ['FOO=bar'], + privileged: true, + }); + assert.deepStrictEqual(lines, [ + '[Isolation] Volumes: /h:/c:ro', + '[Isolation] Mounts: type=bind,src=/h,dst=/c', + '[Isolation] Env: FOO=bar', + '[Isolation] Privileged: true', + ]); + }); + + it('should join multiple volumes with a comma', () => { + assert.deepStrictEqual( + buildDockerRuntimeStatusLines({ volumes: ['/a:/a', '/b:/b'] }), + ['[Isolation] Volumes: /a:/a, /b:/b'] + ); + }); +}); + +describe('buildDockerRuntimeMetadata', () => { + it('should null out empty collections and falsy privileged', () => { + assert.deepStrictEqual(buildDockerRuntimeMetadata({}), { + volumes: null, + mounts: null, + env: null, + privileged: null, + }); + }); + + it('should pass through populated collections and privileged', () => { + assert.deepStrictEqual( + buildDockerRuntimeMetadata({ + volumes: ['/h:/c'], + mounts: ['type=bind,src=/h,dst=/c'], + env: ['FOO=bar'], + privileged: true, + }), + { + volumes: ['/h:/c'], + mounts: ['type=bind,src=/h,dst=/c'], + env: ['FOO=bar'], + privileged: true, + } + ); + }); +}); diff --git a/rust/src/bin/main.rs b/rust/src/bin/main.rs index 06e3a87..6c56df3 100644 --- a/rust/src/bin/main.rs +++ b/rust/src/bin/main.rs @@ -16,8 +16,9 @@ use start_command::{ args_parser::{ generate_session_name, generate_uuid, get_effective_mode, has_isolation, parse_args, }, - clear_current_execution, create_finish_block, create_log_footer, create_log_header, - create_log_path_for_execution, create_start_block, + build_isolation_options_map, clear_current_execution, create_finish_block, create_log_footer, + create_log_header, create_log_path_for_execution, create_start_block, + docker_runtime_status_lines, execution_control::{control_execution, ControlAction}, execution_store::{ CleanupOptions, ExecutionRecord, ExecutionRecordOptions, ExecutionStore, @@ -516,27 +517,12 @@ fn run_with_isolation( if let Some(ref image) = effective_image { extra_lines.push(format!("[Isolation] Image: {}", image)); } - if !wrapper_options.volumes.is_empty() { - extra_lines.push(format!( - "[Isolation] Volumes: {}", - wrapper_options.volumes.join(", ") - )); - } - if !wrapper_options.mounts.is_empty() { - extra_lines.push(format!( - "[Isolation] Mounts: {}", - wrapper_options.mounts.join(", ") - )); - } - if !wrapper_options.env.is_empty() { - extra_lines.push(format!( - "[Isolation] Env: {}", - wrapper_options.env.join(", ") - )); - } - if wrapper_options.privileged { - extra_lines.push("[Isolation] Privileged: true".to_string()); - } + extra_lines.extend(docker_runtime_status_lines( + &wrapper_options.volumes, + &wrapper_options.mounts, + &wrapper_options.env, + wrapper_options.privileged, + )); if let Some(ref endpoint) = wrapper_options.endpoint { extra_lines.push(format!("[Isolation] Endpoint: {}", endpoint)); } @@ -582,47 +568,13 @@ fn run_with_isolation( // Create execution tracking record with isolation options let execution_store = config.create_execution_store(); - let mut opts_map: std::collections::HashMap = - std::collections::HashMap::new(); - let str_val = |s: &str| serde_json::Value::String(s.to_string()); - if let Some(env) = environment { - opts_map.insert("isolated".into(), str_val(env)); - } - opts_map.insert("isolationMode".into(), str_val(mode)); - opts_map.insert("sessionName".into(), str_val(&session_name)); - if let Some(ref v) = effective_image { - opts_map.insert("image".into(), str_val(v)); - } - if !wrapper_options.volumes.is_empty() { - opts_map.insert( - "volumes".into(), - serde_json::Value::Array(wrapper_options.volumes.iter().map(|s| str_val(s)).collect()), - ); - } - if !wrapper_options.mounts.is_empty() { - opts_map.insert( - "mounts".into(), - serde_json::Value::Array(wrapper_options.mounts.iter().map(|s| str_val(s)).collect()), - ); - } - if !wrapper_options.env.is_empty() { - opts_map.insert( - "env".into(), - serde_json::Value::Array(wrapper_options.env.iter().map(|s| str_val(s)).collect()), - ); - } - if wrapper_options.privileged { - opts_map.insert("privileged".into(), serde_json::Value::Bool(true)); - } - if let Some(ref v) = wrapper_options.endpoint { - opts_map.insert("endpoint".into(), str_val(v)); - } - if let Some(ref v) = created_user { - opts_map.insert("user".into(), str_val(v)); - } - opts_map.insert( - "keepAlive".into(), - serde_json::Value::Bool(wrapper_options.keep_alive), + let opts_map = build_isolation_options_map( + environment, + mode, + &session_name, + effective_image.as_deref(), + wrapper_options, + created_user.as_deref(), ); let mut execution_record = ExecutionRecord::with_options(ExecutionRecordOptions { uuid: Some(session_id.to_string()), diff --git a/rust/src/lib/args_parser_cases.rs b/rust/src/lib/args_parser_cases.rs index c75240a..824948d 100644 --- a/rust/src/lib/args_parser_cases.rs +++ b/rust/src/lib/args_parser_cases.rs @@ -253,6 +253,123 @@ fn test_privileged_rejected_without_docker() { assert!(err.contains("--privileged option is only valid with --isolated docker")); } +#[test] +fn test_docker_env_short_flag() { + let args: Vec = vec!["-i", "docker", "-e", "TOKEN=abc", "--", "ls"] + .into_iter() + .map(String::from) + .collect(); + let result = parse_args(&args).unwrap(); + assert_eq!(result.wrapper_options.env, vec!["TOKEN=abc".to_string()]); +} + +#[test] +fn test_docker_env_eq_form() { + let args: Vec = vec!["-i", "docker", "--env=TOKEN=abc", "--", "ls"] + .into_iter() + .map(String::from) + .collect(); + let result = parse_args(&args).unwrap(); + assert_eq!(result.wrapper_options.env, vec!["TOKEN=abc".to_string()]); +} + +#[test] +fn test_docker_mount_eq_form() { + let args: Vec = vec![ + "-i", + "docker", + "--mount=type=bind,src=/h,dst=/c", + "--", + "ls", + ] + .into_iter() + .map(String::from) + .collect(); + let result = parse_args(&args).unwrap(); + assert_eq!( + result.wrapper_options.mounts, + vec!["type=bind,src=/h,dst=/c".to_string()] + ); +} + +#[test] +fn test_docker_combined_runtime_options() { + let args: Vec = vec![ + "-i", + "docker", + "--image", + "konard/hive-mind-dind:latest", + "--privileged", + "-v", + "/h/a:/c/a", + "-e", + "TOKEN=abc", + "--", + "solve", + ] + .into_iter() + .map(String::from) + .collect(); + let result = parse_args(&args).unwrap(); + assert_eq!( + result.wrapper_options.image, + Some("konard/hive-mind-dind:latest".to_string()) + ); + assert!(result.wrapper_options.privileged); + assert_eq!( + result.wrapper_options.volumes, + vec!["/h/a:/c/a".to_string()] + ); + assert_eq!(result.wrapper_options.env, vec!["TOKEN=abc".to_string()]); +} + +#[test] +fn test_mount_requires_argument() { + let args: Vec = vec!["-i", "docker", "--mount", "--", "ls"] + .into_iter() + .map(String::from) + .collect(); + let err = parse_args(&args).unwrap_err(); + assert!(err.contains("requires a mount spec argument")); +} + +#[test] +fn test_env_requires_argument() { + let args: Vec = vec!["-i", "docker", "--env", "--", "ls"] + .into_iter() + .map(String::from) + .collect(); + let err = parse_args(&args).unwrap_err(); + assert!(err.contains("requires a KEY=VALUE argument")); +} + +#[test] +fn test_mount_rejected_for_non_docker() { + let args: Vec = vec![ + "-i", + "tmux", + "--mount", + "type=bind,src=/h,dst=/c", + "--", + "ls", + ] + .into_iter() + .map(String::from) + .collect(); + let err = parse_args(&args).unwrap_err(); + assert!(err.contains("--mount option is only valid with --isolated docker")); +} + +#[test] +fn test_env_rejected_for_non_docker() { + let args: Vec = vec!["-i", "screen", "-e", "A=1", "--", "ls"] + .into_iter() + .map(String::from) + .collect(); + let err = parse_args(&args).unwrap_err(); + assert!(err.contains("--env option is only valid with --isolated docker")); +} + #[test] fn test_ssh_requires_endpoint() { let args: Vec = vec!["--isolated", "ssh", "--", "npm", "test"] diff --git a/rust/src/lib/isolation_metadata.rs b/rust/src/lib/isolation_metadata.rs new file mode 100644 index 0000000..fb6e56d --- /dev/null +++ b/rust/src/lib/isolation_metadata.rs @@ -0,0 +1,116 @@ +//! Metadata helpers for isolated executions. +//! +//! Builds the human-readable `[Isolation]` status lines and the execution +//! record options map that describe how an isolated command was launched, +//! including the configurable Docker runtime options (volumes, mounts, +//! environment variables, privileged mode). Kept separate from `isolation` +//! so the runtime backends and the metadata representation can evolve +//! independently. + +use crate::args_parser::WrapperOptions; +use std::collections::HashMap; + +/// Build the human-readable `[Isolation]` status lines for docker runtime +/// options (volumes, mounts, env, privileged). Used for the start block and +/// log header; empty collections contribute no lines. +pub fn docker_runtime_status_lines( + volumes: &[String], + mounts: &[String], + env: &[String], + privileged: bool, +) -> Vec { + let mut lines = Vec::new(); + if !volumes.is_empty() { + lines.push(format!("[Isolation] Volumes: {}", volumes.join(", "))); + } + if !mounts.is_empty() { + lines.push(format!("[Isolation] Mounts: {}", mounts.join(", "))); + } + if !env.is_empty() { + lines.push(format!("[Isolation] Env: {}", env.join(", "))); + } + if privileged { + lines.push("[Isolation] Privileged: true".to_string()); + } + lines +} + +/// Build the execution-record metadata entries for docker runtime options. +/// Returns `(key, value)` pairs to merge into the options map; empty +/// collections and a false `privileged` flag contribute no entries. +pub fn docker_runtime_metadata( + volumes: &[String], + mounts: &[String], + env: &[String], + privileged: bool, +) -> Vec<(String, serde_json::Value)> { + let arr = |items: &[String]| { + serde_json::Value::Array( + items + .iter() + .map(|s| serde_json::Value::String(s.clone())) + .collect(), + ) + }; + let mut entries = Vec::new(); + if !volumes.is_empty() { + entries.push(("volumes".to_string(), arr(volumes))); + } + if !mounts.is_empty() { + entries.push(("mounts".to_string(), arr(mounts))); + } + if !env.is_empty() { + entries.push(("env".to_string(), arr(env))); + } + if privileged { + entries.push(("privileged".to_string(), serde_json::Value::Bool(true))); + } + entries +} + +/// Build the execution-record options map describing how an isolated command +/// was launched (environment, mode, session, image, docker runtime options, +/// endpoint, user, keep-alive). Used to persist the execution record so it can +/// be surfaced via `--status`/`--list`. +pub fn build_isolation_options_map( + environment: Option<&str>, + mode: &str, + session_name: &str, + effective_image: Option<&str>, + options: &WrapperOptions, + created_user: Option<&str>, +) -> HashMap { + let str_val = |s: &str| serde_json::Value::String(s.to_string()); + let mut opts_map = HashMap::new(); + if let Some(env) = environment { + opts_map.insert("isolated".to_string(), str_val(env)); + } + opts_map.insert("isolationMode".to_string(), str_val(mode)); + opts_map.insert("sessionName".to_string(), str_val(session_name)); + if let Some(v) = effective_image { + opts_map.insert("image".to_string(), str_val(v)); + } + for (k, v) in docker_runtime_metadata( + &options.volumes, + &options.mounts, + &options.env, + options.privileged, + ) { + opts_map.insert(k, v); + } + if let Some(v) = &options.endpoint { + opts_map.insert("endpoint".to_string(), str_val(v)); + } + if let Some(v) = created_user { + opts_map.insert("user".to_string(), str_val(v)); + } + opts_map.insert( + "keepAlive".to_string(), + serde_json::Value::Bool(options.keep_alive), + ); + opts_map +} + +#[cfg(test)] +#[path = "isolation_metadata_cases.rs"] +mod tests; diff --git a/rust/src/lib/isolation_metadata_cases.rs b/rust/src/lib/isolation_metadata_cases.rs new file mode 100644 index 0000000..3049730 --- /dev/null +++ b/rust/src/lib/isolation_metadata_cases.rs @@ -0,0 +1,124 @@ +use super::*; + +#[test] +fn test_docker_runtime_status_lines_empty() { + assert!(docker_runtime_status_lines(&[], &[], &[], false).is_empty()); +} + +#[test] +fn test_docker_runtime_status_lines_populated() { + let lines = docker_runtime_status_lines( + &["/h:/c:ro".to_string()], + &["type=bind,src=/h,dst=/c".to_string()], + &["FOO=bar".to_string()], + true, + ); + assert_eq!( + lines, + vec![ + "[Isolation] Volumes: /h:/c:ro".to_string(), + "[Isolation] Mounts: type=bind,src=/h,dst=/c".to_string(), + "[Isolation] Env: FOO=bar".to_string(), + "[Isolation] Privileged: true".to_string(), + ] + ); +} + +#[test] +fn test_docker_runtime_status_lines_joins_multiple() { + let lines = + docker_runtime_status_lines(&["/a:/a".to_string(), "/b:/b".to_string()], &[], &[], false); + assert_eq!(lines, vec!["[Isolation] Volumes: /a:/a, /b:/b".to_string()]); +} + +#[test] +fn test_docker_runtime_metadata_empty() { + assert!(docker_runtime_metadata(&[], &[], &[], false).is_empty()); +} + +#[test] +fn test_docker_runtime_metadata_populated() { + let entries = docker_runtime_metadata( + &["/h:/c".to_string()], + &["type=bind,src=/h,dst=/c".to_string()], + &["FOO=bar".to_string()], + true, + ); + let map: std::collections::HashMap<_, _> = entries.into_iter().collect(); + assert_eq!( + map.get("volumes"), + Some(&serde_json::json!(["/h:/c".to_string()])) + ); + assert_eq!( + map.get("mounts"), + Some(&serde_json::json!(["type=bind,src=/h,dst=/c".to_string()])) + ); + assert_eq!( + map.get("env"), + Some(&serde_json::json!(["FOO=bar".to_string()])) + ); + assert_eq!(map.get("privileged"), Some(&serde_json::Value::Bool(true))); +} + +#[test] +fn test_docker_runtime_metadata_omits_privileged_when_false() { + let entries = docker_runtime_metadata(&["/h:/c".to_string()], &[], &[], false); + let map: std::collections::HashMap<_, _> = entries.into_iter().collect(); + assert!(map.contains_key("volumes")); + assert!(!map.contains_key("privileged")); + assert!(!map.contains_key("mounts")); + assert!(!map.contains_key("env")); +} + +#[test] +fn test_build_isolation_options_map_basic() { + let opts = WrapperOptions::default(); + let map = build_isolation_options_map( + Some("docker"), + "detached", + "my-session", + Some("alpine:latest"), + &opts, + None, + ); + assert_eq!(map.get("isolated"), Some(&serde_json::json!("docker"))); + assert_eq!( + map.get("isolationMode"), + Some(&serde_json::json!("detached")) + ); + assert_eq!( + map.get("sessionName"), + Some(&serde_json::json!("my-session")) + ); + assert_eq!(map.get("image"), Some(&serde_json::json!("alpine:latest"))); + assert_eq!(map.get("keepAlive"), Some(&serde_json::Value::Bool(false))); + assert!(!map.contains_key("volumes")); +} + +#[test] +fn test_build_isolation_options_map_includes_runtime_options() { + let opts = WrapperOptions { + volumes: vec!["/h:/c:ro".to_string()], + env: vec!["TOKEN=abc".to_string()], + privileged: true, + ..Default::default() + }; + let map = build_isolation_options_map( + Some("docker"), + "attached", + "s", + Some("konard/hive-mind-dind:latest"), + &opts, + Some("isolated-user"), + ); + assert_eq!( + map.get("volumes"), + Some(&serde_json::json!(["/h:/c:ro".to_string()])) + ); + assert_eq!( + map.get("env"), + Some(&serde_json::json!(["TOKEN=abc".to_string()])) + ); + assert_eq!(map.get("privileged"), Some(&serde_json::Value::Bool(true))); + assert_eq!(map.get("user"), Some(&serde_json::json!("isolated-user"))); +} diff --git a/rust/src/lib/mod.rs b/rust/src/lib/mod.rs index 689cf34..d856c8d 100644 --- a/rust/src/lib/mod.rs +++ b/rust/src/lib/mod.rs @@ -7,6 +7,7 @@ pub mod execution_control; pub mod execution_store; pub mod failure_handler; pub mod isolation; +pub mod isolation_metadata; pub mod log_uploader; pub mod output_blocks; pub mod sequence_parser; @@ -44,6 +45,9 @@ pub use isolation::{ is_shell_invocation_with_args, run_as_isolated_user, run_isolated, write_log_file, IsolationOptions, IsolationResult, LogHeaderParams, }; +pub use isolation_metadata::{ + build_isolation_options_map, docker_runtime_metadata, docker_runtime_status_lines, +}; pub use log_uploader::upload_execution_log; #[allow(deprecated)] pub use output_blocks::{ From bfee3a5cce3d493cb973d7466a7825c3f8b7769a Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Jun 2026 20:03:21 +0000 Subject: [PATCH 4/4] chore: remove placeholder .gitkeep now that PR has real changes --- .gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index 63fba3e..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-06-09T19:30:23.589Z for PR creation at branch issue-132-81888ef731d7 for issue https://github.com/link-foundation/start/issues/132 \ No newline at end of file