Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <issue-url>

# Run on remote server via SSH
$ --isolated ssh --endpoint [email protected] -- npm test

Expand Down Expand Up @@ -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) |
Expand Down
5 changes: 5 additions & 0 deletions js/.changeset/docker-runtime-controls.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions js/src/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const {
appendLogFile,
createLogPath,
getDefaultDockerImage,
buildDockerRuntimeStatusLines,
buildDockerRuntimeMetadata,
} = require('../lib/isolation');
const {
createIsolatedUser,
Expand Down Expand Up @@ -470,6 +472,7 @@ async function runWithIsolation(
if (effectiveImage) {
extraLines.push(`[Isolation] Image: ${effectiveImage}`);
}
extraLines.push(...buildDockerRuntimeStatusLines(options));
if (options.endpoint) {
extraLines.push(`[Isolation] Endpoint: ${options.endpoint}`);
}
Expand All @@ -496,6 +499,7 @@ async function runWithIsolation(
isolationMode: mode,
sessionName,
image: effectiveImage,
...buildDockerRuntimeMetadata(options),
endpoint: options.endpoint,
user: options.user,
keepAlive: options.keepAlive,
Expand Down Expand Up @@ -570,6 +574,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,
});
Expand Down
99 changes: 99 additions & 0 deletions js/src/lib/args-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
* --detached, -d Run in detached mode (background)
* --session, -s <name> Session name for isolation
* --image <image> Docker image (optional, defaults to OS-matched image)
* --volume, -v <host:container[:mode]> Docker bind mount/volume (repeatable, docker only)
* --mount <mount-spec> Docker --mount spec (repeatable, docker only)
* --env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
* --privileged Run docker container in privileged mode (docker only)
* --endpoint <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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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=<value> or -v=<value>
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=<value>
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=<value> or -e=<value>
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('-')) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions js/src/lib/command-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
73 changes: 72 additions & 1 deletion js/src/lib/isolation.js
Original file line number Diff line number Diff line change
Expand Up @@ -486,10 +486,75 @@ 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;
}

/**
* 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
* @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 = {}) {
Expand Down Expand Up @@ -558,6 +623,8 @@ function runInDocker(command, options = {}) {
dockerArgs.push('--user', options.user);
}

dockerArgs.push(...buildDockerRuntimeArgs(options));

const effectiveCommand = options.keepAlive
? `${command}; exec ${shellToUse}`
: command;
Expand Down Expand Up @@ -640,6 +707,7 @@ function runInDocker(command, options = {}) {
if (options.user) {
dockerArgs.push('--user', options.user);
}
dockerArgs.push(...buildDockerRuntimeArgs(options));
if (DEBUG) {
console.log(`[DEBUG] shell: ${shellToUse}`);
}
Expand Down Expand Up @@ -784,6 +852,9 @@ module.exports = {
runInScreen,
runInTmux,
runInDocker,
buildDockerRuntimeArgs,
buildDockerRuntimeStatusLines,
buildDockerRuntimeMetadata,
runInSsh,
runIsolated,
runAsIsolatedUser,
Expand Down
6 changes: 6 additions & 0 deletions js/src/lib/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ Options:
--session-id <uuid> Session UUID for tracking (auto-generated if not provided)
--session-name <uuid> Alias for --session-id
--image <image> Docker image (optional, defaults to OS-matched image)
--volume, -v <spec> Docker bind mount/volume host:container[:mode] (repeatable, docker only)
--mount <spec> Docker --mount spec (repeatable, docker only)
--env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
--privileged Run docker container in privileged mode (docker only)
--endpoint <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
Expand All @@ -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 [email protected] -- ls -la
$ --isolated-user -- npm test # Create isolated user
$ -u myuser -- npm start # Custom username
Expand Down
Loading
Loading