Shell streaming support (Lua)

This commit is contained in:
Zef Hemel
2025-03-27 17:04:15 +01:00
parent 4587496406
commit 96f299bf8a
8 changed files with 1294 additions and 16 deletions
+254
View File
@@ -0,0 +1,254 @@
import type { HttpSpacePrimitives } from "./spaces/http_space_primitives.ts";
/**
* Event types emitted by the ShellStreamClient
*/
export type ShellStreamEventType =
| "stdout"
| "stderr"
| "exit"
| "error"
| "open"
| "close";
/**
* Event object emitted by the ShellStreamClient
*/
export interface ShellStreamEvent {
type: ShellStreamEventType;
data: string | { code: number };
}
/**
* Options for creating a ShellStreamClient
*/
export interface ShellStreamOptions {
/**
* HTTP space primitives for authenticated requests
*/
httpSpacePrimitives: HttpSpacePrimitives;
/**
* Command to execute
*/
cmd: string;
/**
* Command arguments
*/
args?: string[];
}
/**
* Client for interacting with the shell stream WebSocket API
*/
export class ShellStreamClient {
private ws: WebSocket | null = null;
private eventHandlers = new Map<
ShellStreamEventType,
Array<(event: ShellStreamEvent) => void>
>();
constructor(private options: ShellStreamOptions) {
// Set default options
this.options.args = options.args ?? [];
}
/**
* Connect to the shell stream WebSocket
*/
public async start(): Promise<void> {
if (this.ws) {
return Promise.resolve(); // Already connected
}
// Create an authenticated WebSocket connection
this.ws = await this.options.httpSpacePrimitives
.createAuthenticatedWebSocket(
".shell/stream",
{
cmd: this.options.cmd,
args: JSON.stringify(this.options.args || []),
},
);
// Set up event handlers
this.setupEventHandlers();
// Wait for the connection to be established
return new Promise<void>((resolve, reject) => {
if (!this.ws) {
reject(new Error("WebSocket not initialized"));
return;
}
const onOpen = () => {
this.ws?.removeEventListener("open", onOpen);
this.ws?.removeEventListener("error", onError);
resolve();
};
const onError = (error: Event) => {
this.ws?.removeEventListener("open", onOpen);
this.ws?.removeEventListener("error", onError);
reject(new Error(`Failed to connect: ${error}`));
};
this.ws.addEventListener("open", onOpen);
this.ws.addEventListener("error", onError);
});
}
/**
* Set up WebSocket event handlers
* @private
*/
private setupEventHandlers(): void {
if (!this.ws) return;
// Set up event handlers
this.ws.onopen = () => {
this.emitEvent({ type: "open", data: "Connected" });
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
// Special handling for exit events
if (message.type === "exit") {
// Parse the exit code from the JSON string if needed
let exitData = message.data;
if (typeof exitData === "string" && typeof exitData === "string") {
try {
// Try to parse as JSON in case it's a stringified object
const parsedData = JSON.parse(exitData);
if (
typeof parsedData === "object" && parsedData !== null &&
"code" in parsedData
) {
exitData = parsedData;
}
} catch (e) {
// If it's not valid JSON, just use it as is
console.error("Failed to parse exit data:", e);
}
}
this.emitEvent({
type: message.type,
data: exitData,
});
} else {
// Regular event
this.emitEvent({
type: message.type,
data: message.data,
});
}
} catch (e) {
const error = e as Error;
this.emitEvent({
type: "error",
data: `Error parsing message: ${error.message}`,
});
}
};
this.ws.onclose = () => {
this.ws = null;
this.emitEvent({ type: "close", data: "Disconnected" });
};
this.ws.onerror = (error) => {
this.emitEvent({
type: "error",
data: `WebSocket error: ${error}`,
});
};
}
/**
* Close the WebSocket connection
*/
public close(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
/**
* Send data to the process stdin
*/
public send(data: string): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket not connected");
}
this.ws.send(JSON.stringify({
type: "stdin",
data,
}));
}
/**
* Send a signal to the process
* @param signal The signal to send (e.g., "SIGTERM", "SIGINT", "SIGKILL", "SIGHUP")
* @throws Error if the WebSocket is not connected
*/
public kill(signal: string): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket not connected");
}
this.ws.send(JSON.stringify({
type: "signal",
signal,
}));
}
/**
* Register an event handler
*/
public addEventListener(
eventType: ShellStreamEventType,
handler: (event: ShellStreamEvent) => void,
): void {
if (!this.eventHandlers.has(eventType)) {
this.eventHandlers.set(eventType, []);
}
this.eventHandlers.get(eventType)!.push(handler);
}
/**
* Remove an event handler
*/
public removeEventListener(
eventType: ShellStreamEventType,
handler: (event: ShellStreamEvent) => void,
): void {
if (!this.eventHandlers.has(eventType)) {
return;
}
const handlers = this.eventHandlers.get(eventType)!;
const index = handlers.indexOf(handler);
if (index !== -1) {
handlers.splice(index, 1);
}
}
/**
* Emit an event to all registered handlers
*/
private emitEvent(event: ShellStreamEvent): void {
// Call handlers for the specific event type
if (this.eventHandlers.has(event.type)) {
for (const handler of this.eventHandlers.get(event.type)!) {
handler(event);
}
}
}
}
+33 -1
View File
@@ -238,11 +238,43 @@ export class HttpSpacePrimitives implements SpacePrimitives {
// Used to check if the server is reachable and the user is authenticated
// If not: throws an error or invokes a redirect
async ping() {
await this.authenticatedFetch(`${this.url}/.ping`, {
const response = await this.authenticatedFetch(`${this.url}/.ping`, {
method: "GET",
headers: {
Accept: "application/json",
},
}, 5000);
// Consume the response body to avoid leaks
await response.text();
}
/**
* Create an authenticated WebSocket connection
* @param path The path to connect to
* @param queryParams Optional query parameters
* @returns A WebSocket connection
*/
async createAuthenticatedWebSocket(
path: string,
queryParams: Record<string, string> = {},
): Promise<WebSocket> {
// First make an authenticated request to ensure we have valid cookies
await this.ping();
// Build the WebSocket URL with query parameters
const queryString = Object.entries(queryParams)
.map(([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`
)
.join("&");
// Create the WebSocket URL
const wsUrl = `${this.url.replace(/^http/, "ws")}/${path}${
queryString ? `?${queryString}` : ""
}`;
// Create and return the WebSocket
return new WebSocket(wsUrl);
}
}
+34
View File
@@ -1,5 +1,26 @@
import type { ShellStreamClient } from "$common/shell_stream_client.ts";
import { syscall } from "../syscall.ts";
/**
* Interface for the shell stream client
*/
export interface ShellStream {
/**
* Send data to the process stdin
*/
send(data: string): void;
/**
* Send a signal to the process
*/
kill(signal: string): void;
/**
* Close the connection
*/
close(): void;
}
/**
* Runs a shell command.
* @param cmd the command to run
@@ -12,3 +33,16 @@ export function run(
): Promise<{ stdout: string; stderr: string; code: number }> {
return syscall("shell.run", cmd, args);
}
/**
* Runs a shell command with streaming I/O.
* @param cmd the command to run
* @param args the arguments to pass to the command
* @returns a shell stream client for interacting with the process
*/
export function spawn(
cmd: string,
args: string[],
): Promise<ShellStreamClient> {
return syscall("shell.spawn", cmd, args);
}
+19 -15
View File
@@ -3,7 +3,10 @@ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import { validator } from "hono/validator";
import type { AssetBundle } from "$lib/asset_bundle/bundle.ts";
import type { FileMeta } from "@silverbulletmd/silverbullet/types";
import type { ShellRequest } from "@silverbulletmd/silverbullet/type/rpc";
import {
handleShellEndpoint,
handleShellStreamEndpoint,
} from "./shell_endpoints.ts";
import { SpaceServer } from "./space_server.ts";
import type { KvPrimitives } from "$lib/data/kv_primitives.ts";
import { extendedMarkdownLanguage } from "$common/markdown_parser/parser.ts";
@@ -397,20 +400,21 @@ export class HttpServer {
});
// Shell command endpoint
this.app.post("/.shell", async (c) => {
const req = c.req;
const body = await req.json();
try {
const shellCommand: ShellRequest = body;
// Note: in read-only this is set to NoShellSupport, so don't worry
const shellResponse = await this.spaceServer.shellBackend.handle(
shellCommand,
);
return c.json(shellResponse);
} catch (e: any) {
console.log("Shell error", e);
return c.text(e.message, 500);
}
this.app.post("/.shell", (c) => {
return handleShellEndpoint(
c,
this.spaceServer.shellBackend,
this.spaceServer.readOnly,
);
});
// Shell WebSocket endpoint
this.app.get("/.shell/stream", (c) => {
return handleShellStreamEndpoint(
c,
this.spaceServer.pagesPath,
this.spaceServer.readOnly,
);
});
// HTTP Proxy endpoint
+583
View File
@@ -0,0 +1,583 @@
import { assert, assertEquals, assertExists } from "@std/assert";
import { HttpServer, type ServerOptions } from "./http_server.ts";
import { AssetBundle } from "$lib/asset_bundle/bundle.ts";
import { MemoryKvPrimitives } from "$lib/data/memory_kv_primitives.ts";
import { sleep } from "../lib/async.ts";
import {
ShellStreamClient,
type ShellStreamEvent,
} from "../common/shell_stream_client.ts";
import { HttpSpacePrimitives } from "../common/spaces/http_space_primitives.ts";
// Create a test script
async function createTestScript(dir: string): Promise<string> {
const scriptContent = `#!/bin/sh
echo "Hello from test script"
echo "This is stderr output" >&2
read input
echo "You said: $input"
exit 0
`;
const scriptPath = `${dir}/test_script.sh`;
await Deno.writeTextFile(scriptPath, scriptContent);
await Deno.chmod(scriptPath, 0o755); // Make executable
return scriptPath;
}
// Create a signal-handling test script
async function createSignalTestScript(dir: string): Promise<string> {
const scriptContent = `#!/bin/sh
echo "Starting signal test script"
# Set up signal handler
trap 'echo "Received SIGTERM"; exit 0' TERM
trap 'echo "Received SIGINT"; exit 0' INT
echo "Signal handlers set up"
# Wait indefinitely
while true; do
sleep 1
done
`;
const scriptPath = `${dir}/signal_test.sh`;
await Deno.writeTextFile(scriptPath, scriptContent);
await Deno.chmod(scriptPath, 0o755); // Make executable
return scriptPath;
}
// Create a long-running test script that writes to a file when it exits
async function createLongRunningScript(dir: string): Promise<string> {
const scriptContent = `#!/bin/sh
echo "Starting long-running script"
# Create a file to track the process ID
echo $$ > "${dir}/process.pid"
# Set up signal handler to detect proper cleanup
trap 'echo "Process terminated properly" > "${dir}/cleanup.log"; exit 0' TERM INT
echo "Ready for long-running operation"
# Wait indefinitely
while true; do
sleep 1
echo "Still running..."
done
`;
const scriptPath = `${dir}/long_running.sh`;
await Deno.writeTextFile(scriptPath, scriptContent);
await Deno.chmod(scriptPath, 0o755); // Make executable
return scriptPath;
}
// Setup a minimal HttpServer for testing
async function setupTestServer(
tempDir: string,
): Promise<{ server: HttpServer; port: number }> {
// Create minimal asset bundles
const clientAssetBundle = new AssetBundle();
const plugAssetBundle = new AssetBundle();
// Create a memory KV store
const kvPrimitives = new MemoryKvPrimitives();
// Find an available port
const port = 9000 + Math.floor(Math.random() * 1000);
// Create server options
const options: ServerOptions = {
hostname: "localhost",
port,
clientAssetBundle,
plugAssetBundle,
baseKvPrimitives: kvPrimitives,
pagesPath: tempDir,
shellBackend: "local", // Use local shell backend
readOnly: false,
indexPage: "index",
enableSpaceScript: false,
};
// Create and start the server
const server = new HttpServer(options);
await server.start();
return { server, port };
}
// Helper function to create a script that fails with an error
async function createErrorScript(dir: string): Promise<string> {
const scriptContent = `#!/bin/sh
echo "This is stdout before the error"
echo "This is stderr output" >&2
exit 1
`;
const scriptPath = `${dir}/error_script.sh`;
await Deno.writeTextFile(scriptPath, scriptContent);
await Deno.chmod(scriptPath, 0o755); // Make executable
return scriptPath;
}
// Run all tests in a single test function to avoid resource tracking issues
Deno.test("Shell endpoint tests", async () => {
// Test 1: Basic functionality with POST endpoint
{
// Test: Basic functionality with POST endpoint
// Create a temporary directory for testing
const tempDir = await Deno.makeTempDir();
try {
// Set up test server
const { server, port } = await setupTestServer(tempDir);
try {
// Create a client with HttpSpacePrimitives
const httpSpacePrimitives = new HttpSpacePrimitives(
`http://localhost:${port}`,
);
const client = new ShellStreamClient({
httpSpacePrimitives,
cmd: "echo",
args: ["Hello, World!"],
});
// Collect events
const events: ShellStreamEvent[] = [];
client.addEventListener("stdout", (event) => events.push(event));
client.addEventListener("stderr", (event) => events.push(event));
client.addEventListener("exit", (event) => events.push(event));
// Connect to the server
await client.start();
// Wait for output and exit
await sleep(1000);
// Close the connection and wait for cleanup
client.close();
await sleep(500);
// Verify events
assert(events.length >= 1, "Expected at least 1 event");
// Should have stdout message
const stdoutEvent = events.find((e) =>
e.type === "stdout" &&
typeof e.data === "string" &&
e.data.includes("Hello, World!")
);
assertExists(stdoutEvent, "Stdout event not found");
} finally {
// Clean up server
server.stop();
}
} finally {
// Clean up temporary directory
await Deno.remove(tempDir, { recursive: true });
}
}
// Cleanup on premature WebSocket close
{
// Test: Cleanup on premature WebSocket close
// Create a temporary directory for testing
const tempDir = await Deno.makeTempDir();
try {
// Create a long-running test script
const scriptPath = await createLongRunningScript(tempDir);
// Set up test server
const { server, port } = await setupTestServer(tempDir);
try {
// Create a client with HttpSpacePrimitives
const httpSpacePrimitives = new HttpSpacePrimitives(
`http://localhost:${port}`,
);
const client = new ShellStreamClient({
httpSpacePrimitives,
cmd: scriptPath,
args: [],
});
// Collect events
const events: ShellStreamEvent[] = [];
client.addEventListener("stdout", (event) => events.push(event));
client.addEventListener("stderr", (event) => events.push(event));
client.addEventListener("exit", (event) => events.push(event));
// Connect to the server
await client.start();
// Wait for the script to start
await sleep(1000);
// Verify the process is running by checking if the PID file exists
const pidFileExists = await Deno.stat(`${tempDir}/process.pid`)
.then(
() => true,
() => false,
);
assert(pidFileExists, "Process PID file should exist");
// Close the WebSocket connection prematurely
client.close();
// Wait for cleanup to happen
await sleep(2000);
// Check if the cleanup log file was created, indicating proper termination
const cleanupFileExists = await Deno.stat(`${tempDir}/cleanup.log`)
.then(
() => true,
() => false,
);
assert(
cleanupFileExists,
"Cleanup log file should exist, indicating proper process termination",
);
if (cleanupFileExists) {
const cleanupContent = await Deno.readTextFile(
`${tempDir}/cleanup.log`,
);
assert(
cleanupContent.includes("Process terminated properly"),
"Process should have been terminated properly",
);
}
// Verify events
const startupEvent = events.find((e) =>
e.type === "stdout" &&
typeof e.data === "string" &&
e.data.includes("Starting long-running script")
);
assertExists(startupEvent, "Startup message not found");
} finally {
// Clean up server
server.stop();
}
} finally {
// Clean up temporary directory
await Deno.remove(tempDir, { recursive: true });
}
}
// Unified send method with POST endpoint
{
// Test: Unified send method with POST endpoint
// Create a temporary directory for testing
const tempDir = await Deno.makeTempDir();
try {
// Create a test script
const scriptPath = await createTestScript(tempDir);
// Set up test server
const { server, port } = await setupTestServer(tempDir);
try {
// Create a client with HttpSpacePrimitives
const httpSpacePrimitives = new HttpSpacePrimitives(
`http://localhost:${port}`,
);
const client = new ShellStreamClient({
httpSpacePrimitives,
cmd: scriptPath,
args: [],
});
// Collect events
const events: ShellStreamEvent[] = [];
client.addEventListener("stdout", (event) => events.push(event));
client.addEventListener("stderr", (event) => events.push(event));
client.addEventListener("exit", (event) => events.push(event));
// Connect to the server
await client.start();
// Wait for initial output
await sleep(500);
// Send input using the unified send() method
client.send("Hello from test");
// Wait for response and exit
await sleep(1000);
// Close the connection and wait for cleanup
client.close();
await sleep(500);
// Verify events
assert(events.length >= 3, "Expected at least 3 events");
// Should have stdout message
const stdoutEvent = events.find((e) =>
e.type === "stdout" &&
typeof e.data === "string" &&
e.data.includes("Hello from test script")
);
assertExists(stdoutEvent, "Stdout event not found");
// Should have stderr message
const stderrEvent = events.find((e) =>
e.type === "stderr" &&
typeof e.data === "string" &&
e.data.includes("This is stderr output")
);
assertExists(stderrEvent, "Stderr event not found");
// Should have response to input
const responseEvent = events.find((e) =>
e.type === "stdout" &&
typeof e.data === "string" &&
e.data.includes("You said: Hello from test")
);
assertExists(responseEvent, "Response event not found");
} finally {
// Clean up server
server.stop();
}
} finally {
// Clean up temporary directory
await Deno.remove(tempDir, { recursive: true });
}
}
// Send signal to process
{
// Test: Send signal to process
// Create a temporary directory for testing
const tempDir = await Deno.makeTempDir();
try {
// Create a signal-handling test script
const scriptPath = await createSignalTestScript(tempDir);
// Set up test server
const { server, port } = await setupTestServer(tempDir);
try {
// Create a client with HttpSpacePrimitives
const httpSpacePrimitives = new HttpSpacePrimitives(
`http://localhost:${port}`,
);
const client = new ShellStreamClient({
httpSpacePrimitives,
cmd: scriptPath,
args: [],
});
// Collect events
const events: ShellStreamEvent[] = [];
client.addEventListener("stdout", (event) => events.push(event));
client.addEventListener("stderr", (event) => events.push(event));
client.addEventListener("exit", (event) => events.push(event));
// Connect to the server
await client.start();
// Wait for the script to start and set up signal handlers
await sleep(1000);
// Send a signal to the process
client.kill("SIGTERM");
// Wait for the process to handle the signal and exit
await sleep(1000);
// Close the connection and wait for cleanup
client.close();
await sleep(500);
// Verify events
assert(events.length >= 3, "Expected at least 3 events");
// Should have initial stdout messages
const startupEvent = events.find((e) =>
e.type === "stdout" &&
typeof e.data === "string" &&
e.data.includes("Starting signal test script")
);
assertExists(startupEvent, "Startup message not found");
// Should have signal handler message
const signalEvent = events.find((e) =>
e.type === "stdout" &&
typeof e.data === "string" &&
e.data.includes("Received SIGTERM")
);
assertExists(signalEvent, "Signal handler message not found");
// Should have exit event
const exitEvent = events.find((e) => e.type === "exit");
assertExists(exitEvent, "Exit event not found");
} finally {
// Clean up server
server.stop();
}
} finally {
// Clean up temporary directory
await Deno.remove(tempDir, { recursive: true });
}
}
// .shell command with arguments
{
// Test: Command with arguments
// Create a temporary directory for testing
const tempDir = await Deno.makeTempDir();
try {
// Create a test script
const scriptPath = await createTestScript(tempDir);
// Set up test server
const { server, port } = await setupTestServer(tempDir);
try {
// Create HTTP space primitives for making requests
const httpSpacePrimitives = new HttpSpacePrimitives(
`http://localhost:${port}`,
);
// Test command with arguments
const response = await httpSpacePrimitives.authenticatedFetch(
`${httpSpacePrimitives.url}/.shell`,
{
method: "POST",
body: JSON.stringify({
cmd: scriptPath,
args: [],
}),
},
);
const result = await response.json();
// Verify response
assertEquals(result.code, 0, "Exit code should be 0");
assert(
result.stdout.includes("Hello from test script"),
"Output should contain the script output",
);
assert(
result.stderr.includes("This is stderr output"),
"Stderr should contain the error output",
);
} finally {
// Clean up server
server.stop();
}
} finally {
// Clean up temporary directory
await Deno.remove(tempDir, { recursive: true });
}
}
// Error handling
{
// Test: Error handling
// Create a temporary directory for testing
const tempDir = await Deno.makeTempDir();
try {
// Create an error script
const scriptPath = await createErrorScript(tempDir);
// Set up test server
const { server, port } = await setupTestServer(tempDir);
try {
// Create HTTP space primitives for making requests
const httpSpacePrimitives = new HttpSpacePrimitives(
`http://localhost:${port}`,
);
// Test command that fails
const response = await httpSpacePrimitives.authenticatedFetch(
`${httpSpacePrimitives.url}/.shell`,
{
method: "POST",
body: JSON.stringify({
cmd: scriptPath,
args: [],
}),
},
);
const result = await response.json();
// Verify response
assertEquals(result.code, 1, "Exit code should be 1");
assert(
result.stdout.includes("This is stdout before the error"),
"Output should contain the stdout text",
);
assert(
result.stderr.includes("This is stderr output"),
"Stderr should contain the error output",
);
} finally {
// Clean up server
server.stop();
}
} finally {
// Clean up temporary directory
await Deno.remove(tempDir, { recursive: true });
}
}
// Read-only mode
{
// Test: Read-only mode
// Create a temporary directory for testing
const tempDir = await Deno.makeTempDir();
try {
// Set up test server with read-only mode
const { server, port } = await setupTestServer(tempDir);
// Set server to read-only mode
server.spaceServer.readOnly = true;
try {
// Create HTTP space primitives for making requests
const httpSpacePrimitives = new HttpSpacePrimitives(
`http://localhost:${port}`,
);
// Test command in read-only mode
const response = await httpSpacePrimitives.authenticatedFetch(
`${httpSpacePrimitives.url}/.shell`,
{
method: "POST",
body: JSON.stringify({
cmd: "echo",
args: ["Hello, World!"],
}),
},
);
// Verify response status is 405 Method Not Allowed
assertEquals(
response.status,
405,
"Status should be 405 Method Not Allowed",
);
const text = await response.text();
assert(
text.includes("Read only mode"),
"Response should mention read-only mode",
);
} finally {
// Clean up server
server.stop();
}
} finally {
// Clean up temporary directory
await Deno.remove(tempDir, { recursive: true });
}
}
});
+209
View File
@@ -1,5 +1,6 @@
import type { ShellRequest, ShellResponse } from "../type/rpc.ts";
import type { ServerOptions } from "./http_server.ts";
import { timeout } from "../lib/async.ts";
/**
* Configuration via environment variables:
@@ -35,6 +36,214 @@ export class NotSupportedShell implements ShellBackend {
}
}
export class StreamingShell {
private process: Deno.ChildProcess | null = null;
private outputHandlers: Array<
(type: string, data: string) => void
> = [];
private textDecoder = new TextDecoder();
private textEncoder = new TextEncoder();
constructor(private cwd: string) {}
start(cmd: string, args: string[]): void {
console.log("Starting streaming shell:", cmd, args);
this.process = new Deno.Command(cmd, {
cwd: this.cwd,
args: args,
stdin: "piped",
stdout: "piped",
stderr: "piped",
}).spawn();
// Handle stdout
this.readStream(this.process.stdout, "stdout");
// Handle stderr
this.readStream(this.process.stderr, "stderr");
// Handle process exit
this.process.status.then((status) => {
this.notifyOutput(
"exit",
"json",
JSON.stringify({ code: status.code || 0 }),
);
// Process is already exited, just set to null
this.process = null;
});
}
private async readStream(
stream: ReadableStream<Uint8Array>,
type: string,
): Promise<void> {
const reader = stream.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
// Handle as text data (UTF-8)
const text = this.textDecoder.decode(value, { stream: true });
if (text.trim()) {
console.log(`${type.toUpperCase()}: ${text}`);
this.notifyOutput(type, "text", text);
}
}
} catch (e) {
const error = e as Error;
console.error(`Error reading ${type}:`, error);
this.notifyOutput("error", "text", `Stream error: ${error.message}`);
} finally {
reader.releaseLock();
}
}
async writeToStdin(data: string): Promise<void> {
if (!this.process || !this.process.stdin) {
throw new Error("Process not started or stdin not available");
}
const writer = this.process.stdin.getWriter();
try {
// Add newline for text data
await writer.write(this.textEncoder.encode(data + "\n"));
} finally {
writer.releaseLock();
}
}
onOutput(
handler: (type: string, data: string) => void,
): void {
this.outputHandlers.push(handler);
}
private notifyOutput(type: string, _format: string, data: string): void {
for (const handler of this.outputHandlers) {
handler(type, data);
}
}
/**
* Send a signal to the process
* @param signal The signal to send (e.g., "SIGTERM", "SIGINT", "SIGKILL")
* @returns True if the signal was sent successfully, false otherwise
*/
sendSignal(signal: string): boolean {
if (!this.process) {
console.error("Cannot send signal: Process not started");
return false;
}
try {
// Convert signal string to Deno.Signal enum
let denoSignal: Deno.Signal;
switch (signal.toUpperCase()) {
case "SIGINT":
denoSignal = "SIGINT";
break;
case "SIGTERM":
denoSignal = "SIGTERM";
break;
case "SIGKILL":
denoSignal = "SIGKILL";
break;
case "SIGHUP":
denoSignal = "SIGHUP";
break;
default:
console.error(`Unsupported signal: ${signal}`);
return false;
}
this.process.kill(denoSignal);
console.log(`Sent ${signal} to process`);
return true;
} catch (e) {
console.error(`Error sending ${signal} to process:`, e);
return false;
}
}
async close(): Promise<void> {
if (this.process) {
try {
// First try to send SIGTERM to allow graceful shutdown
try {
console.log("Sending SIGTERM to process");
this.process.kill("SIGTERM");
} catch (e) {
console.error("Error sending SIGTERM to process:", e);
}
// Close stdin if it's available
if (this.process.stdin) {
try {
// Create a writer to check if the stream is writable
const writer = this.process.stdin.getWriter();
writer.releaseLock(); // Release immediately to avoid locking
// If we got here, the stream is still open, so close it
this.process.stdin.close();
} catch (e) {
// If we get an error, the stream might already be closed or errored
// Just log it and continue
console.log("Note: stdin already closed or errored:", e);
}
}
// Wait for the process to exit with a short timeout
try {
const status = await Promise.race([
this.process.status,
timeout(500).then(() => {
throw new Error("Process exit timeout after SIGTERM");
}),
]);
console.log("Process exited with code:", status.code);
this.process = null;
return;
} catch (e) {
console.log("Process did not exit after SIGTERM:", e);
}
// If we're here, the process didn't exit after SIGTERM
// Try SIGKILL as a last resort
if (this.process) { // Check if process is still not null
try {
console.log("Sending SIGKILL to process");
this.process.kill("SIGKILL");
// Wait again with a longer timeout
const status = await Promise.race([
this.process.status,
timeout(500).then(() => {
throw new Error("Process exit timeout after SIGKILL");
}),
]);
console.log("Process exited with code after SIGKILL:", status.code);
} catch (e) {
console.error(
"Error waiting for process to exit after SIGKILL:",
e,
);
}
}
} catch (e) {
console.error("Error closing process:", e);
}
// Always set process to null to avoid memory leaks
this.process = null;
}
}
}
export class LocalShell implements ShellBackend {
constructor(private cwd: string) {
}
+125
View File
@@ -0,0 +1,125 @@
import type { Context } from "hono";
import type { ShellRequest } from "@silverbulletmd/silverbullet/type/rpc";
import type { ShellBackend } from "./shell_backend.ts";
import { StreamingShell } from "./shell_backend.ts";
/**
* Handles the /.shell endpoint for non-streaming shell commands
*/
export async function handleShellEndpoint(
c: Context,
shellBackend: ShellBackend,
readOnly: boolean,
): Promise<Response> {
const req = c.req;
const body = await req.json();
try {
if (readOnly) {
return c.text("Read only mode, no shell commands allowed", 405);
}
const shellCommand: ShellRequest = body;
// Note: in read-only this is set to NoShellSupport, so don't worry
const shellResponse = await shellBackend.handle(shellCommand);
return c.json(shellResponse);
} catch (e: any) {
console.error("Shell error", e);
return c.text(e.message, 500);
}
}
/**
* Handles the /.shell/stream endpoint for streaming shell commands
*/
export function handleShellStreamEndpoint(
c: Context,
cwd: string,
readOnly: boolean,
): Response {
const req = c.req;
const url = new URL(req.url);
// Check if read-only mode is enabled
if (readOnly) {
return c.text("Read only mode, no shell commands allowed", 405);
}
// Get command and arguments from query parameters
const cmd = url.searchParams.get("cmd");
if (!cmd) {
return c.text("Missing cmd parameter", 400);
}
let args: string[] = [];
const argsParam = url.searchParams.get("args");
if (argsParam) {
try {
args = JSON.parse(argsParam);
if (!Array.isArray(args)) {
return c.text("args must be a JSON array", 400);
}
} catch (_e) {
return c.text("Invalid args parameter: must be a JSON array", 400);
}
}
// Create a streaming shell
const shell = new StreamingShell(cwd);
// Upgrade the connection to a WebSocket
try {
const { socket, response } = Deno.upgradeWebSocket(req.raw);
// Set up WebSocket event handlers
socket.onopen = () => {
// Start the shell process when the WebSocket is opened
shell.start(cmd, args);
// Forward shell output to the WebSocket
shell.onOutput((type, data) => {
socket.send(JSON.stringify({
type,
data,
}));
});
};
socket.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === "stdin") {
await shell.writeToStdin(message.data);
} else if (message.type === "signal") {
const success = shell.sendSignal(message.signal);
if (!success) {
socket.send(JSON.stringify({
type: "error",
data: `Failed to send signal: ${message.signal}`,
}));
}
}
} catch (e) {
console.error("Error handling WebSocket message:", e);
socket.send(JSON.stringify({
type: "error",
data: `Error: ${e}`,
}));
}
};
socket.onclose = async () => {
await shell.close();
};
socket.onerror = (error) => {
console.error("WebSocket error:", error);
shell.close().catch((e) => {
console.error("Error closing shell:", e);
});
};
return response;
} catch (e) {
console.error("WebSocket upgrade failed:", e);
return c.text("WebSocket upgrade failed", 400);
}
}
+37
View File
@@ -1,5 +1,26 @@
import type { SysCallMapping } from "../../lib/plugos/system.ts";
import type { Client } from "../client.ts";
import { ShellStreamClient } from "../../common/shell_stream_client.ts";
/**
* Interface for the shell stream client
*/
export interface ShellStream {
/**
* Send data to the process stdin
*/
send(data: string): void;
/**
* Send a signal to the process
*/
kill(signal: string): void;
/**
* Close the connection
*/
close(): void;
}
export function shellSyscalls(
client: Client,
@@ -29,5 +50,21 @@ export function shellSyscalls(
}
return { code, stderr, stdout };
},
"shell.spawn": (
_ctx,
cmd: string,
args: string[],
): ShellStreamClient => {
if (!client.httpSpacePrimitives) {
throw new Error("Not supported in fully local mode");
}
// Create a shell stream client
return new ShellStreamClient({
httpSpacePrimitives: client.httpSpacePrimitives,
cmd,
args: args || [],
});
},
};
}