Skip to content

pi0/xjdp

Repository files navigation

🐚 xjdp

Remote shell for JavaScript servers.

Eval, exec, and filesystem access to any running JavaScript server over HTTP β€” with ECDSA public-key auth, scoped permissions, and path-jailed filesystem.

Built for AI agents and curious humans! Connect an agent to a remote server and let it inspect state, run commands, read logs, and edit files. Turning any deployment into an interactive sandbox.

  • Zero dependencies.
  • Isomorphic client (browser, Node.js, AI agents)
  • SSE streaming with HTTP polling fallback
  • Path-jailed filesystem, env-filtered exec, scoped permissions

Demos

Quick Start

1. Set up a server

The server exports a standard { fetch } handler that works with any runtime (Node.js via srvx, Bun, Deno, etc).

import { serve } from "srvx";
import { createServer, generateKeyPair, fingerprint } from "xjdp";

const serverKeyPair = await generateKeyPair();

// Clients use this to verify the server identity
console.log("fingerprint:", await fingerprint(serverKeyPair.publicKey));

const server = createServer({
  serverKeyPair,
  acl: {
    // Grant any client read-only access
    "*": ["fs:read"],
  },
});

serve({ fetch: server.fetch, port: 3000 });

2. Generate a client key

npx xjdp keygen

This outputs everything you need:

Generated ECDSA P-384 key pair

Fingerprint:
9b4270452ef7f75efc0...

Private key (pass via -k flag):
eyJrdHkiOiJFQyIs...

ACL entry (paste into server config):
"9b4270452ef7...": ["eval","exec","fs:read","fs:write"]

Connect:
npx xjdp -u http://localhost:3000 -k eyJrdHkiOiJFQyIs...

Copy the ACL entry into your server's acl config:

acl: {
  "*": ["fs:read"],
  "9b4270452ef7...": ["eval", "exec", "fs:read", "fs:write"],
},

3. Connect

npx xjdp -u http://localhost:3000 -k eyJrdHkiOiJFQyIs...

Programmatic API

import { RJDPClient, parseKey } from "xjdp";

// No key β€” auto-generates ephemeral key pair (readonly via "*" wildcard ACL)
const client = await RJDPClient.connect("http://localhost:3000");

// Or with a pre-shared key for full access
// const client = await RJDPClient.connect("http://localhost:3000", {
//   ...await parseKey("eyJrdHkiOiJFQyIs..."),
//   serverFingerprint: "a3f9", // optional β€” prefix match
// });

// Eval
const { result } = await client.eval("process.version");

// Eval with context
const { result: sum } = await client.eval("a + b", { context: { a: 1, b: 2 } });

// Exec with streaming output
const proc = client.exec("ls", ["-la"]);
for await (const chunk of proc.stdout) {
  process.stdout.write(chunk);
}
const exit = await proc.wait();

// Filesystem
const content = await client.fs.read("/src/index.ts");
await client.fs.write("/tmp/out.txt", "hello");
const entries = await client.fs.list("/src");
await client.fs.mkdir("/src/new-dir");
await client.fs.rename("/old.txt", "/new.txt");
await client.fs.delete("/tmp/out.txt");

client.close();

CLI

npx xjdp [flags]
Flag Env Var Description
-u, --url XJDP_URL Server URL (default: http://localhost:3000)
-f, --fingerprint XJDP_FINGERPRINT Expected server fingerprint (prefix match)
-k, --key XJDP_KEY Pre-shared private key (base64 JWK)

Subcommands

Command Description
keygen Generate a key pair and print fingerprint, key, and ACL entry

REPL Commands

Command Description
<js code> Evaluate JavaScript (or fall through to exec)
eval <code> Explicitly evaluate JavaScript
repl Enter JS REPL mode (all input is eval'd)
exec <file> [args] Execute a file with streaming output
cd [path] Change remote working directory
ls [path] List directory contents
cat <path> Read file contents
write <path> <text> Write text to a file
stat <path> File/directory info
mkdir <path> Create directory
rm <path> Delete file or directory
mv <from> <to> Rename/move
help Show all commands
exit Quit

Unrecognized commands are passed to exec automatically, so you can type node -v or git status directly.

Examples

# Pin server fingerprint (prefix match β€” even 4 chars works)
npx xjdp -u http://localhost:3000 -f a3f9

# Combine fingerprint pinning + pre-shared key
npx xjdp -u http://localhost:3000 -f a3f9 -k eyJrdHkiOiJFQyIs...

# Via environment variables
XJDP_URL=http://localhost:3000 XJDP_FINGERPRINT=a3f9 XJDP_KEY=eyJrdHkiOiJFQyIs... npx xjdp

Server Configuration

createServer({
  serverKeyPair: CryptoKeyPair, // Required β€” server ECDSA P-384 key pair
  acl: Record<string, Scope[]>, // Required β€” fingerprint β†’ scopes mapping
  fsRoot: "/workspace", // Path jail root (default: /workspace)
  transports: ["sse", "http"], // Enabled transports (default: both)
  capabilities: ["eval", "exec", "fs"], // Enabled capabilities (default: all)
  maxConcurrentExec: 3, // Per-session exec limit
  sessionTtl: 3600000, // Session TTL in ms (default: 1h)
  evalTimeout: 5000, // Eval timeout in ms (default: 5s)
  maxReadSize: 10485760, // Max file read size (default: 10MB)
  envDenylist: [/AWS_.*/, /.*TOKEN.*/], // Exec env filter patterns
  storage: Storage, // External KV for sessions/nonces (default: in-memory)
});

External Storage

By default, sessions and nonces are stored in-memory. This works for single-instance deployments but breaks on serverless platforms (Netlify, AWS Lambda, etc.) where each request may hit a different instance.

Pass a storage option implementing the Storage interface to use an external KV store:

interface Storage {
  get(key: string): string | undefined | Promise<string | undefined>;
  set(key: string, value: string, ttl?: number): void | Promise<void>;
  delete(key: string): void | Promise<void>;
}

Example with Redis:

import { createClient } from "redis";

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const storage = {
  async get(key) {
    return (await redis.get(key)) ?? undefined;
  },
  async set(key, value, ttl) {
    if (ttl) await redis.set(key, value, { PX: ttl });
    else await redis.set(key, value);
  },
  async delete(key) {
    await redis.del(key);
  },
};

const server = createServer({
  serverKeyPair,
  acl: { "*": ["fs:read"] },
  storage,
});

Any KV store with get/set/delete works: Redis, Upstash, Deno KV, Netlify Blobs, Cloudflare KV, etc.

Scopes

Scope Description
eval Execute JavaScript via AsyncFunction
exec Spawn child processes
fs:read Read files, list directories, stat
fs:write Write files, mkdir, rename, delete

Key Utilities

Function Description
generateKeyPair() Generate ECDSA P-384 key pair (non-extractable by default)
generateKeyPair({ extractable: true }) Generate extractable key pair (for serialization)
serializeKey(key) Serialize a CryptoKey to a compact base64 string
parseKey(str) Parse back to CryptoKeyPair (private) or CryptoKey (public)
fingerprint(publicKey) SHA-256 hex fingerprint of a public key

Protocol

Client                                          Server
  β”‚                                                β”‚
  │─── GET /.jdp/info ──────────────────────────►  β”‚
  │◄── { transports, capabilities, fingerprint }   β”‚
  β”‚                                                β”‚
  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”               β”‚
  β”‚  β”‚ verify server fingerprint   β”‚  (optional)   β”‚
  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜               β”‚
  β”‚                                                β”‚
  │─── GET /.jdp/challenge ─────────────────────►  β”‚
  │◄── { nonce, serverPubKey, ttl } ─────────────  β”‚
  β”‚                                                β”‚
  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”               β”‚
  β”‚  β”‚ sign(nonce, clientPrivKey)  β”‚               β”‚
  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜               β”‚
  β”‚                                                β”‚
  │─── POST /.jdp/auth { sig, pubKey, nonce } ──►  β”‚
  │◄── { sessionId, scopes, expiresAt } ─────────  β”‚
  β”‚                                                β”‚
  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”               β”‚
  β”‚  β”‚ pick best transport         β”‚               β”‚
  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜               β”‚
  β”‚                                                β”‚
  ╔════════════════════════════════════════════════╗
  β•‘  SSE Transport                                 β•‘
  β•‘                                                β•‘
  β•‘  │─── GET /.jdp/stream ──────────────────►  β”‚  β•‘
  β•‘  │◄── text/event-stream ─────────────────   β”‚  β•‘
  β•‘  β”‚                                          β”‚  β•‘
  β•‘  │─── POST /.jdp/send { frame } ────────►  β”‚  β•‘
  β•‘  │◄── SSE: event: eval.res ──────────────   β”‚  β•‘
  β•‘  │◄── SSE: event: exec.stdout ───────────   β”‚  β•‘
  β•‘  │◄── SSE: event: exec.exit ─────────────   β”‚  β•‘
  β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
  ╔════════════════════════════════════════════════╗
  β•‘  HTTP Fallback Transport                       β•‘
  β•‘                                                β•‘
  β•‘  │─── POST /.jdp/invoke { frame } ──────►  β”‚  β•‘
  β•‘  │◄── { response frame } ────────────────   β”‚  β•‘
  β•‘  β”‚                                          β”‚  β•‘
  β•‘  │─── GET /.jdp/poll?id=…&cursor=… ─────►  β”‚  β•‘
  β•‘  │◄── { chunks, next, done } ────────────   β”‚  β•‘
  β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•

Frames: { id, type, ts, payload }

  eval.req ──► eval.res
  exec.req ──► exec.stdout* ──► exec.stderr* ──► exec.exit
  exec.kill
  fs.req   ──► fs.res
  ping     ──► pong

Endpoints

Endpoint Method Auth Description
/.jdp/info GET No Capabilities, transports & server fingerprint
/.jdp/challenge GET No Request auth nonce
/.jdp/auth POST No Authenticate with signed nonce
/.jdp/stream GET Yes Open SSE stream
/.jdp/send POST Yes Send frame via SSE transport
/.jdp/invoke POST Yes Send frame via HTTP transport
/.jdp/poll GET Yes Poll exec output (HTTP fallback)

Authentication

All auth uses ECDSA P-384 via the Web Crypto API. No OpenSSL or third-party crypto.

  1. Client fetches /.jdp/info β€” optionally verifies server fingerprint (prefix match)
  2. Client requests a challenge nonce from the server
  3. Client signs the nonce with its private key
  4. Server verifies the signature against its ACL (keyed by public key fingerprint)
  5. Server issues a session token with scoped permissions

Nonces are single-use with a 30-second TTL, stored in an LRU cache for replay prevention.

Security

  • Path jail β€” All filesystem operations are confined to a root directory. Symlink escapes are caught via realpathSync re-check.
  • Env denylist β€” Exec filters out environment variables matching AWS_*, *TOKEN*, *SECRET*, *PASSWORD*, *PRIVATE*.
  • Scoped permissions β€” Each client key is mapped to specific capabilities (eval, exec, fs:read, fs:write).
  • Nonce replay prevention β€” LRU cache of used nonces with TTL expiry.
  • Session expiry β€” Configurable TTL (default 1 hour).
  • Frame size limits β€” Configurable max frame and file read sizes.
  • Server fingerprint pinning β€” Clients can verify the server's identity via fingerprint prefix matching.

Development

pnpm dev           # playground server + REPL
pnpm test          # lint + typecheck
pnpm fmt           # auto-fix lint + format

Sponsors

License

MIT

About

🐚 Remote shell for JavaScript servers.

Resources

License

Stars

Watchers

Forks

Contributors