From d61873e8daa5547f7ad39286ca988eede48eb83f Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sat, 12 Jan 2019 13:44:29 -0600 Subject: [PATCH] Add commands (#2) * Add remote command execution * Add tests for environment variables and resize * Fix tab spacing, add newlines * Remove extra newline * Add fork --- packages/server/package.json | 3 + packages/server/scripts/generate_proto.sh | 0 packages/server/src/browser/client.ts | 104 +++++++++++++++-- packages/server/src/browser/command.ts | 91 +++++++++++++++ packages/server/src/node/command.ts | 105 +++++++++++++++++ packages/server/src/node/server.ts | 43 ++++++- packages/server/src/proto/client.proto | 46 ++++---- packages/server/src/proto/client_pb.d.ts | 11 +- packages/server/src/proto/client_pb.js | 73 +++++++++--- packages/server/src/proto/command.proto | 67 +++++------ packages/server/src/proto/command_pb.d.ts | 12 ++ packages/server/src/proto/command_pb.js | 97 ++++++++++++++-- packages/server/src/proto/node.proto | 46 ++++---- packages/server/test/command.test.ts | 132 ++++++++++++++++++++++ packages/server/test/forker.js | 1 + packages/server/yarn.lock | 22 ++++ 16 files changed, 744 insertions(+), 109 deletions(-) mode change 100644 => 100755 packages/server/scripts/generate_proto.sh create mode 100644 packages/server/src/browser/command.ts create mode 100644 packages/server/src/node/command.ts create mode 100644 packages/server/test/command.test.ts create mode 100755 packages/server/test/forker.js diff --git a/packages/server/package.json b/packages/server/package.json index 1e732a1c3..840b6b111 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -2,11 +2,14 @@ "name": "server", "dependencies": { "express": "^4.16.4", + "node-pty": "^0.8.0", "ws": "^6.1.2" }, "devDependencies": { "@types/express": "^4.16.0", + "@types/text-encoding": "^0.0.35", "@types/ws": "^6.0.1", + "text-encoding": "^0.7.0", "ts-protoc-gen": "^0.8.0" } } diff --git a/packages/server/scripts/generate_proto.sh b/packages/server/scripts/generate_proto.sh old mode 100644 new mode 100755 diff --git a/packages/server/src/browser/client.ts b/packages/server/src/browser/client.ts index 99f5ec99e..fa96e5073 100644 --- a/packages/server/src/browser/client.ts +++ b/packages/server/src/browser/client.ts @@ -1,15 +1,17 @@ import { ReadWriteConnection } from "../common/connection"; -import { NewEvalMessage, ServerMessage, EvalDoneMessage, EvalFailedMessage, TypedValue, ClientMessage } from "../proto"; +import { NewEvalMessage, ServerMessage, EvalDoneMessage, EvalFailedMessage, TypedValue, ClientMessage, NewSessionMessage, TTYDimensions, SessionOutputMessage, CloseSessionInputMessage } from "../proto"; import { Emitter } from "@coder/events"; import { logger, field } from "@coder/logger"; - +import { ChildProcess, SpawnOptions, ServerProcess } from "./command"; export class Client { - private evalId: number = 0; private evalDoneEmitter: Emitter = new Emitter(); private evalFailedEmitter: Emitter = new Emitter(); + private sessionId: number = 0; + private sessions: Map = new Map(); + public constructor( private readonly connection: ReadWriteConnection, ) { @@ -86,20 +88,108 @@ export class Client { if (failedMsg.getId() === id) { d1.dispose(); d2.dispose(); - + rej(failedMsg.getMessage()); } }); - + return prom; } - + + /** + * Spawns a process from a command. _Somewhat_ reflects the "child_process" API. + * @param command + * @param args Arguments + * @param options Options to execute for the command + */ + public spawn(command: string, args: string[] = [], options?: SpawnOptions): ChildProcess { + return this.doSpawn(command, args, options, false); + } + + /** + * Fork a module. + * @param modulePath Path of the module + * @param args Args to add for the module + * @param options Options to execute + */ + public fork(modulePath: string, args: string[] = [], options?: SpawnOptions): ChildProcess { + return this.doSpawn(modulePath, args, options, true); + } + + private doSpawn(command: string, args: string[] = [], options?: SpawnOptions, isFork: boolean = false): ChildProcess { + const id = this.sessionId++; + const newSess = new NewSessionMessage(); + newSess.setId(id); + newSess.setCommand(command); + newSess.setArgsList(args); + newSess.setIsFork(isFork); + if (options) { + if (options.cwd) { + newSess.setCwd(options.cwd); + } + if (options.env) { + Object.keys(options.env).forEach((envKey) => { + newSess.getEnvMap().set(envKey, options.env![envKey]); + }); + } + if (options.tty) { + const tty = new TTYDimensions(); + tty.setHeight(options.tty.rows); + tty.setWidth(options.tty.columns); + newSess.setTtyDimensions(tty); + } + } + const clientMsg = new ClientMessage(); + clientMsg.setNewSession(newSess); + this.connection.send(clientMsg.serializeBinary()); + + const serverProc = new ServerProcess(this.connection, id, options ? options.tty !== undefined : false); + serverProc.stdin.on("close", () => { + console.log("stdin closed"); + const c = new CloseSessionInputMessage(); + c.setId(id); + const cm = new ClientMessage(); + cm.setCloseSessionInput(c); + this.connection.send(cm.serializeBinary()); + }); + this.sessions.set(id, serverProc); + return serverProc; + } + private handleMessage(message: ServerMessage): void { if (message.hasEvalDone()) { this.evalDoneEmitter.emit(message.getEvalDone()!); } else if (message.hasEvalFailed()) { this.evalFailedEmitter.emit(message.getEvalFailed()!); + } else if (message.hasNewSessionFailure()) { + const s = this.sessions.get(message.getNewSessionFailure()!.getId()); + if (!s) { + return; + } + s.emit("error", new Error(message.getNewSessionFailure()!.getMessage())); + this.sessions.delete(message.getNewSessionFailure()!.getId()); + } else if (message.hasSessionDone()) { + const s = this.sessions.get(message.getSessionDone()!.getId()); + if (!s) { + return; + } + s.emit("exit", message.getSessionDone()!.getExitStatus()); + this.sessions.delete(message.getSessionDone()!.getId()); + } else if (message.hasSessionOutput()) { + const output = message.getSessionOutput()!; + const s = this.sessions.get(output.getId()); + if (!s) { + return; + } + const data = new TextDecoder().decode(output.getData_asU8()); + const stream = output.getFd() === SessionOutputMessage.FD.STDOUT ? s.stdout : s.stderr; + stream.emit("data", data); + } else if (message.hasIdentifySession()) { + const s = this.sessions.get(message.getIdentifySession()!.getId()); + if (!s) { + return; + } + s.pid = message.getIdentifySession()!.getPid(); } } - } diff --git a/packages/server/src/browser/command.ts b/packages/server/src/browser/command.ts new file mode 100644 index 000000000..48b32368b --- /dev/null +++ b/packages/server/src/browser/command.ts @@ -0,0 +1,91 @@ +import * as events from "events"; +import * as stream from "stream"; +import { SendableConnection } from "../common/connection"; +import { ShutdownSessionMessage, ClientMessage, SessionOutputMessage, WriteToSessionMessage, ResizeSessionTTYMessage, TTYDimensions as ProtoTTYDimensions } from "../proto"; + +export interface TTYDimensions { + readonly columns: number; + readonly rows: number; +} + +export interface SpawnOptions { + cwd?: string; + env?: { readonly [key: string]: string }; + tty?: TTYDimensions; +} + +export interface ChildProcess { + readonly stdin: stream.Writable; + readonly stdout: stream.Readable; + readonly stderr: stream.Readable; + + readonly killed?: boolean; + readonly pid: number | undefined; + + kill(signal?: string): void; + send(message: string | Uint8Array): void; + + on(event: "error", listener: (err: Error) => void): void; + on(event: "exit", listener: (code: number, signal: string) => void): void; + + resize?(dimensions: TTYDimensions): void; +} + +export class ServerProcess extends events.EventEmitter implements ChildProcess { + public readonly stdin = new stream.Writable(); + public readonly stdout = new stream.Readable({ read: () => true }); + public readonly stderr = new stream.Readable({ read: () => true }); + public pid: number | undefined; + + private _killed: boolean = false; + + public constructor( + private readonly connection: SendableConnection, + private readonly id: number, + private readonly hasTty: boolean = false, + ) { + super(); + + if (!this.hasTty) { + delete this.resize; + } + } + + public get killed(): boolean { + return this._killed; + } + + public kill(signal?: string): void { + const kill = new ShutdownSessionMessage(); + kill.setId(this.id); + if (signal) { + kill.setSignal(signal); + } + const client = new ClientMessage(); + client.setShutdownSession(kill); + this.connection.send(client.serializeBinary()); + + this._killed = true; + } + + public send(message: string | Uint8Array): void { + const send = new WriteToSessionMessage(); + send.setId(this.id); + send.setData(typeof message === "string" ? new TextEncoder().encode(message) : message); + const client = new ClientMessage(); + client.setWriteToSession(send); + this.connection.send(client.serializeBinary()); + } + + public resize(dimensions: TTYDimensions) { + const resize = new ResizeSessionTTYMessage(); + resize.setId(this.id); + const tty = new ProtoTTYDimensions(); + tty.setHeight(dimensions.rows); + tty.setWidth(dimensions.columns); + resize.setTtyDimensions(tty); + const client = new ClientMessage(); + client.setResizeSessionTty(resize); + this.connection.send(client.serializeBinary()); + } +} diff --git a/packages/server/src/node/command.ts b/packages/server/src/node/command.ts new file mode 100644 index 000000000..c51ee2488 --- /dev/null +++ b/packages/server/src/node/command.ts @@ -0,0 +1,105 @@ +import * as cp from "child_process"; +import * as nodePty from "node-pty"; +import * as stream from "stream"; +import { TextEncoder } from "text-encoding"; +import { NewSessionMessage, ServerMessage, SessionDoneMessage, SessionOutputMessage, ShutdownSessionMessage, IdentifySessionMessage, ClientMessage } from "../proto"; +import { SendableConnection } from "../common/connection"; + +export interface Process { + stdin?: stream.Writable; + stdout?: stream.Readable; + stderr?: stream.Readable; + + pid: number; + killed?: boolean; + + on(event: "data", cb: (data: string) => void): void; + on(event: 'exit', listener: (exitCode: number, signal?: number) => void): void; + write(data: string | Uint8Array): void; + resize?(cols: number, rows: number): void; + kill(signal?: string): void; + title?: number; +} + +export const handleNewSession = (connection: SendableConnection, newSession: NewSessionMessage, onExit: () => void): Process => { + let process: Process; + + const env = {} as any; + newSession.getEnvMap().forEach((value: any, key: any) => { + env[key] = value; + }); + if (newSession.getTtyDimensions()) { + // Spawn with node-pty + process = nodePty.spawn(newSession.getCommand(), newSession.getArgsList(), { + cols: newSession.getTtyDimensions()!.getWidth(), + rows: newSession.getTtyDimensions()!.getHeight(), + cwd: newSession.getCwd(), + env, + }); + } else { + const options = { + cwd: newSession.getCwd(), + env, + }; + let proc: cp.ChildProcess; + if (newSession.getIsFork()) { + proc = cp.fork(newSession.getCommand(), newSession.getArgsList()); + } else { + proc = cp.spawn(newSession.getCommand(), newSession.getArgsList(), options); + } + + process = { + stdin: proc.stdin, + stderr: proc.stderr, + stdout: proc.stdout, + on: (...args: any[]) => (proc.on)(...args), + write: (d) => proc.stdin.write(d), + kill: (s) => proc.kill(s || "SIGTERM"), + pid: proc.pid, + }; + } + + const sendOutput = (fd: SessionOutputMessage.FD, msg: string | Uint8Array): void => { + const serverMsg = new ServerMessage(); + const d = new SessionOutputMessage(); + d.setId(newSession.getId()); + d.setData(typeof msg === "string" ? new TextEncoder().encode(msg) : msg); + d.setFd(SessionOutputMessage.FD.STDOUT); + serverMsg.setSessionOutput(d); + connection.send(serverMsg.serializeBinary()); + }; + + if (process.stdout && process.stderr) { + process.stdout.on("data", (data) => { + sendOutput(SessionOutputMessage.FD.STDOUT, data); + }); + + process.stderr.on("data", (data) => { + sendOutput(SessionOutputMessage.FD.STDERR, data); + }); + } else { + process.on("data", (data) => { + sendOutput(SessionOutputMessage.FD.STDOUT, Buffer.from(data)); + }); + } + + const id = new IdentifySessionMessage(); + id.setId(newSession.getId()); + id.setPid(process.pid); + const sm = new ServerMessage(); + sm.setIdentifySession(id); + connection.send(sm.serializeBinary()); + + process.on("exit", (code, signal) => { + const serverMsg = new ServerMessage(); + const exit = new SessionDoneMessage(); + exit.setId(newSession.getId()); + exit.setExitStatus(code); + serverMsg.setSessionDone(exit); + connection.send(serverMsg.serializeBinary()); + + onExit(); + }); + + return process; +}; diff --git a/packages/server/src/node/server.ts b/packages/server/src/node/server.ts index 6d6762617..004d58600 100644 --- a/packages/server/src/node/server.ts +++ b/packages/server/src/node/server.ts @@ -1,13 +1,19 @@ import { logger, field } from "@coder/logger"; +import { TextDecoder } from "text-encoding"; import { ClientMessage } from "../proto"; import { evaluate } from "./evaluate"; import { ReadWriteConnection } from "../common/connection"; +import { Process, handleNewSession } from "./command"; export class Server { + private readonly sessions: Map; + public constructor( private readonly connection: ReadWriteConnection, ) { + this.sessions = new Map(); + connection.onMessage((data) => { try { this.handleMessage(ClientMessage.deserializeBinary(data)); @@ -20,7 +26,42 @@ export class Server { private handleMessage(message: ClientMessage): void { if (message.hasNewEval()) { evaluate(this.connection, message.getNewEval()!); + } else if (message.hasNewSession()) { + const session = handleNewSession(this.connection, message.getNewSession()!, () => { + this.sessions.delete(message.getNewSession()!.getId()); + }); + + this.sessions.set(message.getNewSession()!.getId(), session); + } else if (message.hasCloseSessionInput()) { + const s = this.getSession(message.getCloseSessionInput()!.getId()); + if (!s || !s.stdin) { + return; + } + s.stdin.end(); + } else if (message.hasResizeSessionTty()) { + const s = this.getSession(message.getResizeSessionTty()!.getId()); + if (!s || !s.resize) { + return; + } + const tty = message.getResizeSessionTty()!.getTtyDimensions()!; + s.resize(tty.getWidth(), tty.getHeight()); + } else if (message.hasShutdownSession()) { + const s = this.getSession(message.getShutdownSession()!.getId()); + if (!s) { + return; + } + s.kill(message.getShutdownSession()!.getSignal()); + } else if (message.hasWriteToSession()) { + const s = this.getSession(message.getWriteToSession()!.getId()); + if (!s) { + return; + } + s.write(new TextDecoder().decode(message.getWriteToSession()!.getData_asU8())); } } -} \ No newline at end of file + private getSession(id: number): Process | undefined { + return this.sessions.get(id); + } + +} diff --git a/packages/server/src/proto/client.proto b/packages/server/src/proto/client.proto index 27f4671a4..e3229a444 100644 --- a/packages/server/src/proto/client.proto +++ b/packages/server/src/proto/client.proto @@ -3,29 +3,29 @@ import "command.proto"; import "node.proto"; message ClientMessage { - oneof msg { - // command.proto - NewSessionMessage new_session = 1; - ShutdownSessionMessage shutdown_session = 2; - WriteToSessionMessage write_to_session = 3; - CloseSessionInputMessage close_session_input = 4; - ResizeSessionTTYMessage resize_session_tty = 5; - - // node.proto - NewEvalMessage new_eval = 6; - - } + oneof msg { + // command.proto + NewSessionMessage new_session = 1; + ShutdownSessionMessage shutdown_session = 2; + WriteToSessionMessage write_to_session = 3; + CloseSessionInputMessage close_session_input = 4; + ResizeSessionTTYMessage resize_session_tty = 5; + + // node.proto + NewEvalMessage new_eval = 6; + } } message ServerMessage { - oneof msg { - // command.proto - NewSessionFailureMessage new_session_failure = 1; - SessionDoneMessage session_done = 2; - SessionOutputMessage session_output = 3; - - // node.proto - EvalFailedMessage eval_failed = 4; - EvalDoneMessage eval_done = 5; - } -} \ No newline at end of file + oneof msg { + // command.proto + NewSessionFailureMessage new_session_failure = 1; + SessionDoneMessage session_done = 2; + SessionOutputMessage session_output = 3; + IdentifySessionMessage identify_session = 4; + + // node.proto + EvalFailedMessage eval_failed = 5; + EvalDoneMessage eval_done = 6; + } +} diff --git a/packages/server/src/proto/client_pb.d.ts b/packages/server/src/proto/client_pb.d.ts index 206850259..0e3939b33 100644 --- a/packages/server/src/proto/client_pb.d.ts +++ b/packages/server/src/proto/client_pb.d.ts @@ -84,6 +84,11 @@ export class ServerMessage extends jspb.Message { getSessionOutput(): command_pb.SessionOutputMessage | undefined; setSessionOutput(value?: command_pb.SessionOutputMessage): void; + hasIdentifySession(): boolean; + clearIdentifySession(): void; + getIdentifySession(): command_pb.IdentifySessionMessage | undefined; + setIdentifySession(value?: command_pb.IdentifySessionMessage): void; + hasEvalFailed(): boolean; clearEvalFailed(): void; getEvalFailed(): node_pb.EvalFailedMessage | undefined; @@ -110,6 +115,7 @@ export namespace ServerMessage { newSessionFailure?: command_pb.NewSessionFailureMessage.AsObject, sessionDone?: command_pb.SessionDoneMessage.AsObject, sessionOutput?: command_pb.SessionOutputMessage.AsObject, + identifySession?: command_pb.IdentifySessionMessage.AsObject, evalFailed?: node_pb.EvalFailedMessage.AsObject, evalDone?: node_pb.EvalDoneMessage.AsObject, } @@ -119,8 +125,9 @@ export namespace ServerMessage { NEW_SESSION_FAILURE = 1, SESSION_DONE = 2, SESSION_OUTPUT = 3, - EVAL_FAILED = 4, - EVAL_DONE = 5, + IDENTIFY_SESSION = 4, + EVAL_FAILED = 5, + EVAL_DONE = 6, } } diff --git a/packages/server/src/proto/client_pb.js b/packages/server/src/proto/client_pb.js index 4eb515f00..d25cf80e7 100644 --- a/packages/server/src/proto/client_pb.js +++ b/packages/server/src/proto/client_pb.js @@ -465,7 +465,7 @@ if (goog.DEBUG && !COMPILED) { * @private {!Array>} * @const */ -proto.ServerMessage.oneofGroups_ = [[1,2,3,4,5]]; +proto.ServerMessage.oneofGroups_ = [[1,2,3,4,5,6]]; /** * @enum {number} @@ -475,8 +475,9 @@ proto.ServerMessage.MsgCase = { NEW_SESSION_FAILURE: 1, SESSION_DONE: 2, SESSION_OUTPUT: 3, - EVAL_FAILED: 4, - EVAL_DONE: 5 + IDENTIFY_SESSION: 4, + EVAL_FAILED: 5, + EVAL_DONE: 6 }; /** @@ -517,6 +518,7 @@ proto.ServerMessage.toObject = function(includeInstance, msg) { newSessionFailure: (f = msg.getNewSessionFailure()) && command_pb.NewSessionFailureMessage.toObject(includeInstance, f), sessionDone: (f = msg.getSessionDone()) && command_pb.SessionDoneMessage.toObject(includeInstance, f), sessionOutput: (f = msg.getSessionOutput()) && command_pb.SessionOutputMessage.toObject(includeInstance, f), + identifySession: (f = msg.getIdentifySession()) && command_pb.IdentifySessionMessage.toObject(includeInstance, f), evalFailed: (f = msg.getEvalFailed()) && node_pb.EvalFailedMessage.toObject(includeInstance, f), evalDone: (f = msg.getEvalDone()) && node_pb.EvalDoneMessage.toObject(includeInstance, f) }; @@ -571,11 +573,16 @@ proto.ServerMessage.deserializeBinaryFromReader = function(msg, reader) { msg.setSessionOutput(value); break; case 4: + var value = new command_pb.IdentifySessionMessage; + reader.readMessage(value,command_pb.IdentifySessionMessage.deserializeBinaryFromReader); + msg.setIdentifySession(value); + break; + case 5: var value = new node_pb.EvalFailedMessage; reader.readMessage(value,node_pb.EvalFailedMessage.deserializeBinaryFromReader); msg.setEvalFailed(value); break; - case 5: + case 6: var value = new node_pb.EvalDoneMessage; reader.readMessage(value,node_pb.EvalDoneMessage.deserializeBinaryFromReader); msg.setEvalDone(value); @@ -642,18 +649,26 @@ proto.ServerMessage.prototype.serializeBinaryToWriter = function (writer) { command_pb.SessionOutputMessage.serializeBinaryToWriter ); } - f = this.getEvalFailed(); + f = this.getIdentifySession(); if (f != null) { writer.writeMessage( 4, f, + command_pb.IdentifySessionMessage.serializeBinaryToWriter + ); + } + f = this.getEvalFailed(); + if (f != null) { + writer.writeMessage( + 5, + f, node_pb.EvalFailedMessage.serializeBinaryToWriter ); } f = this.getEvalDone(); if (f != null) { writer.writeMessage( - 5, + 6, f, node_pb.EvalDoneMessage.serializeBinaryToWriter ); @@ -761,18 +776,48 @@ proto.ServerMessage.prototype.hasSessionOutput = function() { /** - * optional EvalFailedMessage eval_failed = 4; + * optional IdentifySessionMessage identify_session = 4; + * @return {proto.IdentifySessionMessage} + */ +proto.ServerMessage.prototype.getIdentifySession = function() { + return /** @type{proto.IdentifySessionMessage} */ ( + jspb.Message.getWrapperField(this, command_pb.IdentifySessionMessage, 4)); +}; + + +/** @param {proto.IdentifySessionMessage|undefined} value */ +proto.ServerMessage.prototype.setIdentifySession = function(value) { + jspb.Message.setOneofWrapperField(this, 4, proto.ServerMessage.oneofGroups_[0], value); +}; + + +proto.ServerMessage.prototype.clearIdentifySession = function() { + this.setIdentifySession(undefined); +}; + + +/** + * Returns whether this field is set. + * @return{!boolean} + */ +proto.ServerMessage.prototype.hasIdentifySession = function() { + return jspb.Message.getField(this, 4) != null; +}; + + +/** + * optional EvalFailedMessage eval_failed = 5; * @return {proto.EvalFailedMessage} */ proto.ServerMessage.prototype.getEvalFailed = function() { return /** @type{proto.EvalFailedMessage} */ ( - jspb.Message.getWrapperField(this, node_pb.EvalFailedMessage, 4)); + jspb.Message.getWrapperField(this, node_pb.EvalFailedMessage, 5)); }; /** @param {proto.EvalFailedMessage|undefined} value */ proto.ServerMessage.prototype.setEvalFailed = function(value) { - jspb.Message.setOneofWrapperField(this, 4, proto.ServerMessage.oneofGroups_[0], value); + jspb.Message.setOneofWrapperField(this, 5, proto.ServerMessage.oneofGroups_[0], value); }; @@ -786,23 +831,23 @@ proto.ServerMessage.prototype.clearEvalFailed = function() { * @return{!boolean} */ proto.ServerMessage.prototype.hasEvalFailed = function() { - return jspb.Message.getField(this, 4) != null; + return jspb.Message.getField(this, 5) != null; }; /** - * optional EvalDoneMessage eval_done = 5; + * optional EvalDoneMessage eval_done = 6; * @return {proto.EvalDoneMessage} */ proto.ServerMessage.prototype.getEvalDone = function() { return /** @type{proto.EvalDoneMessage} */ ( - jspb.Message.getWrapperField(this, node_pb.EvalDoneMessage, 5)); + jspb.Message.getWrapperField(this, node_pb.EvalDoneMessage, 6)); }; /** @param {proto.EvalDoneMessage|undefined} value */ proto.ServerMessage.prototype.setEvalDone = function(value) { - jspb.Message.setOneofWrapperField(this, 5, proto.ServerMessage.oneofGroups_[0], value); + jspb.Message.setOneofWrapperField(this, 6, proto.ServerMessage.oneofGroups_[0], value); }; @@ -816,7 +861,7 @@ proto.ServerMessage.prototype.clearEvalDone = function() { * @return{!boolean} */ proto.ServerMessage.prototype.hasEvalDone = function() { - return jspb.Message.getField(this, 5) != null; + return jspb.Message.getField(this, 6) != null; }; diff --git a/packages/server/src/proto/command.proto b/packages/server/src/proto/command.proto index f7f744aed..f83403a40 100644 --- a/packages/server/src/proto/command.proto +++ b/packages/server/src/proto/command.proto @@ -6,70 +6,73 @@ syntax = "proto3"; // If env is provided, the environment variables will be set. // If tty_dimensions is included, we will spawn a tty for the command using the given dimensions. message NewSessionMessage { - uint64 id = 1; - string command = 2; - repeated string args = 3; - map env = 4; - TTYDimensions tty_dimensions = 5; + uint64 id = 1; + string command = 2; + repeated string args = 3; + map env = 4; + string cwd = 5; + TTYDimensions tty_dimensions = 6; + bool is_fork = 7; } // Sent when starting a session failed. message NewSessionFailureMessage { - uint64 id = 1; - enum Reason { - Prohibited = 0; - ResourceShortage = 1; - } - Reason reason = 2; - string message = 3; + uint64 id = 1; + enum Reason { + Prohibited = 0; + ResourceShortage = 1; + } + Reason reason = 2; + string message = 3; } // Sent when a session has completed message SessionDoneMessage { - uint64 id = 1; - int64 exit_status = 2; + uint64 id = 1; + int64 exit_status = 2; } // Identifies a session with a PID. message IdentifySessionMessage { - uint64 id = 1; - uint64 pid = 2; + uint64 id = 1; + uint64 pid = 2; } // Writes data to a session. message WriteToSessionMessage { - uint64 id = 1; - bytes data = 2; + uint64 id = 1; + bytes data = 2; } // Resizes the TTY of the session identified by the id. // The connection will be closed if a TTY was not requested when the session was created. message ResizeSessionTTYMessage { - uint64 id = 1; - TTYDimensions tty_dimensions = 2; + uint64 id = 1; + TTYDimensions tty_dimensions = 2; } // CloseSessionInputMessage closes the stdin of the session by the ID. message CloseSessionInputMessage { - uint64 id = 1; + uint64 id = 1; } message ShutdownSessionMessage { - uint64 id = 1; + uint64 id = 1; + string signal = 2; } // SessionOutputMessage carries data read from the stdout or stderr of the session identified by the id. message SessionOutputMessage { - uint64 id = 1; - enum FD { - Stdout = 0; - Stderr = 1; - } - FD fd = 2; - bytes data = 3; + uint64 id = 1; + enum FD { + Stdout = 0; + Stderr = 1; + } + FD fd = 2; + bytes data = 3; } message TTYDimensions { - uint32 height = 1; - uint32 width = 2; -} \ No newline at end of file + uint32 height = 1; + uint32 width = 2; +} diff --git a/packages/server/src/proto/command_pb.d.ts b/packages/server/src/proto/command_pb.d.ts index 0f459ac05..5c590b201 100644 --- a/packages/server/src/proto/command_pb.d.ts +++ b/packages/server/src/proto/command_pb.d.ts @@ -17,11 +17,17 @@ export class NewSessionMessage extends jspb.Message { getEnvMap(): jspb.Map; clearEnvMap(): void; + getCwd(): string; + setCwd(value: string): void; + hasTtyDimensions(): boolean; clearTtyDimensions(): void; getTtyDimensions(): TTYDimensions | undefined; setTtyDimensions(value?: TTYDimensions): void; + getIsFork(): boolean; + setIsFork(value: boolean): void; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): NewSessionMessage.AsObject; static toObject(includeInstance: boolean, msg: NewSessionMessage): NewSessionMessage.AsObject; @@ -38,7 +44,9 @@ export namespace NewSessionMessage { command: string, argsList: Array, envMap: Array<[string, string]>, + cwd: string, ttyDimensions?: TTYDimensions.AsObject, + isFork: boolean, } } @@ -199,6 +207,9 @@ export class ShutdownSessionMessage extends jspb.Message { getId(): number; setId(value: number): void; + getSignal(): string; + setSignal(value: string): void; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): ShutdownSessionMessage.AsObject; static toObject(includeInstance: boolean, msg: ShutdownSessionMessage): ShutdownSessionMessage.AsObject; @@ -212,6 +223,7 @@ export class ShutdownSessionMessage extends jspb.Message { export namespace ShutdownSessionMessage { export type AsObject = { id: number, + signal: string, } } diff --git a/packages/server/src/proto/command_pb.js b/packages/server/src/proto/command_pb.js index 95364861b..d6875979c 100644 --- a/packages/server/src/proto/command_pb.js +++ b/packages/server/src/proto/command_pb.js @@ -78,7 +78,9 @@ proto.NewSessionMessage.toObject = function(includeInstance, msg) { command: msg.getCommand(), argsList: jspb.Message.getField(msg, 3), envMap: (f = msg.getEnvMap(true)) ? f.toArray() : [], - ttyDimensions: (f = msg.getTtyDimensions()) && proto.TTYDimensions.toObject(includeInstance, f) + cwd: msg.getCwd(), + ttyDimensions: (f = msg.getTtyDimensions()) && proto.TTYDimensions.toObject(includeInstance, f), + isFork: msg.getIsFork() }; if (includeInstance) { @@ -135,10 +137,18 @@ proto.NewSessionMessage.deserializeBinaryFromReader = function(msg, reader) { }); break; case 5: + var value = /** @type {string} */ (reader.readString()); + msg.setCwd(value); + break; + case 6: var value = new proto.TTYDimensions; reader.readMessage(value,proto.TTYDimensions.deserializeBinaryFromReader); msg.setTtyDimensions(value); break; + case 7: + var value = /** @type {boolean} */ (reader.readBool()); + msg.setIsFork(value); + break; default: reader.skipField(); break; @@ -202,14 +212,28 @@ proto.NewSessionMessage.prototype.serializeBinaryToWriter = function (writer) { if (f && f.getLength() > 0) { f.serializeBinary(4, writer, jspb.BinaryWriter.prototype.writeString, jspb.BinaryWriter.prototype.writeString); } + f = this.getCwd(); + if (f.length > 0) { + writer.writeString( + 5, + f + ); + } f = this.getTtyDimensions(); if (f != null) { writer.writeMessage( - 5, + 6, f, proto.TTYDimensions.serializeBinaryToWriter ); } + f = this.getIsFork(); + if (f) { + writer.writeBool( + 7, + f + ); + } }; @@ -288,18 +312,33 @@ proto.NewSessionMessage.prototype.getEnvMap = function(opt_noLazyCreate) { /** - * optional TTYDimensions tty_dimensions = 5; + * optional string cwd = 5; + * @return {string} + */ +proto.NewSessionMessage.prototype.getCwd = function() { + return /** @type {string} */ (jspb.Message.getFieldProto3(this, 5, "")); +}; + + +/** @param {string} value */ +proto.NewSessionMessage.prototype.setCwd = function(value) { + jspb.Message.setField(this, 5, value); +}; + + +/** + * optional TTYDimensions tty_dimensions = 6; * @return {proto.TTYDimensions} */ proto.NewSessionMessage.prototype.getTtyDimensions = function() { return /** @type{proto.TTYDimensions} */ ( - jspb.Message.getWrapperField(this, proto.TTYDimensions, 5)); + jspb.Message.getWrapperField(this, proto.TTYDimensions, 6)); }; /** @param {proto.TTYDimensions|undefined} value */ proto.NewSessionMessage.prototype.setTtyDimensions = function(value) { - jspb.Message.setWrapperField(this, 5, value); + jspb.Message.setWrapperField(this, 6, value); }; @@ -313,7 +352,24 @@ proto.NewSessionMessage.prototype.clearTtyDimensions = function() { * @return{!boolean} */ proto.NewSessionMessage.prototype.hasTtyDimensions = function() { - return jspb.Message.getField(this, 5) != null; + return jspb.Message.getField(this, 6) != null; +}; + + +/** + * optional bool is_fork = 7; + * Note that Boolean fields may be set to 0/1 when serialized from a Java server. + * You should avoid comparisons like {@code val === true/false} in those cases. + * @return {boolean} + */ +proto.NewSessionMessage.prototype.getIsFork = function() { + return /** @type {boolean} */ (jspb.Message.getFieldProto3(this, 7, false)); +}; + + +/** @param {boolean} value */ +proto.NewSessionMessage.prototype.setIsFork = function(value) { + jspb.Message.setField(this, 7, value); }; @@ -1528,7 +1584,8 @@ proto.ShutdownSessionMessage.prototype.toObject = function(opt_includeInstance) */ proto.ShutdownSessionMessage.toObject = function(includeInstance, msg) { var f, obj = { - id: msg.getId() + id: msg.getId(), + signal: msg.getSignal() }; if (includeInstance) { @@ -1569,6 +1626,10 @@ proto.ShutdownSessionMessage.deserializeBinaryFromReader = function(msg, reader) var value = /** @type {number} */ (reader.readUint64()); msg.setId(value); break; + case 2: + var value = /** @type {string} */ (reader.readString()); + msg.setSignal(value); + break; default: reader.skipField(); break; @@ -1614,6 +1675,13 @@ proto.ShutdownSessionMessage.prototype.serializeBinaryToWriter = function (write f ); } + f = this.getSignal(); + if (f.length > 0) { + writer.writeString( + 2, + f + ); + } }; @@ -1641,6 +1709,21 @@ proto.ShutdownSessionMessage.prototype.setId = function(value) { }; +/** + * optional string signal = 2; + * @return {string} + */ +proto.ShutdownSessionMessage.prototype.getSignal = function() { + return /** @type {string} */ (jspb.Message.getFieldProto3(this, 2, "")); +}; + + +/** @param {string} value */ +proto.ShutdownSessionMessage.prototype.setSignal = function(value) { + jspb.Message.setField(this, 2, value); +}; + + /** * Generated by JsPbCodeGenerator. diff --git a/packages/server/src/proto/node.proto b/packages/server/src/proto/node.proto index 7f7309a4e..3a9449d5e 100644 --- a/packages/server/src/proto/node.proto +++ b/packages/server/src/proto/node.proto @@ -1,36 +1,36 @@ syntax = "proto3"; message TypedValue { - enum Type { - String = 0; - Number = 1; - Object = 2; - Boolean = 3; - } - Type type = 1; - string value = 2; + enum Type { + String = 0; + Number = 1; + Object = 2; + Boolean = 3; + } + Type type = 1; + string value = 2; } message NewEvalMessage { - uint64 id = 1; - string function = 2; - repeated string args = 3; - // Timeout in ms - uint32 timeout = 4; + uint64 id = 1; + string function = 2; + repeated string args = 3; + // Timeout in ms + uint32 timeout = 4; } message EvalFailedMessage { - uint64 id = 1; - enum Reason { - Timeout = 0; - Exception = 1; - Conflict = 2; - } - Reason reason = 2; - string message = 3; + uint64 id = 1; + enum Reason { + Timeout = 0; + Exception = 1; + Conflict = 2; + } + Reason reason = 2; + string message = 3; } message EvalDoneMessage { - uint64 id = 1; - TypedValue response = 2; + uint64 id = 1; + TypedValue response = 2; } diff --git a/packages/server/test/command.test.ts b/packages/server/test/command.test.ts new file mode 100644 index 000000000..6d0a5187c --- /dev/null +++ b/packages/server/test/command.test.ts @@ -0,0 +1,132 @@ +import * as path from "path"; +import { TextEncoder, TextDecoder } from "text-encoding"; +import { createClient } from "./helpers"; + +(global).TextDecoder = TextDecoder; +(global).TextEncoder = TextEncoder; + +describe("Command", () => { + const client = createClient(); + + it("should execute command and return output", (done) => { + const proc = client.spawn("echo", ["test"]); + proc.stdout.on("data", (data) => { + expect(data).toEqual("test\n"); + }); + proc.on("exit", (code) => { + done(); + }); + }); + + it("should create shell", (done) => { + const proc = client.spawn("/bin/bash", [], { + tty: { + columns: 100, + rows: 10, + }, + }); + let first = true; + proc.stdout.on("data", (data) => { + if (first) { + // First piece of data is a welcome msg. Second is the prompt + first = false; + return; + } + expect(data.toString().endsWith("$ ")).toBeTruthy(); + proc.kill(); + }); + proc.on("exit", () => done()); + }); + + it("should cat", (done) => { + const proc = client.spawn("cat", []); + expect(proc.pid).toBeUndefined(); + proc.stdout.on("data", (data) => { + expect(data).toEqual("banana"); + expect(proc.pid).toBeDefined(); + proc.kill(); + }); + proc.on("exit", () => done()); + proc.send("banana"); + proc.stdin.end(); + }); + + it("should print env variable", (done) => { + const proc = client.spawn("env", [], { + env: { hi: "donkey" }, + }); + proc.stdout.on("data", (data) => { + expect(data).toEqual("hi=donkey\n"); + done(); + }); + }); + + it("should resize", (done) => { + // Requires the `tput lines` cmd to be available + + const proc = client.spawn("/bin/bash", [], { + tty: { + columns: 10, + rows: 10, + }, + }); + let output: number = 0; // Number of outputs parsed + proc.stdout.on("data", (data) => { + output++; + + if (output === 1) { + // First is welcome msg + return; + } + + if (output === 2) { + proc.send("tput lines\n"); + return; + } + + if (output === 3) { + // Echo of tput lines + return; + } + + if (output === 4) { + expect(data.toString().trim()).toEqual("10"); + proc.resize!({ + columns: 10, + rows: 50, + }); + return; + } + + if (output === 5) { + // Primpt + return; + } + + if (output === 6) { + proc.send("tput lines\n"); + return; + } + + if (output === 7) { + // Echo of tput lines + return; + } + + if (output === 8) { + expect(data.toString().trim()).toEqual("50"); + proc.kill(); + expect(proc.killed).toBeTruthy(); + } + }); + proc.on("exit", () => done()); + }); + + it("should fork", (done) => { + const proc = client.fork(path.join(__dirname, "forker.js")); + proc.stdout.on("data", (data) => { + expect(data).toEqual("test"); + }); + proc.on("exit", () => done()); + }); +}); \ No newline at end of file diff --git a/packages/server/test/forker.js b/packages/server/test/forker.js new file mode 100755 index 000000000..02525d0fb --- /dev/null +++ b/packages/server/test/forker.js @@ -0,0 +1 @@ +console.log("test"); \ No newline at end of file diff --git a/packages/server/yarn.lock b/packages/server/yarn.lock index eac47b40c..2d3ca7179 100644 --- a/packages/server/yarn.lock +++ b/packages/server/yarn.lock @@ -63,6 +63,11 @@ "@types/express-serve-static-core" "*" "@types/mime" "*" +"@types/text-encoding@^0.0.35": + version "0.0.35" + resolved "https://registry.yarnpkg.com/@types/text-encoding/-/text-encoding-0.0.35.tgz#6f14474e0b232bc70c59677aadc65dcc5a99c3a9" + integrity sha512-jfo/A88XIiAweUa8np+1mPbm3h2w0s425YrI8t3wk5QxhH6UI7w517MboNVnGDeMSuoFwA8Rwmklno+FicvV4g== + "@types/ws@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-6.0.1.tgz#ca7a3f3756aa12f62a0a62145ed14c6db25d5a28" @@ -295,11 +300,23 @@ ms@2.0.0: resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= +nan@2.10.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f" + integrity sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA== + negotiator@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" integrity sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk= +node-pty@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-0.8.0.tgz#08bccb633f49e2e3f7245eb56ea6b40f37ccd64f" + integrity sha512-g5ggk3gN4gLrDmAllee5ScFyX3YzpOC/U8VJafha4pE7do0TIE1voiIxEbHSRUOPD1xYqmY+uHhOKAd3avbxGQ== + dependencies: + nan "2.10.0" + on-finished@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" @@ -399,6 +416,11 @@ statuses@~1.4.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087" integrity sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew== +text-encoding@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.7.0.tgz#f895e836e45990624086601798ea98e8f36ee643" + integrity sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA== + ts-protoc-gen@^0.8.0: version "0.8.0" resolved "https://registry.yarnpkg.com/ts-protoc-gen/-/ts-protoc-gen-0.8.0.tgz#2a9a31ee8a4d4760c484f1d0c7199633afaa5e3e"