terminal-commands 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,163 @@
1
+ import { z } from "zod";
2
+
3
+ export interface DeviceAuthConfig {
4
+ issuer: string;
5
+ clientId: string;
6
+ audience: string;
7
+ scope: string;
8
+ }
9
+
10
+ export interface StoredCredentials {
11
+ accessToken: string;
12
+ refreshToken?: string;
13
+ expiresAt: number;
14
+ scope?: string;
15
+ tokenType?: string;
16
+ issuer: string;
17
+ audience: string;
18
+ }
19
+
20
+ export type FetchLike = (
21
+ input: string | URL | Request,
22
+ init?: RequestInit,
23
+ ) => Promise<Response>;
24
+
25
+ const deviceCodeSchema = z.object({
26
+ device_code: z.string().min(1),
27
+ user_code: z.string().min(1),
28
+ verification_uri: z.string().url(),
29
+ verification_uri_complete: z.string().url().optional(),
30
+ expires_in: z.number().int().positive(),
31
+ interval: z.number().int().positive().default(5),
32
+ });
33
+
34
+ const tokenSchema = z.object({
35
+ access_token: z.string().min(1),
36
+ refresh_token: z.string().min(1).optional(),
37
+ expires_in: z.number().int().positive(),
38
+ scope: z.string().optional(),
39
+ token_type: z.string().optional(),
40
+ });
41
+
42
+ function endpoint(issuer: string, path: string): string {
43
+ return new URL(path, issuer.endsWith("/") ? issuer : `${issuer}/`).toString();
44
+ }
45
+
46
+ async function postForm(
47
+ url: string,
48
+ values: Record<string, string>,
49
+ fetchImpl: FetchLike,
50
+ ): Promise<Response> {
51
+ return fetchImpl(url, {
52
+ method: "POST",
53
+ headers: { "content-type": "application/x-www-form-urlencoded" },
54
+ body: new URLSearchParams(values),
55
+ });
56
+ }
57
+
58
+ export async function requestDeviceCode(
59
+ config: DeviceAuthConfig,
60
+ fetchImpl: FetchLike = fetch,
61
+ ) {
62
+ const response = await postForm(
63
+ endpoint(config.issuer, "oauth/device/code"),
64
+ {
65
+ client_id: config.clientId,
66
+ audience: config.audience,
67
+ scope: config.scope,
68
+ },
69
+ fetchImpl,
70
+ );
71
+ if (!response.ok)
72
+ throw new Error(`Device login could not start (${response.status}).`);
73
+ return deviceCodeSchema.parse(await response.json());
74
+ }
75
+
76
+ export async function pollForDeviceToken(
77
+ config: DeviceAuthConfig,
78
+ deviceCode: string,
79
+ expiresInSeconds: number,
80
+ intervalSeconds: number,
81
+ signal?: AbortSignal,
82
+ fetchImpl: FetchLike = fetch,
83
+ ): Promise<StoredCredentials> {
84
+ const deadline = Date.now() + expiresInSeconds * 1_000;
85
+ let intervalMs = Math.max(intervalSeconds, 1) * 1_000;
86
+
87
+ while (Date.now() < deadline) {
88
+ await new Promise<void>((resolve, reject) => {
89
+ const timer = setTimeout(resolve, intervalMs);
90
+ signal?.addEventListener(
91
+ "abort",
92
+ () => {
93
+ clearTimeout(timer);
94
+ reject(new Error("Login cancelled."));
95
+ },
96
+ { once: true },
97
+ );
98
+ });
99
+
100
+ const response = await postForm(
101
+ endpoint(config.issuer, "oauth/token"),
102
+ {
103
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
104
+ device_code: deviceCode,
105
+ client_id: config.clientId,
106
+ },
107
+ fetchImpl,
108
+ );
109
+ const body = (await response.json()) as Record<string, unknown>;
110
+ if (response.ok) {
111
+ const token = tokenSchema.parse(body);
112
+ return {
113
+ accessToken: token.access_token,
114
+ refreshToken: token.refresh_token,
115
+ expiresAt: Date.now() + token.expires_in * 1_000,
116
+ scope: token.scope,
117
+ tokenType: token.token_type,
118
+ issuer: config.issuer,
119
+ audience: config.audience,
120
+ };
121
+ }
122
+ if (body.error === "authorization_pending") continue;
123
+ if (body.error === "slow_down") {
124
+ intervalMs += 5_000;
125
+ continue;
126
+ }
127
+ if (body.error === "expired_token") break;
128
+ throw new Error(
129
+ `Device login failed: ${String(body.error_description ?? body.error ?? response.status)}`,
130
+ );
131
+ }
132
+ throw new Error("Device login expired. Run login again.");
133
+ }
134
+
135
+ export async function refreshDeviceToken(
136
+ config: DeviceAuthConfig,
137
+ refreshToken: string,
138
+ fetchImpl: FetchLike = fetch,
139
+ ): Promise<StoredCredentials> {
140
+ const response = await postForm(
141
+ endpoint(config.issuer, "oauth/token"),
142
+ {
143
+ grant_type: "refresh_token",
144
+ client_id: config.clientId,
145
+ refresh_token: refreshToken,
146
+ },
147
+ fetchImpl,
148
+ );
149
+ if (!response.ok)
150
+ throw new Error(
151
+ "Saved login expired. Run `terminal-commands login` again.",
152
+ );
153
+ const token = tokenSchema.parse(await response.json());
154
+ return {
155
+ accessToken: token.access_token,
156
+ refreshToken: token.refresh_token ?? refreshToken,
157
+ expiresAt: Date.now() + token.expires_in * 1_000,
158
+ scope: token.scope,
159
+ tokenType: token.token_type,
160
+ issuer: config.issuer,
161
+ audience: config.audience,
162
+ };
163
+ }
package/src/index.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
2
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import type { Request, Response } from "express";
4
+
5
+ import { createServer } from "./server.js";
6
+
7
+ const host = process.env.MACHINE_TERMINAL_HOST ?? "127.0.0.1";
8
+ const port = Number.parseInt(process.env.MACHINE_TERMINAL_PORT ?? "3333", 10);
9
+ const localHosts = new Set(["127.0.0.1", "::1", "localhost"]);
10
+
11
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
12
+ throw new Error("MACHINE_TERMINAL_PORT must be a valid TCP port.");
13
+ }
14
+ if (
15
+ !localHosts.has(host) &&
16
+ process.env.MACHINE_TERMINAL_ALLOW_PUBLIC_BIND !== "1"
17
+ ) {
18
+ throw new Error(
19
+ "Refusing a non-loopback bind. Set MACHINE_TERMINAL_ALLOW_PUBLIC_BIND=1 only behind authenticated HTTPS.",
20
+ );
21
+ }
22
+
23
+ const app = createMcpExpressApp({ host });
24
+
25
+ app.get("/health", (_request: Request, response: Response) => {
26
+ response.json({
27
+ status: "ok",
28
+ server: "terminal-commands",
29
+ version: "0.1.0",
30
+ });
31
+ });
32
+
33
+ app.post("/mcp", async (request: Request, response: Response) => {
34
+ const server = createServer();
35
+ const transport = new StreamableHTTPServerTransport({
36
+ sessionIdGenerator: undefined,
37
+ });
38
+ response.on("close", () => {
39
+ void transport.close();
40
+ void server.close();
41
+ });
42
+
43
+ try {
44
+ await server.connect(transport);
45
+ await transport.handleRequest(request, response, request.body);
46
+ } catch (error) {
47
+ console.error("MCP request failed", error);
48
+ if (!response.headersSent) {
49
+ response.status(500).json({
50
+ jsonrpc: "2.0",
51
+ error: { code: -32603, message: "Internal server error" },
52
+ id: null,
53
+ });
54
+ }
55
+ }
56
+ });
57
+
58
+ for (const method of ["get", "delete"] as const) {
59
+ app[method]("/mcp", (_request: Request, response: Response) => {
60
+ response.status(405).json({
61
+ jsonrpc: "2.0",
62
+ error: { code: -32000, message: "Method not allowed" },
63
+ id: null,
64
+ });
65
+ });
66
+ }
67
+
68
+ const listener = app.listen(port, host, () => {
69
+ console.log(`Terminal Commands MCP listening at http://${host}:${port}/mcp`);
70
+ });
71
+
72
+ function shutdown(signal: string) {
73
+ console.log(`Received ${signal}; shutting down.`);
74
+ listener.close((error?: Error) => {
75
+ if (error) {
76
+ console.error(error);
77
+ process.exitCode = 1;
78
+ }
79
+ });
80
+ }
81
+
82
+ process.on("SIGINT", () => shutdown("SIGINT"));
83
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
package/src/runner.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ import { assertProgramAllowed, safeEnvironment } from "./security.js";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ export interface ProgramResult {
9
+ exitCode: number | null;
10
+ signal: string | null;
11
+ stdout: string;
12
+ stderr: string;
13
+ timedOut: boolean;
14
+ }
15
+
16
+ export async function runProgram(options: {
17
+ program: string;
18
+ args: string[];
19
+ cwd: string;
20
+ timeoutMs: number;
21
+ maxOutputChars: number;
22
+ }): Promise<ProgramResult> {
23
+ assertProgramAllowed(options.program);
24
+
25
+ try {
26
+ const { stdout, stderr } = await execFileAsync(
27
+ options.program,
28
+ options.args,
29
+ {
30
+ cwd: options.cwd,
31
+ env: safeEnvironment(),
32
+ encoding: "utf8",
33
+ maxBuffer: options.maxOutputChars,
34
+ timeout: options.timeoutMs,
35
+ windowsHide: true,
36
+ },
37
+ );
38
+ return { exitCode: 0, signal: null, stdout, stderr, timedOut: false };
39
+ } catch (error) {
40
+ const failure = error as NodeJS.ErrnoException & {
41
+ code?: number | string;
42
+ killed?: boolean;
43
+ signal?: string;
44
+ stdout?: string;
45
+ stderr?: string;
46
+ };
47
+ return {
48
+ exitCode: typeof failure.code === "number" ? failure.code : null,
49
+ signal: failure.signal ?? null,
50
+ stdout: failure.stdout ?? "",
51
+ stderr: failure.stderr ?? failure.message,
52
+ timedOut: failure.killed === true && failure.signal === "SIGTERM",
53
+ };
54
+ }
55
+ }
@@ -0,0 +1,112 @@
1
+ import { constants } from "node:fs";
2
+ import { access, lstat, realpath } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ const DEFAULT_ENVIRONMENT_KEYS = [
7
+ "LANG",
8
+ "LC_ALL",
9
+ "LC_CTYPE",
10
+ "PATH",
11
+ "SHELL",
12
+ "TERM",
13
+ "TMPDIR",
14
+ "USER",
15
+ ] as const;
16
+
17
+ const DEFAULT_BLOCKED_PROGRAMS = new Set([
18
+ "bash",
19
+ "cmd",
20
+ "cmd.exe",
21
+ "fish",
22
+ "powershell",
23
+ "powershell.exe",
24
+ "pwsh",
25
+ "sh",
26
+ "sudo",
27
+ "su",
28
+ "zsh",
29
+ ]);
30
+
31
+ export function configuredRoots(
32
+ raw = process.env.MACHINE_TERMINAL_ROOTS,
33
+ ): string[] {
34
+ const candidates = raw?.split(path.delimiter).filter(Boolean) ?? [
35
+ process.cwd(),
36
+ ];
37
+ return [...new Set(candidates.map((candidate) => path.resolve(candidate)))];
38
+ }
39
+
40
+ export function isWithinRoot(candidate: string, root: string): boolean {
41
+ const relative = path.relative(root, candidate);
42
+ return (
43
+ relative === "" ||
44
+ (!relative.startsWith(`..${path.sep}`) && relative !== "..")
45
+ );
46
+ }
47
+
48
+ export async function resolveReadablePath(
49
+ requestedPath: string,
50
+ roots: string[],
51
+ cwd = roots[0] ?? os.homedir(),
52
+ ): Promise<string> {
53
+ const resolvedRoots = await Promise.all(roots.map((root) => realpath(root)));
54
+ const candidate = await realpath(path.resolve(cwd, requestedPath));
55
+ if (!resolvedRoots.some((root) => isWithinRoot(candidate, root))) {
56
+ throw new Error("Path is outside MACHINE_TERMINAL_ROOTS.");
57
+ }
58
+ await access(candidate, constants.R_OK);
59
+ return candidate;
60
+ }
61
+
62
+ export async function resolveWritablePath(
63
+ requestedPath: string,
64
+ roots: string[],
65
+ cwd = roots[0] ?? os.homedir(),
66
+ ): Promise<string> {
67
+ const absolute = path.resolve(cwd, requestedPath);
68
+ const resolvedParent = await realpath(path.dirname(absolute));
69
+ const resolvedRoots = await Promise.all(roots.map((root) => realpath(root)));
70
+ const candidate = path.join(resolvedParent, path.basename(absolute));
71
+ if (!resolvedRoots.some((root) => isWithinRoot(candidate, root))) {
72
+ throw new Error("Path is outside MACHINE_TERMINAL_ROOTS.");
73
+ }
74
+ try {
75
+ const stats = await lstat(candidate);
76
+ if (stats.isSymbolicLink()) {
77
+ throw new Error("Writing through symbolic links is not allowed.");
78
+ }
79
+ } catch (error) {
80
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
81
+ }
82
+ return candidate;
83
+ }
84
+
85
+ export function safeEnvironment(
86
+ source: NodeJS.ProcessEnv = process.env,
87
+ ): NodeJS.ProcessEnv {
88
+ return Object.fromEntries(
89
+ DEFAULT_ENVIRONMENT_KEYS.flatMap((key) =>
90
+ source[key] === undefined ? [] : [[key, source[key]]],
91
+ ),
92
+ );
93
+ }
94
+
95
+ export function assertProgramAllowed(program: string): void {
96
+ const name = path.basename(program).toLowerCase();
97
+ const allowShell = process.env.MACHINE_TERMINAL_ALLOW_SHELL === "1";
98
+ if (!allowShell && DEFAULT_BLOCKED_PROGRAMS.has(name)) {
99
+ throw new Error(
100
+ `Program '${name}' is blocked by default. Set MACHINE_TERMINAL_ALLOW_SHELL=1 to allow shells and privilege tools.`,
101
+ );
102
+ }
103
+
104
+ const allowlist = process.env.MACHINE_TERMINAL_ALLOWED_PROGRAMS?.split(",")
105
+ .map((value) => value.trim().toLowerCase())
106
+ .filter(Boolean);
107
+ if (allowlist?.length && !allowlist.includes(name)) {
108
+ throw new Error(
109
+ `Program '${name}' is not in MACHINE_TERMINAL_ALLOWED_PROGRAMS.`,
110
+ );
111
+ }
112
+ }
package/src/server.ts ADDED
@@ -0,0 +1,217 @@
1
+ import { readFile, readdir, writeFile } from "node:fs/promises";
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { z } from "zod";
5
+
6
+ import { runProgram } from "./runner.js";
7
+ import {
8
+ configuredRoots,
9
+ resolveReadablePath,
10
+ resolveWritablePath,
11
+ } from "./security.js";
12
+
13
+ const roots = configuredRoots();
14
+
15
+ function textResult(value: unknown) {
16
+ return {
17
+ content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
18
+ structuredContent: value as Record<string, unknown>,
19
+ };
20
+ }
21
+
22
+ export function createServer(): McpServer {
23
+ const server = new McpServer(
24
+ { name: "terminal-commands", version: "0.1.0" },
25
+ {
26
+ instructions:
27
+ "This server controls the user's machine. Prefer dedicated file tools over commands. Every command and write can modify or expose local data and must receive user approval. Never request secrets or bypass configured roots and safety controls.",
28
+ },
29
+ );
30
+
31
+ server.registerTool(
32
+ "get_system_info",
33
+ {
34
+ title: "Get machine information",
35
+ description:
36
+ "Return basic platform information and the configured filesystem roots.",
37
+ inputSchema: {},
38
+ outputSchema: {
39
+ platform: z.string(),
40
+ architecture: z.string(),
41
+ nodeVersion: z.string(),
42
+ roots: z.array(z.string()),
43
+ },
44
+ annotations: {
45
+ readOnlyHint: true,
46
+ destructiveHint: false,
47
+ openWorldHint: false,
48
+ },
49
+ },
50
+ async () =>
51
+ textResult({
52
+ platform: process.platform,
53
+ architecture: process.arch,
54
+ nodeVersion: process.version,
55
+ roots,
56
+ }),
57
+ );
58
+
59
+ server.registerTool(
60
+ "list_directory",
61
+ {
62
+ title: "List a directory",
63
+ description:
64
+ "List immediate entries in a directory inside the configured roots.",
65
+ inputSchema: { path: z.string().min(1) },
66
+ outputSchema: {
67
+ path: z.string(),
68
+ entries: z.array(
69
+ z.object({
70
+ name: z.string(),
71
+ type: z.enum(["directory", "file", "other"]),
72
+ }),
73
+ ),
74
+ truncated: z.boolean(),
75
+ },
76
+ annotations: {
77
+ readOnlyHint: true,
78
+ destructiveHint: false,
79
+ openWorldHint: false,
80
+ },
81
+ },
82
+ async ({ path: requestedPath }) => {
83
+ const directory = await resolveReadablePath(requestedPath, roots);
84
+ const entries = await readdir(directory, { withFileTypes: true });
85
+ return textResult({
86
+ path: directory,
87
+ entries: entries.slice(0, 1000).map((entry) => ({
88
+ name: entry.name,
89
+ type: entry.isDirectory()
90
+ ? "directory"
91
+ : entry.isFile()
92
+ ? "file"
93
+ : "other",
94
+ })),
95
+ truncated: entries.length > 1000,
96
+ });
97
+ },
98
+ );
99
+
100
+ server.registerTool(
101
+ "read_text_file",
102
+ {
103
+ title: "Read a text file",
104
+ description:
105
+ "Read a UTF-8 text file inside the configured roots, with a bounded result size.",
106
+ inputSchema: {
107
+ path: z.string().min(1),
108
+ maxCharacters: z.number().int().min(1).max(200_000).default(50_000),
109
+ },
110
+ outputSchema: {
111
+ path: z.string(),
112
+ contents: z.string(),
113
+ truncated: z.boolean(),
114
+ },
115
+ annotations: {
116
+ readOnlyHint: true,
117
+ destructiveHint: false,
118
+ openWorldHint: false,
119
+ },
120
+ },
121
+ async ({ path: requestedPath, maxCharacters }) => {
122
+ const filename = await resolveReadablePath(requestedPath, roots);
123
+ const contents = await readFile(filename, "utf8");
124
+ return textResult({
125
+ path: filename,
126
+ contents: contents.slice(0, maxCharacters),
127
+ truncated: contents.length > maxCharacters,
128
+ });
129
+ },
130
+ );
131
+
132
+ server.registerTool(
133
+ "write_text_file",
134
+ {
135
+ title: "Write a text file",
136
+ description:
137
+ "Create or overwrite a UTF-8 text file inside the configured roots.",
138
+ inputSchema: {
139
+ path: z.string().min(1),
140
+ contents: z.string().max(500_000),
141
+ overwrite: z.boolean().default(false),
142
+ },
143
+ outputSchema: {
144
+ path: z.string(),
145
+ bytesWritten: z.number().int().nonnegative(),
146
+ },
147
+ annotations: {
148
+ readOnlyHint: false,
149
+ destructiveHint: true,
150
+ openWorldHint: false,
151
+ },
152
+ },
153
+ async ({ path: requestedPath, contents, overwrite }) => {
154
+ const filename = await resolveWritablePath(requestedPath, roots);
155
+ await writeFile(filename, contents, {
156
+ encoding: "utf8",
157
+ flag: overwrite ? "w" : "wx",
158
+ });
159
+ return textResult({
160
+ path: filename,
161
+ bytesWritten: Buffer.byteLength(contents),
162
+ });
163
+ },
164
+ );
165
+
166
+ server.registerTool(
167
+ "run_program",
168
+ {
169
+ title: "Run a local program",
170
+ description:
171
+ "Run one executable without a shell. Shells and privilege-elevation programs are blocked unless explicitly enabled by local configuration.",
172
+ inputSchema: {
173
+ program: z.string().min(1),
174
+ args: z.array(z.string()).max(100).default([]),
175
+ cwd: z.string().min(1).optional(),
176
+ timeoutMs: z.number().int().min(100).max(120_000).default(30_000),
177
+ maxOutputCharacters: z
178
+ .number()
179
+ .int()
180
+ .min(1_000)
181
+ .max(200_000)
182
+ .default(50_000),
183
+ },
184
+ outputSchema: {
185
+ program: z.string(),
186
+ args: z.array(z.string()),
187
+ cwd: z.string(),
188
+ exitCode: z.number().int().nullable(),
189
+ signal: z.string().nullable(),
190
+ stdout: z.string(),
191
+ stderr: z.string(),
192
+ timedOut: z.boolean(),
193
+ },
194
+ annotations: {
195
+ readOnlyHint: false,
196
+ destructiveHint: true,
197
+ openWorldHint: true,
198
+ },
199
+ },
200
+ async ({ program, args, cwd, timeoutMs, maxOutputCharacters }) => {
201
+ const workingDirectory = await resolveReadablePath(
202
+ cwd ?? roots[0]!,
203
+ roots,
204
+ );
205
+ const result = await runProgram({
206
+ program,
207
+ args,
208
+ cwd: workingDirectory,
209
+ timeoutMs,
210
+ maxOutputChars: maxOutputCharacters,
211
+ });
212
+ return textResult({ program, args, cwd: workingDirectory, ...result });
213
+ },
214
+ );
215
+
216
+ return server;
217
+ }
package/src/stdio.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
+
3
+ import { createServer } from "./server.js";
4
+
5
+ const server = createServer();
6
+ const transport = new StdioServerTransport();
7
+
8
+ await server.connect(transport);
9
+
10
+ async function shutdown() {
11
+ await server.close();
12
+ process.exit(0);
13
+ }
14
+
15
+ process.on("SIGINT", () => void shutdown());
16
+ process.on("SIGTERM", () => void shutdown());
@@ -0,0 +1,32 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { loadAgentConfig } from "../src/agent-config.js";
5
+
6
+ const required = {
7
+ MACHINE_TERMINAL_DEVICE_TOKEN: "test-token",
8
+ MACHINE_TERMINAL_DEVICE_ID: "test-device",
9
+ };
10
+
11
+ test("accepts a secure gateway URL", () => {
12
+ const config = loadAgentConfig({
13
+ ...required,
14
+ MACHINE_TERMINAL_GATEWAY_URL: "wss://mcp.example.com/device/connect",
15
+ });
16
+ assert.equal(config.MACHINE_TERMINAL_AGENT_ALLOW_DESTRUCTIVE, "0");
17
+ });
18
+
19
+ test("allows unencrypted WebSocket only on loopback", () => {
20
+ assert.doesNotThrow(() =>
21
+ loadAgentConfig({
22
+ ...required,
23
+ MACHINE_TERMINAL_GATEWAY_URL: "ws://127.0.0.1:3400/device/connect",
24
+ }),
25
+ );
26
+ assert.throws(() =>
27
+ loadAgentConfig({
28
+ ...required,
29
+ MACHINE_TERMINAL_GATEWAY_URL: "ws://mcp.example.com/device/connect",
30
+ }),
31
+ );
32
+ });