Skip to content

A persistent shell service that provides HTTP API access to a long-running shell session. It is designed for use by LLMs (through a tool) that need to execute shell commands while maintaining shell state (environment variables, working directory, shell functions) across multiple requests.

License

Notifications You must be signed in to change notification settings

EndlessInternational/shelld

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

shelld

A persistent shell service that provides HTTP API access to a long-running shell session. It is designed for use by LLMs (through a tool) that need to execute shell commands while maintaining shell state (environment variables, working directory, shell functions) across multiple requests.

The shelld service includes no authentication - only a simple API key that acts as a lock token to ensure the correct client is talking to the correct shell instance. It MUST be used inside of a private network or VPC on infrastructure that does not expose the service to the public internet.

The shelld service SHOULD be executed in a container (potentially a container that is reaped after the shell is shutdown, thereby ensuring each LLM session gets a fresh container). In theory, you could run the service on your local machine to allow your own local agent to access that machine through a shell tool, but this is NOT RECOMMENDED as a local agent can take actions that will make your machine unusable.

Overview

The shelld service spawns a single bash shell process with a PTY (pseudo-terminal) and exposes it via HTTP endpoints. Unlike typical command execution where each command runs in a fresh process, shelld maintains a persistent shell session:

  • Environment variables set in one request persist to subsequent requests
  • Working directory changes persist
  • Shell functions and aliases persist
  • The shell process remains running between requests

Quick Start

# Build
go build -o bin/shelld ./cmd/shelld

# Create minimal config (defaults are fine)
cat > config.toml << 'EOF'
# shelld uses defaults for everything
# API key is set by first client request
EOF

# Run
./bin/shelld --config config.toml

Authentication and Locking

All endpoints except /health require the X-Shell-Key header:

curl -X POST -H "X-Shell-Key: my-session-key" http://localhost:8080/startup

Requests without a key receive 401 Unauthorized.

Shell Locking Model

The shell uses a first-come-first-served locking model:

  1. First request locks - The first request with an X-Shell-Key header locks the shell to that key
  2. Subsequent requests verify - All subsequent requests must use the same key
  3. No pre-configuration - The API key is not configured in advance; it is set by the first client
┌─────────────────────────────────────────────────────────┐
│                    Locking Flow                         │
│                                                         │
│  1. First request: X-Shell-Key: abc123                  │
│     → Shell locks to "abc123"                           │
│                                                         │
│  2. Subsequent request: X-Shell-Key: abc123             │
│     → Allowed (key matches)                             │
│                                                         │
│  3. Request with different key: X-Shell-Key: wrong      │
│     → 401 Unauthorized (key doesn't match lock)         │
└─────────────────────────────────────────────────────────┘

This design is intentional for LLM usage:

  • One shelld instance per LLM session - Each LLM session should have its own shelld instance
  • Exclusive access - The LLM "owns" the shell for the duration of the session
  • State isolation - Environment variables, working directory, and shell state are private to that session
  • Clean shutdown - When the session ends, the shelld instance shuts down (either via /shutdown, idle timeout, or process termination)

Do not share a shelld instance between multiple LLM sessions or users.

Shell Lifecycle

The shell has four states:

State Description
available Initial state. Shell process not running. Call /startup to start.
ready Shell running and waiting for commands. Call /run to execute.
busy Shell executing a command. Wait for completion or call /kill.
unrecoverable Shell in error state. Call /recycle to recover.

State Diagram

                    ┌─────────────┐
                    │  available  │
                    └──────┬──────┘
                           │ /startup
                           ▼
        ┌─────────────────────────────────────┐
        │                ready                 │◄────────┐
        └──────┬──────────────────────────────┘          │
               │ /run                                    │
               ▼                                         │
        ┌─────────────┐                                  │
        │    busy     │──────────────────────────────────┘
        └──────┬──────┘  command completes
               │
               │ error or /kill
               ▼
        ┌─────────────────┐
        │  unrecoverable  │
        └────────┬────────┘
                 │ /recycle
                 ▼
          (back to ready)

Endpoints

Method Path Auth Description
POST /startup Yes Start the shell process
POST /run Yes Execute a command
POST /kill Yes Terminate running command/shell
POST /recycle Yes Kill and restart shell
POST /shutdown Yes Shutdown the server (or ?recycle=true to recycle shell)
GET /status Yes Get current shell state
GET /health No Health check

Detailed Documentation:

Command Execution

Basic Usage

# Start shell
curl -X POST -H "X-Shell-Key: $KEY" http://localhost:8080/startup

# Run command
curl -X POST -H "X-Shell-Key: $KEY" -d "echo hello" http://localhost:8080/run
# Response: hello

# Commands persist state
curl -X POST -H "X-Shell-Key: $KEY" -d "export FOO=bar" http://localhost:8080/run
curl -X POST -H "X-Shell-Key: $KEY" -d "echo \$FOO" http://localhost:8080/run
# Response: bar

Command Timeout

Commands have a configurable timeout (default: 5 minutes). If a command doesn't complete within the timeout:

  1. The HTTP request returns 202 Accepted with an error message
  2. The shell remains in busy state
  3. The command continues running in the background
  4. When the command completes, the shell returns to ready state

You can override the timeout per-request using the X-Command-Timeout header:

# Wait up to 30 minutes for this command
curl -X POST \
  -H "X-Shell-Key: $KEY" \
  -H "X-Command-Timeout: 30m" \
  -d "long-running-task" \
  http://localhost:8080/run

The timeout value is capped by timeout.command_maximum in the config.

Handling Long-Running Commands

For commands that may exceed the timeout:

  1. Set an appropriate X-Command-Timeout header
  2. If you get 202 Accepted, poll /status until it returns ready
  3. The command output is lost on timeout (the command still runs, but output isn't captured)
  4. To abort, call /kill
# Start long command with short timeout
curl -X POST -H "X-Shell-Key: $KEY" -H "X-Command-Timeout: 1s" \
  -d "sleep 10 && echo done" http://localhost:8080/run
# Returns 202 immediately

# Poll for completion
while [ "$(curl -s -H "X-Shell-Key: $KEY" http://localhost:8080/status)" = "busy" ]; do
  sleep 1
done
echo "Command completed"

Configuration

Configuration is in TOML format. See config/config.example.toml for all options.

All Settings

[server]
port = 8080                  # HTTP port (default: 8080)

[shell]
command = "/bin/bash"        # Shell to execute (default: /bin/bash)
working_directory = ""      # Initial working directory (default: where shelld was launched)

[timeout]
command = "5m"               # Default command timeout (default: 5m)
command_maximum = "30m"      # Max allowed via header (default: 30m)
idle = "30m"                 # Shutdown after inactivity (default: 30m)
shutdown = "30s"             # Graceful shutdown timeout (default: 30s)
kill = "5s"                  # SIGINT to SIGKILL grace period (default: 5s)

[hooks]
shell_command = "/bin/sh"    # Shell for hooks (default: /bin/sh)
on_startup = ""              # Command to run on startup
on_shutdown = ""             # Command to run on shutdown
on_recycle = ""              # Command to run on recycle

Environment Variables

  • SHELLD_CONFIG - Path to config file (alternative to --config flag)

Idle Timeout

The server automatically shuts down after timeout.idle duration of inactivity. Any authenticated request (including /status) resets the idle timer.

For long-running commands, poll /status periodically to keep the server alive:

# Keep server alive while waiting for command
while [ "$(curl -s -H "X-Shell-Key: $KEY" http://localhost:8080/status)" = "busy" ]; do
  sleep 60  # Poll every minute to reset idle timer
done

Error Handling

HTTP Status Codes

Code Meaning
200 Success
202 Command timed out (still running in background)
400 Bad request (empty command, invalid timeout header)
401 Unauthorized (missing or invalid API key)
409 Conflict (wrong shell state for operation)
500 Internal server error

Common Errors

Error Message Cause Solution
"The shell has not been started." Called /run before /startup Call /startup first
"The shell is busy executing another command." Called /run while command running Wait for completion or /kill
"The shell is already started." Called /startup when already running Shell is ready, proceed with /run
"The shell is in an unrecoverable state and must be recycled." Shell crashed or error occurred Call /recycle

Lifecycle Hooks

Hooks run shell commands at specific lifecycle events:

[hooks]
on_startup = "echo 'Shell starting' >> /var/log/shelld.log"
on_shutdown = "cleanup-script.sh"
on_recycle = "echo 'Shell recycled'"

Hooks:

  • Run synchronously (server waits for completion)
  • Run in a separate shell process (not the persistent shell)
  • Have SHELLD_HOOK environment variable set to the hook type
  • Errors are logged but don't block the operation

Building and Testing

# Build
go build -o bin/shelld ./cmd/shelld

# Run all tests
./test.sh

# Run only unit tests
go test ./internal/...

# Run only integration tests
./test.sh  # Runs both, but you can run individual test scripts

Security Considerations

  1. API Key: Use a strong, random API key. The key is transmitted in HTTP headers.
  2. TLS: shelld does not provide TLS. Use a reverse proxy (nginx, caddy) for HTTPS.
  3. Network: Bind to localhost or use firewall rules to restrict access.
  4. Commands: shelld executes arbitrary commands. Only allow trusted clients.
  5. Isolation: Run in a container or VM to limit blast radius.

Architecture

┌─────────────────────────────────────────────────────┐
│                    shelld                            │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────┐  │
│  │ HTTP Server │───▶│ Shell Mgr   │───▶│  bash   │  │
│  │  (net/http) │    │  (PTY)      │    │ process │  │
│  └─────────────┘    └─────────────┘    └─────────┘  │
│         │                  │                         │
│         ▼                  ▼                         │
│  ┌─────────────┐    ┌─────────────┐                 │
│  │   Config    │    │  Lifecycle  │                 │
│  │   (TOML)    │    │   Hooks     │                 │
│  └─────────────┘    └─────────────┘                 │
└─────────────────────────────────────────────────────┘

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/EndlessInternational/shelld.

License

This project is available as open source under the terms of the MIT License.

About

A persistent shell service that provides HTTP API access to a long-running shell session. It is designed for use by LLMs (through a tool) that need to execute shell commands while maintaining shell state (environment variables, working directory, shell functions) across multiple requests.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published