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
- Vercel: https://xjdp.vercel.app/
- Deno: https://xjdp.pi0.deno.net/
- Netlify: https://xjdp.netlify.app/
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 });npx xjdp keygenThis 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"],
},npx xjdp -u http://localhost:3000 -k eyJrdHkiOiJFQyIs...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();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) |
| Command | Description |
|---|---|
keygen |
Generate a key pair and print fingerprint, key, and ACL entry |
| 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.
# 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 xjdpcreateServer({
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)
});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.
| 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 |
| 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 |
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
| 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) |
All auth uses ECDSA P-384 via the Web Crypto API. No OpenSSL or third-party crypto.
- Client fetches
/.jdp/infoβ optionally verifies server fingerprint (prefix match) - Client requests a challenge nonce from the server
- Client signs the nonce with its private key
- Server verifies the signature against its ACL (keyed by public key fingerprint)
- 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.
- Path jail β All filesystem operations are confined to a root directory. Symlink escapes are caught via
realpathSyncre-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.
pnpm dev # playground server + REPL
pnpm test # lint + typecheck
pnpm fmt # auto-fix lint + formatMIT