xjdp
v0.0.1
Published
Secure DevTools protocol for your remote JS servers.
Readme
xjdp
Secure DevTools protocol for your remote JS servers.
xjdp gives you eval, exec, and filesystem access to any running JavaScript server through a lightweight debugging protocol. Think of it as attaching a DevTools session to a production Node.js process, except it works over HTTP with ECDSA public-key authentication, scoped permissions, and path-jailed filesystem access — instead of an open WebSocket on localhost.
Built for AI agents. 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. Treat long-running servers like short-lived containers: attach, debug, fix, detach.
- Zero production dependencies — Web Crypto API only
- Isomorphic client (browser + Node.js + AI agents)
- SSE streaming with HTTP polling fallback
- Path-jailed filesystem, env-filtered exec, scoped permissions
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();
// Print server fingerprint — clients use this to verify they're connecting to the right server
console.log("Server 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 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"],
},3. Connect
npx xjdp -u http://localhost:3000 -k eyJrdHkiOiJFQyIs...Programmatic API
import { RJDPClient, generateKeyPair, parseKey } from "xjdp";
// Fresh key pair (ephemeral — works with "*" wildcard ACL)
const { privateKey, publicKey } = await generateKeyPair();
// Or import a pre-shared key
// const { privateKey, publicKey } = await parseKey("eyJrdHkiOiJFQyIs...");
const client = await RJDPClient.connect("http://localhost:3000", {
privateKey,
publicKey,
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();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 |
CLI 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.
CLI 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 |
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 xjdpServer 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
});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 |
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) |
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 ──► pongAuthentication
All auth uses ECDSA P-384 via the Web Crypto API. No OpenSSL or third-party crypto required.
- 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.
Security
- 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.
Development
pnpm dev # playground server + REPL
pnpm test # lint + typecheck
pnpm fmt # auto-fix lint + formatSponsors
License
MIT
