From 7505be6b1cab944a423da03f7a355696284b6489 Mon Sep 17 00:00:00 2001 From: k Date: Sat, 22 Aug 2026 19:07:04 +0900 Subject: [PATCH 01/30] Add Docker Desktop runtime setup --- .dockerignore | 22 +++++++++ .env.example | 20 +++++++-- .gitignore | 5 +++ DOCKER.md | 110 +++++++++++++++++++++++++++++++++++++++++++++ Dockerfile | 46 +++++++++++++++++++ docker-compose.yml | 33 ++++++++++++++ workspace/.gitkeep | 1 + 7 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 DOCKER.md create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 workspace/.gitkeep diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..efc2aa4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +# Dependencies and generated output +node_modules/ +dist/ +coverage/ +.vitest/ +*.tsbuildinfo + +# Source-control and local configuration +.git/ +.env +.env.* +!.env.example +workspace/ +data/ +npm-debug.log +*.log + +# Local/editor files +.DS_Store +Thumbs.db +.idea/ +.vscode/ diff --git a/.env.example b/.env.example index 4122bde..8373847 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,20 @@ +# Docker Compose mount source. Keep this limited to a dedicated project directory. +WORKSPACE_PATH=./workspace + # Network MCP_HOST=0.0.0.0 MCP_PORT=3000 MCP_ENDPOINT=/mcp -MCP_PUBLIC_URL=https://mcp.example.com +MCP_PUBLIC_URL= +MCP_ALLOWED_HOSTS= MCP_TRUST_PROXY_HOPS=0 # Static bearer authentication. OAuth can instead use MCP_OAUTH_APPROVAL_KEY. MCP_AUTH_TOKEN=replace-with-a-long-random-token MCP_ALLOW_NO_AUTH=false -# Host execution. No sandbox, approval, command allowlist, or path restriction is applied. -MCP_DEFAULT_CWD=/root +# Container execution. No sandbox, approval, command allowlist, or path restriction is applied. +MCP_DEFAULT_CWD=/workspace MCP_DEFAULT_SHELL=/bin/bash # Operational limits for transport stability, not permission restrictions. @@ -21,3 +25,13 @@ MCP_PROCESS_RETENTION_MS=3600000 MCP_MAX_PROCESSES=128 MCP_MAX_FILE_CHUNK_BYTES=1048576 MCP_MAX_EDIT_FILE_BYTES=67108864 + +# Optional built-in OAuth 2.1 server. These values are required when enabled. +MCP_OAUTH_ENABLED=false +MCP_OAUTH_APPROVAL_KEY= +MCP_OAUTH_ISSUER= +MCP_OAUTH_RESOURCE= +MCP_OAUTH_STATE_FILE=/data/oauth-state.json +MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS=3600 +MCP_OAUTH_REFRESH_TOKEN_TTL_SECONDS=2592000 +MCP_OAUTH_AUTHORIZATION_CODE_TTL_SECONDS=300 diff --git a/.gitignore b/.gitignore index cc301bb..496e637 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,11 @@ coverage/ .tmp/ tmp/ temp/ +/data/ + +# Docker bind-mount workspace: retain the directory, ignore its contents. +/workspace/* +!/workspace/.gitkeep # Credentials and private keys *.pem diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..a29c5bd --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,110 @@ +# Running with Docker Desktop on macOS + +This setup runs `cokacremote` inside Docker Desktop. The MCP server can access +only the directory mounted at `/workspace`; it does not receive the Docker +socket, your SSH keys, or your macOS home directory. + +## Prerequisites + +- macOS +- Docker Desktop +- Git + +## Setup + +Create your local configuration and set a strong authentication token: + +```bash +cp .env.example .env +openssl rand -hex 32 +``` + +Put the generated value in `.env` as `MCP_AUTH_TOKEN=`. Do +not commit `.env`. + +The service executes commands as the non-root `node` user inside the +container. The image already includes Git, curl, wget, OpenSSH client, +Python 3, build tools, jq, and ripgrep for work performed in the mounted +workspace. + +## Build + +```bash +docker compose build +``` + +## Start + +```bash +docker compose up -d +``` + +## Status + +```bash +docker compose ps +``` + +## Logs + +```bash +docker compose logs -f cokacremote +``` + +## Stop + +```bash +docker compose down +``` + +The named `cokacremote-data` volume is intentionally retained by this command +so OAuth state survives a container replacement. Remove it only when you +intend to invalidate that state: + +```bash +docker compose down -v +``` + +## Rebuild + +```bash +docker compose build --no-cache +docker compose up -d +``` + +## Health check + +The HTTP port is bound to localhost only. With the default settings: + +```bash +curl -f http://127.0.0.1:3000/health +``` + +The MCP endpoint is `http://127.0.0.1:3000/mcp`. It requires the Bearer token +configured in `MCP_AUTH_TOKEN` unless built-in OAuth is enabled or the server +is deliberately configured for authenticated upstream access. + +## Workspace + +By default, the host directory `./workspace` is mounted at `/workspace` in the +container and is the MCP server's default working directory. Only +`workspace/.gitkeep` is tracked by this repository; files created during work +are ignored. + +To use an existing macOS project directory, set a specific path in `.env`: + +```dotenv +WORKSPACE_PATH=/Users/myname/Projects/ai-workspace +``` + +Do not mount your entire home directory, `/`, Docker Desktop's socket, system +directories, or SSH key directories. The container is intentionally not +privileged and publishes its HTTP port only on `127.0.0.1`. + +## OAuth state + +When `MCP_OAUTH_ENABLED=true`, set `MCP_PUBLIC_URL`, +`MCP_OAUTH_APPROVAL_KEY`, `MCP_OAUTH_ISSUER`, and `MCP_OAUTH_RESOURCE` to the +public HTTPS values for the future reverse-proxy or tunnel deployment. OAuth +state defaults to `/data/oauth-state.json`, which is backed by the named +Docker volume rather than the bind-mounted workspace. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3b681c1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +FROM node:22-bookworm-slim AS build + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build && npm prune --omit=dev + +FROM node:22-bookworm-slim + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + bash \ + build-essential \ + ca-certificates \ + curl \ + git \ + jq \ + openssh-client \ + python3 \ + python3-pip \ + ripgrep \ + wget \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=build --chown=node:node /app/package.json /app/package-lock.json ./ +COPY --from=build --chown=node:node /app/node_modules ./node_modules +COPY --from=build --chown=node:node /app/dist ./dist + +RUN mkdir /workspace /data \ + && chown node:node /workspace /data + +ENV NODE_ENV=production \ + MCP_DEFAULT_CWD=/workspace \ + MCP_OAUTH_STATE_FILE=/data/oauth-state.json + +USER node + +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..148bcad --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + cokacremote: + build: + context: . + dockerfile: Dockerfile + container_name: cokacremote + restart: unless-stopped + init: true + env_file: + - path: .env + required: false + environment: + MCP_HOST: ${MCP_HOST:-0.0.0.0} + MCP_PORT: ${MCP_PORT:-3000} + MCP_DEFAULT_CWD: ${MCP_DEFAULT_CWD:-/workspace} + MCP_OAUTH_STATE_FILE: ${MCP_OAUTH_STATE_FILE:-/data/oauth-state.json} + MCP_AUTH_TOKEN: ${MCP_AUTH_TOKEN:-} + ports: + - "127.0.0.1:${MCP_PORT:-3000}:${MCP_PORT:-3000}" + volumes: + - ${WORKSPACE_PATH:-./workspace}:/workspace + - cokacremote-data:/data + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:$${MCP_PORT:-3000}/health || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + security_opt: + - no-new-privileges:true + +volumes: + cokacremote-data: diff --git a/workspace/.gitkeep b/workspace/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/workspace/.gitkeep @@ -0,0 +1 @@ + From b4e5dfb7bd993d276ede7bc43998319138d7199f Mon Sep 17 00:00:00 2001 From: k Date: Mon, 24 Aug 2026 15:14:46 +0900 Subject: [PATCH 02/30] Persist container SSH credentials --- .dockerignore | 1 + .env.example | 3 +++ README.md | 12 ++++++++++++ docker-compose.yml | 4 +++- 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index efc2aa4..767be1e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,6 +12,7 @@ coverage/ !.env.example workspace/ data/ +.ssh/ npm-debug.log *.log diff --git a/.env.example b/.env.example index 8373847..9114e64 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ # Docker Compose mount source. Keep this limited to a dedicated project directory. WORKSPACE_PATH=./workspace +# Container-only SSH directory stored beside this project on the same disk. +SSH_PATH=./.ssh + # Network MCP_HOST=0.0.0.0 MCP_PORT=3000 diff --git a/README.md b/README.md index cecee4e..f634560 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,18 @@ The Quick Start above is enough to run a normal local instance. If you are chang MCP_AUTH_TOKEN=development-token npm run dev ``` +## Docker Compose + +The Compose service bind-mounts the project-local `./.ssh` directory at `/home/node/.ssh`. SSH keys, config, and `known_hosts` created inside the container therefore survive container restarts and recreation without exposing the host user's SSH directory. Set `SSH_PATH` in `.env` to use another directory on the same disk. + +Then build or recreate the service normally: + +```bash +docker compose up -d --build +``` + +Create the host directory with mode `0700` before starting the service. Because this is a bind mount, `docker compose down --volumes` does not remove its SSH credentials. Back up or delete the configured `SSH_PATH` separately when needed. + ## Authentication When `MCP_AUTH_TOKEN` is set, every MCP request requires the following header: diff --git a/docker-compose.yml b/docker-compose.yml index 148bcad..a44d6fc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,9 +16,11 @@ services: MCP_OAUTH_STATE_FILE: ${MCP_OAUTH_STATE_FILE:-/data/oauth-state.json} MCP_AUTH_TOKEN: ${MCP_AUTH_TOKEN:-} ports: - - "127.0.0.1:${MCP_PORT:-3000}:${MCP_PORT:-3000}" + - "${MCP_LISTEN_HOST:-127.0.0.1}:${MCP_PORT:-3000}:${MCP_PORT:-3000}" + - "${MCP_LISTEN_HOST:-127.0.0.1}:3010-3020:3010-3020" volumes: - ${WORKSPACE_PATH:-./workspace}:/workspace + - ${SSH_PATH:-./.ssh}:/home/node/.ssh - cokacremote-data:/data healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:$${MCP_PORT:-3000}/health || exit 1"] From f9d4f55d6ce4cedf0dca510a856f3ed14004d699 Mon Sep 17 00:00:00 2001 From: k Date: Mon, 24 Aug 2026 15:24:58 +0900 Subject: [PATCH 03/30] Document MCP listen host setting --- .env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.example b/.env.example index 9114e64..72ba837 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ SSH_PATH=./.ssh # Network MCP_HOST=0.0.0.0 +MCP_LISTEN_HOST=127.0.0.1 MCP_PORT=3000 MCP_ENDPOINT=/mcp MCP_PUBLIC_URL= From d9aea75d55b1c20e0b4e2f4888bba8ddf9908b51 Mon Sep 17 00:00:00 2001 From: k Date: Tue, 25 Aug 2026 12:36:00 +0900 Subject: [PATCH 04/30] Harden authentication and OAuth state handling --- .env.example | 1 + README.md | 4 +- deploy/remote-dev-mcp.env.example | 1 + src/config.ts | 19 +++++++++ src/http-server.ts | 19 +++++++++ src/oauth.ts | 29 ++++++++++++-- test/all-tools.integration.test.ts | 6 ++- test/auth-security.integration.test.ts | 54 ++++++++++++++++++++++++-- test/config.test.ts | 44 +++++++++++++++------ test/mcp.integration.test.ts | 13 ++++--- test/oauth.integration.test.ts | 7 +++- 11 files changed, 168 insertions(+), 29 deletions(-) diff --git a/.env.example b/.env.example index 72ba837..984ac8f 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ MCP_ALLOWED_HOSTS= MCP_TRUST_PROXY_HOPS=0 # Static bearer authentication. OAuth can instead use MCP_OAUTH_APPROVAL_KEY. +# Secrets must be 32+ characters; literal replace-with-* placeholders are rejected. MCP_AUTH_TOKEN=replace-with-a-long-random-token MCP_ALLOW_NO_AUTH=false diff --git a/README.md b/README.md index f634560..c43883a 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ The server provides 20 tools in total. `remove_path` permanently deletes targets The Quick Start above is enough to run a normal local instance. If you are changing the source code itself, development mode automatically watches the TypeScript entry point: ```bash -MCP_AUTH_TOKEN=development-token npm run dev +MCP_AUTH_TOKEN="$(openssl rand -hex 32)" npm run dev ``` ## Docker Compose @@ -189,6 +189,8 @@ When enabled, the server provides: OAuth uses a single `mcp:tools` scope. Enter the `MCP_OAUTH_APPROVAL_KEY` value on the approval page shown when authorizing a ChatGPT connection. For OAuth-only deployments, it is recommended to leave `MCP_AUTH_TOKEN` empty so there is no permanent static Bearer bypass path. For backward compatibility, `MCP_AUTH_TOKEN` is used as the approval key when no dedicated approval key is configured, but keeping the two values separate is safer. Treat both values like root credentials. Registered clients, client secrets, and token hashes are stored in `MCP_OAUTH_STATE_FILE` with mode `600`. +Both authentication secrets must contain at least 32 characters, and known example placeholder values are rejected at startup. Generate independent values with `openssl rand -hex 32`; do not copy the literal placeholder values from the example environment files. + OAuth-related HTTP routes: | Path | Purpose | diff --git a/deploy/remote-dev-mcp.env.example b/deploy/remote-dev-mcp.env.example index 6665d0e..1e12dc3 100644 --- a/deploy/remote-dev-mcp.env.example +++ b/deploy/remote-dev-mcp.env.example @@ -4,6 +4,7 @@ MCP_ENDPOINT=/mcp MCP_PUBLIC_URL=https://mcp.example.com MCP_ALLOWED_HOSTS=mcp.example.com,127.0.0.1,localhost MCP_TRUST_PROXY_HOPS=1 +# Authentication secrets must be 32+ characters. Generate them with: openssl rand -hex 32 MCP_AUTH_TOKEN= MCP_ALLOW_NO_AUTH=false MCP_OAUTH_ENABLED=true diff --git a/src/config.ts b/src/config.ts index f3676be..48f60a7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,8 @@ import path from "node:path"; +const MIN_AUTH_SECRET_CHARACTERS = 32; +const PLACEHOLDER_SECRET_PREFIX = "replace-with-"; + export interface AppConfig { host: string; port: number; @@ -62,6 +65,20 @@ function parseInteger( return parsed; } +function validateAuthSecret(value: string | undefined, name: string): void { + if (value === undefined) { + return; + } + if ( + value.length < MIN_AUTH_SECRET_CHARACTERS || + value.toLowerCase().startsWith(PLACEHOLDER_SECRET_PREFIX) + ) { + throw new Error( + `${name} must be at least ${MIN_AUTH_SECRET_CHARACTERS} characters and must not be an example placeholder; generate one with 'openssl rand -hex 32'`, + ); + } +} + function normalizeEndpoint(value: string | undefined): string { const endpoint = value?.trim() || "/mcp"; if (!endpoint.startsWith("/")) { @@ -106,6 +123,8 @@ export function loadConfig( const oauthApprovalKey = oauthEnabled ? env.MCP_OAUTH_APPROVAL_KEY?.trim() || authToken : undefined; + validateAuthSecret(authToken, "MCP_AUTH_TOKEN"); + validateAuthSecret(oauthApprovalKey, "MCP_OAUTH_APPROVAL_KEY"); if (!allowNoAuth && !authToken && !oauthEnabled) { throw new Error( "MCP_AUTH_TOKEN is required. Set MCP_ALLOW_NO_AUTH=true only when an upstream OAuth gateway or private network authenticates callers.", diff --git a/src/http-server.ts b/src/http-server.ts index f5e9418..1d076ae 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -58,6 +58,14 @@ export async function startHttpServer( ): Promise { const app = express(); app.disable("x-powered-by"); + app.use((_request, response, next) => { + response.set({ + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + }); + next(); + }); if (config.trustProxyHops > 0) { app.set("trust proxy", config.trustProxyHops); } @@ -111,12 +119,23 @@ export async function startHttpServer( resource_name: "cokacremote", }); }); + const rateLimitOptions = config.trustProxyHops === 0 + ? { validate: { xForwardedForHeader: false } } + : undefined; const oauthRouterOptions = { provider: oauthProvider, issuerUrl: oauthProvider.issuerUrl, resourceServerUrl: oauthProvider.resourceUrl, scopesSupported: [...OAUTH_SCOPES], resourceName: "cokacremote", + ...(rateLimitOptions + ? { + authorizationOptions: { rateLimit: rateLimitOptions }, + clientRegistrationOptions: { rateLimit: rateLimitOptions }, + revocationOptions: { rateLimit: rateLimitOptions }, + tokenOptions: { rateLimit: rateLimitOptions }, + } + : {}), } satisfies AuthRouterOptions; const oauthMetadata = { ...createOAuthMetadata(oauthRouterOptions), diff --git a/src/oauth.ts b/src/oauth.ts index 5c55415..68e7373 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -1,5 +1,6 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; -import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { constants } from "node:fs"; +import { mkdir, open, rename, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js"; @@ -195,12 +196,32 @@ class PersistentOAuthStore implements OAuthRegisteredClientsStore { private async ensureLoaded(): Promise { this.loadPromise ??= (async () => { + let handle: Awaited> | undefined; try { - this.state = parseState(await readFile(this.stateFile, "utf8")); + const noFollowFlag = process.platform === "win32" ? 0 : constants.O_NOFOLLOW; + handle = await open(this.stateFile, constants.O_RDONLY | noFollowFlag); + const info = await handle.stat(); + if (!info.isFile()) { + throw new Error("OAuth state path must be a regular file"); + } + if (typeof process.geteuid === "function" && info.uid !== process.geteuid()) { + throw new Error("OAuth state file must be owned by the service user"); + } + if (process.platform !== "win32" && (info.mode & 0o077) !== 0) { + throw new Error("OAuth state file permissions must not grant group or other access"); + } + this.state = parseState(await handle.readFile("utf8")); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return; } + if (code === "ELOOP") { + throw new Error("OAuth state file must not be a symbolic link", { cause: error }); + } + throw error; + } finally { + await handle?.close(); } })(); await this.loadPromise; diff --git a/test/all-tools.integration.test.ts b/test/all-tools.integration.test.ts index 92eb6ca..df6991c 100644 --- a/test/all-tools.integration.test.ts +++ b/test/all-tools.integration.test.ts @@ -110,7 +110,7 @@ describe.sequential("all registered MCP tools", () => { } else { localDirectory = await mkdtemp(path.join(os.tmpdir(), "cokacremote-all-tools-")); testRoot = path.join(localDirectory, "tool-root"); - authToken = "all-tools-test-secret"; + authToken = "all-tools-test-secret-0123456789abcdef"; const config = loadConfig( { MCP_AUTH_TOKEN: authToken, @@ -249,7 +249,9 @@ describe.sequential("all registered MCP tools", () => { env: "script-env-ok", stdin: "script-stdin-ok", }); - expect(String(script.scriptPath)).toMatch(/^\/tmp\/remote-dev-mcp-script-/); + expect(path.relative(os.tmpdir(), String(script.scriptPath))).toMatch( + /^remote-dev-mcp-script-[^/]+\/script\.mjs$/, + ); const keptScript = await callOk("stat_path", { path: script.scriptPath }); expect(keptScript).toMatchObject({ type: "file", mode: "0700" }); await callOk("remove_path", { diff --git a/test/auth-security.integration.test.ts b/test/auth-security.integration.test.ts index 8c28c55..4d5745a 100644 --- a/test/auth-security.integration.test.ts +++ b/test/auth-security.integration.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import type { AddressInfo } from "node:net"; import { createServer } from "node:net"; import os from "node:os"; @@ -34,7 +34,7 @@ describe("OAuth endpoint security boundaries", () => { const config = loadConfig( { MCP_OAUTH_ENABLED: "true", - MCP_OAUTH_APPROVAL_KEY: "oauth-approval-key", + MCP_OAUTH_APPROVAL_KEY: "oauth-approval-key-0123456789abcdef", MCP_PUBLIC_URL: baseUrl, MCP_OAUTH_STATE_FILE: path.join(temporaryDirectory, "oauth-state.json"), MCP_HOST: "127.0.0.1", @@ -49,7 +49,7 @@ describe("OAuth endpoint security boundaries", () => { const approvalKeyAsBearer = await fetch(`${baseUrl}/mcp`, { method: "POST", headers: { - authorization: "Bearer oauth-approval-key", + authorization: "Bearer oauth-approval-key-0123456789abcdef", "content-type": "application/json", }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), @@ -90,7 +90,7 @@ describe("OAuth endpoint security boundaries", () => { ); const baseEnvironment = { MCP_OAUTH_ENABLED: "true", - MCP_OAUTH_APPROVAL_KEY: "oauth-approval-key", + MCP_OAUTH_APPROVAL_KEY: "oauth-approval-key-0123456789abcdef", MCP_PUBLIC_URL: "http://127.0.0.1:34567", }; const metadata = { @@ -164,4 +164,50 @@ describe("OAuth endpoint security boundaries", () => { await rm(temporaryDirectory, { recursive: true, force: true }); } }); + + it("rejects OAuth state files that are exposed or redirected by a symlink", async () => { + if (process.platform === "win32") { + return; + } + const temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), "cokacremote-oauth-state-security-test-"), + ); + const state = JSON.stringify({ version: 1, clients: {}, tokens: {} }); + const baseEnvironment = { + MCP_OAUTH_ENABLED: "true", + MCP_OAUTH_APPROVAL_KEY: "oauth-approval-key-0123456789abcdef", + MCP_PUBLIC_URL: "http://127.0.0.1:34567", + }; + + try { + const permissiveStateFile = path.join(temporaryDirectory, "permissive.json"); + await writeFile(permissiveStateFile, state, { mode: 0o600 }); + await chmod(permissiveStateFile, 0o644); + const permissiveProvider = new RemoteDevOAuthProvider( + loadConfig( + { ...baseEnvironment, MCP_OAUTH_STATE_FILE: permissiveStateFile }, + temporaryDirectory, + ), + ); + await expect(permissiveProvider.clientsStore.getClient("missing")).rejects.toThrow( + "permissions must not grant group or other access", + ); + + const targetFile = path.join(temporaryDirectory, "target.json"); + const linkedStateFile = path.join(temporaryDirectory, "linked.json"); + await writeFile(targetFile, state, { mode: 0o600 }); + await symlink(targetFile, linkedStateFile); + const linkedProvider = new RemoteDevOAuthProvider( + loadConfig( + { ...baseEnvironment, MCP_OAUTH_STATE_FILE: linkedStateFile }, + temporaryDirectory, + ), + ); + await expect(linkedProvider.clientsStore.getClient("missing")).rejects.toThrow( + "must not be a symbolic link", + ); + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } + }); }); diff --git a/test/config.test.ts b/test/config.test.ts index c5bb6f0..6031471 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -2,16 +2,38 @@ import { describe, expect, it } from "vitest"; import { loadConfig } from "../src/config.js"; +const AUTH_SECRET = "test-auth-secret-0123456789abcdef"; +const OAUTH_SECRET = "test-oauth-secret-0123456789abcdef"; + describe("loadConfig", () => { it("requires authentication unless explicitly disabled", () => { expect(() => loadConfig({}, "/tmp")).toThrow("MCP_AUTH_TOKEN is required"); expect(loadConfig({ MCP_ALLOW_NO_AUTH: "true" }, "/tmp").allowNoAuth).toBe(true); }); + it("rejects weak and example authentication secrets", () => { + expect(() => loadConfig({ MCP_AUTH_TOKEN: "short-secret" }, "/tmp")).toThrow( + "MCP_AUTH_TOKEN must be at least 32 characters", + ); + expect(() => + loadConfig({ MCP_AUTH_TOKEN: "replace-with-a-long-random-token" }, "/tmp"), + ).toThrow("must not be an example placeholder"); + expect(() => + loadConfig( + { + MCP_OAUTH_ENABLED: "true", + MCP_OAUTH_APPROVAL_KEY: "short-oauth-secret", + MCP_PUBLIC_URL: "https://mcp.example.com", + }, + "/tmp", + ), + ).toThrow("MCP_OAUTH_APPROVAL_KEY must be at least 32 characters"); + }); + it("loads full-access host settings", () => { const config = loadConfig( { - MCP_AUTH_TOKEN: "secret", + MCP_AUTH_TOKEN: AUTH_SECRET, MCP_PORT: "4321", MCP_DEFAULT_CWD: "/", MCP_ALLOWED_HOSTS: "mcp.example.com,localhost", @@ -23,7 +45,7 @@ describe("loadConfig", () => { port: 4321, defaultCwd: "/", trustProxyHops: 0, - authToken: "secret", + authToken: AUTH_SECRET, allowedHosts: ["mcp.example.com", "localhost"], }); }); @@ -31,22 +53,22 @@ describe("loadConfig", () => { it("rejects partial integers and ports outside the valid range", () => { for (const value of ["3000oops", "3000.9", "70000"]) { expect(() => - loadConfig({ MCP_AUTH_TOKEN: "secret", MCP_PORT: value }, "/tmp"), + loadConfig({ MCP_AUTH_TOKEN: AUTH_SECRET, MCP_PORT: value }, "/tmp"), ).toThrow("MCP_PORT must be an integer between 1 and 65535"); } expect( - loadConfig({ MCP_AUTH_TOKEN: "secret", MCP_PORT: " 4321 " }, "/tmp").port, + loadConfig({ MCP_AUTH_TOKEN: AUTH_SECRET, MCP_PORT: " 4321 " }, "/tmp").port, ).toBe(4321); }); it("requires public HTTPS metadata when OAuth is enabled", () => { expect(() => - loadConfig({ MCP_AUTH_TOKEN: "secret", MCP_OAUTH_ENABLED: "true" }, "/tmp"), + loadConfig({ MCP_AUTH_TOKEN: AUTH_SECRET, MCP_OAUTH_ENABLED: "true" }, "/tmp"), ).toThrow("MCP_OAUTH_ISSUER is required"); const config = loadConfig( { - MCP_AUTH_TOKEN: "secret", + MCP_AUTH_TOKEN: AUTH_SECRET, MCP_OAUTH_ENABLED: "true", MCP_PUBLIC_URL: "https://mcp.example.com", MCP_OAUTH_STATE_FILE: "/tmp/oauth-state.json", @@ -55,7 +77,7 @@ describe("loadConfig", () => { ); expect(config).toMatchObject({ oauthEnabled: true, - oauthApprovalKey: "secret", + oauthApprovalKey: AUTH_SECRET, oauthIssuerUrl: "https://mcp.example.com/", oauthResourceUrl: "https://mcp.example.com/mcp", oauthStateFile: "/tmp/oauth-state.json", @@ -66,7 +88,7 @@ describe("loadConfig", () => { const config = loadConfig( { MCP_OAUTH_ENABLED: "true", - MCP_OAUTH_APPROVAL_KEY: "separate-oauth-approval-key", + MCP_OAUTH_APPROVAL_KEY: OAUTH_SECRET, MCP_PUBLIC_URL: "https://mcp.example.com", MCP_TRUST_PROXY_HOPS: "1", }, @@ -75,7 +97,7 @@ describe("loadConfig", () => { expect(config).toMatchObject({ authToken: undefined, - oauthApprovalKey: "separate-oauth-approval-key", + oauthApprovalKey: OAUTH_SECRET, trustProxyHops: 1, }); expect(() => @@ -91,12 +113,12 @@ describe("loadConfig", () => { it("rejects unsafe proxy trust and OAuth URL settings", () => { expect(() => - loadConfig({ MCP_AUTH_TOKEN: "secret", MCP_TRUST_PROXY_HOPS: "17" }, "/tmp"), + loadConfig({ MCP_AUTH_TOKEN: AUTH_SECRET, MCP_TRUST_PROXY_HOPS: "17" }, "/tmp"), ).toThrow("MCP_TRUST_PROXY_HOPS must be an integer between 0 and 16"); expect(() => loadConfig( { - MCP_AUTH_TOKEN: "secret", + MCP_AUTH_TOKEN: AUTH_SECRET, MCP_OAUTH_ENABLED: "true", MCP_OAUTH_ISSUER: "https://user:password@mcp.example.com", MCP_OAUTH_RESOURCE: "https://mcp.example.com/mcp", diff --git a/test/mcp.integration.test.ts b/test/mcp.integration.test.ts index 70c5c44..d2170ed 100644 --- a/test/mcp.integration.test.ts +++ b/test/mcp.integration.test.ts @@ -29,7 +29,7 @@ describe("remote development MCP server", () => { temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "remote-dev-mcp-http-test-")); config = loadConfig( { - MCP_AUTH_TOKEN: "integration-secret", + MCP_AUTH_TOKEN: "integration-secret-0123456789abcdef", MCP_HOST: "127.0.0.1", MCP_DEFAULT_CWD: temporaryDirectory, MCP_MAX_FILE_CHUNK_BYTES: "65536", @@ -65,6 +65,9 @@ describe("remote development MCP server", () => { }); expect(response.status).toBe(401); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("referrer-policy")).toBe("no-referrer"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); }); it("authenticates MCP requests before parsing their JSON body", async () => { @@ -78,7 +81,7 @@ describe("remote development MCP server", () => { const authenticated = await fetch(endpoint, { method: "POST", headers: { - authorization: "Bearer integration-secret", + authorization: "Bearer integration-secret-0123456789abcdef", "content-type": "application/json", }, body: "{", @@ -90,7 +93,7 @@ describe("remote development MCP server", () => { const client = new Client({ name: "integration-test", version: "1.0.0" }); const transport = new StreamableHTTPClientTransport(endpoint, { requestInit: { - headers: { Authorization: "Bearer integration-secret" }, + headers: { Authorization: "Bearer integration-secret-0123456789abcdef" }, }, }); await client.connect(transport); @@ -157,7 +160,7 @@ describe("remote development MCP server", () => { fetch(endpoint, { method: "POST", headers: { - authorization: "Bearer integration-secret", + authorization: "Bearer integration-secret-0123456789abcdef", accept: "application/json, text/event-stream", "content-type": "application/json", ...additionalHeaders, @@ -250,7 +253,7 @@ describe("remote development MCP server", () => { const getResponse = await fetch(endpoint, { headers: { - authorization: "Bearer integration-secret", + authorization: "Bearer integration-secret-0123456789abcdef", accept: "text/event-stream", }, }); diff --git a/test/oauth.integration.test.ts b/test/oauth.integration.test.ts index 2d4bc1d..47e7fdc 100644 --- a/test/oauth.integration.test.ts +++ b/test/oauth.integration.test.ts @@ -49,7 +49,7 @@ describe("OAuth 2.1 MCP authorization", () => { config = loadConfig( { MCP_OAUTH_ENABLED: "true", - MCP_OAUTH_APPROVAL_KEY: "oauth-login-secret", + MCP_OAUTH_APPROVAL_KEY: "oauth-login-secret-0123456789abcdef", MCP_PUBLIC_URL: baseUrl, MCP_OAUTH_STATE_FILE: stateFile, MCP_HOST: "127.0.0.1", @@ -206,7 +206,10 @@ describe("OAuth 2.1 MCP authorization", () => { const approvedLogin = await fetch(`${baseUrl}/authorize`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, - body: form({ ...authorizationValues, access_key: "oauth-login-secret" }), + body: form({ + ...authorizationValues, + access_key: "oauth-login-secret-0123456789abcdef", + }), redirect: "manual", }); expect(approvedLogin.status).toBe(303); From 2cd63cca64a2af6d934929d4a5a5b4009e8b931a Mon Sep 17 00:00:00 2001 From: k Date: Tue, 25 Aug 2026 16:02:13 +0900 Subject: [PATCH 05/30] Add Docker Compose operations guides --- README.md | 2 + docs/docker-compose-guide.ko.md | 344 +++++++++++++++++++++++++++++++ docs/docker-compose-guide.md | 346 ++++++++++++++++++++++++++++++++ 3 files changed, 692 insertions(+) create mode 100644 docs/docker-compose-guide.ko.md create mode 100644 docs/docker-compose-guide.md diff --git a/README.md b/README.md index c43883a..07e5bab 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,8 @@ MCP_AUTH_TOKEN="$(openssl rand -hex 32)" npm run dev ## Docker Compose +For complete installation, security, persistent SSH, OAuth, update, and troubleshooting instructions, see the [Docker Compose Installation and Operations Guide](docs/docker-compose-guide.md). A [Korean version](docs/docker-compose-guide.ko.md) is also available. For a shorter macOS Docker Desktop walkthrough, see [DOCKER.md](DOCKER.md). + The Compose service bind-mounts the project-local `./.ssh` directory at `/home/node/.ssh`. SSH keys, config, and `known_hosts` created inside the container therefore survive container restarts and recreation without exposing the host user's SSH directory. Set `SSH_PATH` in `.env` to use another directory on the same disk. Then build or recreate the service normally: diff --git a/docs/docker-compose-guide.ko.md b/docs/docker-compose-guide.ko.md new file mode 100644 index 0000000..fb64aca --- /dev/null +++ b/docs/docker-compose-guide.ko.md @@ -0,0 +1,344 @@ +# Docker Compose 설치 및 운영 가이드 + +이 문서는 `cokacremote`를 Docker Compose로 안전하게 실행하고 운영하는 방법을 설명합니다. macOS의 Docker Desktop과 Linux Docker Engine에서 사용할 수 있습니다. + +> [!WARNING] +> `cokacremote`는 명령 실행과 파일 변경 기능을 제공하는 강력한 MCP 서버입니다. 컨테이너 안에서는 `node` 사용자로 실행되지만, 인증된 클라이언트는 마운트된 디렉터리와 컨테이너 네트워크에 제한 없이 접근할 수 있습니다. 호스트 전체, Docker 소켓 또는 개인 SSH 디렉터리를 마운트하지 마십시오. + +## 1. 구성 구조 + +기본 Compose 구성은 다음 경계를 사용합니다. + +| 호스트 | 컨테이너 | 용도 | +|---|---|---| +| `./workspace` | `/workspace` | MCP가 작업하는 기본 디렉터리 | +| `./.ssh` | `/home/node/.ssh` | 컨테이너 전용 SSH 설정과 키의 영속 저장 | +| `cokacremote-data` named volume | `/data` | OAuth 클라이언트와 토큰 해시 상태 저장 | +| `127.0.0.1:3000` | `3000` | MCP HTTP 서버 | +| `127.0.0.1:3010-3020` | `3010-3020` | 컨테이너에서 실행하는 개발 서버용 포트 | + +`./.ssh`는 호스트 사용자의 `~/.ssh`가 아닙니다. 이 프로젝트 전용 디렉터리이며 컨테이너에서 새로 만든 키와 `known_hosts`만 저장해야 합니다. + +## 2. 사전 요구사항 + +- Docker Desktop 또는 Docker Engine과 Compose 플러그인 +- Git +- 인증키 생성을 위한 OpenSSL + +설치 상태를 확인합니다. + +```bash +docker version +docker compose version +git --version +openssl version +``` + +## 3. 최초 설정 + +저장소 루트에서 환경 파일과 마운트 디렉터리를 준비합니다. + +```bash +cp .env.example .env +mkdir -p workspace .ssh +chmod 700 .ssh +``` + +Linux에서 컨테이너의 `node` 사용자(기본 UID/GID `1000`)가 bind mount에 쓰지 못한다면 다음과 같이 소유권을 조정합니다. + +```bash +sudo chown -R 1000:1000 workspace .ssh +``` + +macOS Docker Desktop에서는 일반적으로 별도 소유권 변경이 필요하지 않습니다. + +### 인증키 생성 + +다음 명령을 두 번 실행해 서로 다른 키를 준비합니다. + +```bash +openssl rand -hex 32 +``` + +출력된 값을 `.env`에 입력합니다. 정적 Bearer 인증만 사용할 때는 다음과 같이 설정합니다. + +```dotenv +MCP_AUTH_TOKEN=<생성한 64자리 값> +MCP_ALLOW_NO_AUTH=false +MCP_OAUTH_ENABLED=false +``` + +인증키는 최소 32자여야 하며 `replace-with-`로 시작하는 예제 값은 서버가 거부합니다. `.env`는 Git에서 제외되므로 커밋하지 마십시오. + +### 기본 네트워크 설정 + +로컬에서만 접근할 때 권장되는 값입니다. + +```dotenv +MCP_HOST=0.0.0.0 +MCP_LISTEN_HOST=127.0.0.1 +MCP_PORT=3000 +MCP_ALLOWED_HOSTS=127.0.0.1,localhost +MCP_TRUST_PROXY_HOPS=0 +``` + +`MCP_HOST=0.0.0.0`은 컨테이너 내부에서 요청을 받기 위해 필요합니다. 호스트 공개 범위는 `MCP_LISTEN_HOST=127.0.0.1`이 제한합니다. + +## 4. 설정 검증과 시작 + +Compose 문법과 변수 치환을 먼저 검사합니다. + +```bash +docker compose config --quiet +``` + +이미지를 빌드하고 백그라운드에서 시작합니다. + +```bash +docker compose up -d --build +``` + +상태와 로그를 확인합니다. + +```bash +docker compose ps +docker compose logs --tail=100 cokacremote +``` + +정상 상태라면 `docker compose ps`에 `healthy`가 표시됩니다. 시작 직후에는 health check의 `start_period` 때문에 잠시 `starting`으로 표시될 수 있습니다. + +## 5. 연결 확인 + +Health endpoint를 확인합니다. + +```bash +curl -fsS http://127.0.0.1:3000/health +``` + +기본 MCP URL은 다음과 같습니다. + +```text +http://127.0.0.1:3000/mcp +``` + +MCP 요청에는 `.env`에 설정한 Bearer 인증키가 필요합니다. 인증 없이 endpoint를 호출했을 때 `401 Unauthorized`가 반환되면 인증 경계가 동작하는 것입니다. + +```bash +curl -i -X POST http://127.0.0.1:3000/mcp \ + -H 'Content-Type: application/json' \ + --data '{}' +``` + +응답에는 다음 보안 헤더도 포함되어야 합니다. + +```text +Cache-Control: no-store +Referrer-Policy: no-referrer +X-Content-Type-Options: nosniff +``` + +## 6. 작업 디렉터리 변경 + +기본적으로 `./workspace`만 `/workspace`에 마운트됩니다. 다른 프로젝트를 사용하려면 `.env`의 `WORKSPACE_PATH`를 전용 디렉터리로 변경합니다. + +```dotenv +WORKSPACE_PATH=/Users/myname/Projects/mcp-workspace +``` + +Linux 예시: + +```dotenv +WORKSPACE_PATH=/srv/cokacremote-workspace +``` + +설정 변경 후 컨테이너를 재생성합니다. + +```bash +docker compose up -d --force-recreate +``` + +다음 경로는 마운트하지 않는 것을 권장합니다. + +- `/` 또는 사용자 홈 전체 +- `/var/run/docker.sock` +- 호스트의 `~/.ssh` +- 운영 서버의 시스템 설정 디렉터리 +- 다른 애플리케이션의 비밀정보 저장 디렉터리 + +## 7. 컨테이너 전용 SSH 사용 + +컨테이너 안에서 SSH 키를 만들면 프로젝트의 `SSH_PATH` 디렉터리에 유지됩니다. + +```bash +docker compose exec cokacremote ssh-keygen -t ed25519 -f /home/node/.ssh/id_ed25519 +docker compose exec -T cokacremote sh -lc \ + 'ssh-keyscan github.com >> /home/node/.ssh/known_hosts && chmod 600 /home/node/.ssh/known_hosts' +``` + +공개키를 확인해 필요한 Git 호스팅 서비스에 등록합니다. + +```bash +docker compose exec cokacremote cat /home/node/.ssh/id_ed25519.pub +``` + +`.ssh`는 `.gitignore`와 `.dockerignore`에 포함되어 있습니다. 그래도 별도 백업 정책과 파일 권한을 적용하고 외부에 공유하지 마십시오. + +## 8. 컨테이너 안의 개발 서버 노출 + +Compose는 `3010-3020` 포트를 호스트의 localhost에만 공개합니다. MCP가 컨테이너 안에서 개발 서버를 시작할 때는 컨테이너 외부 연결을 받을 수 있도록 `0.0.0.0`에 바인딩해야 합니다. + +예시: + +```bash +npm run dev -- --host 0.0.0.0 --port 3010 +``` + +호스트에서는 다음 주소로 접속합니다. + +```text +http://127.0.0.1:3010 +``` + +개발 서버가 `127.0.0.1`에만 바인딩되면 컨테이너 내부에서만 접근할 수 있습니다. + +## 9. OAuth를 사용하는 공개 HTTPS 구성 + +ChatGPT와 같은 원격 MCP 클라이언트가 OAuth를 사용하려면 먼저 Nginx, 안전한 터널 또는 로드 밸런서로 공개 HTTPS 주소를 구성해야 합니다. Node.js 포트 `3000`을 인터넷에 직접 공개하지 마십시오. + +프록시가 호스트에서 실행되고 정확히 한 단계만 존재하는 예시는 다음과 같습니다. + +```dotenv +MCP_LISTEN_HOST=127.0.0.1 +MCP_ALLOWED_HOSTS=mcp.example.com,127.0.0.1,localhost +MCP_TRUST_PROXY_HOPS=1 +MCP_AUTH_TOKEN= +MCP_OAUTH_ENABLED=true +MCP_OAUTH_APPROVAL_KEY=<별도로 생성한 64자리 값> +MCP_PUBLIC_URL=https://mcp.example.com +MCP_OAUTH_ISSUER=https://mcp.example.com +MCP_OAUTH_RESOURCE=https://mcp.example.com/mcp +MCP_OAUTH_STATE_FILE=/data/oauth-state.json +``` + +프록시 단계 수가 다르거나 터널 서비스가 전달하는 주소 체계가 다르면 `MCP_TRUST_PROXY_HOPS`도 해당 구조에 맞게 조정해야 합니다. 잘못된 proxy trust 설정은 OAuth rate limit 우회를 허용할 수 있습니다. + +OAuth 상태 파일은 `cokacremote-data` named volume에 저장됩니다. 서버가 생성한 상태 파일은 컨테이너의 서비스 사용자 소유와 `0600` 권한으로 관리됩니다. + +## 10. 중지, 재시작 및 업데이트 + +일반적인 중지와 시작: + +```bash +docker compose stop +docker compose start +``` + +설정 변경 후 재생성: + +```bash +docker compose up -d --force-recreate +``` + +소스와 base image 업데이트: + +```bash +git pull --ff-only +docker compose build --pull +docker compose up -d +docker compose ps +``` + +서비스와 네트워크를 내리되 OAuth named volume은 유지합니다. + +```bash +docker compose down +``` + +## 11. 데이터 백업과 초기화 + +다음 데이터는 컨테이너를 재생성해도 유지됩니다. + +- `WORKSPACE_PATH`의 작업 파일 +- `SSH_PATH`의 컨테이너 전용 SSH 파일 +- `cokacremote-data` volume의 OAuth 상태 + +OAuth 상태를 파일로 백업하려면 실행 중인 컨테이너에서 복사한 후 권한을 제한합니다. + +```bash +docker compose cp cokacremote:/data/oauth-state.json ./oauth-state.backup.json +chmod 600 oauth-state.backup.json +``` + +백업에는 OAuth 클라이언트 정보와 토큰 해시가 들어 있으므로 인증정보와 동일하게 보호하십시오. + +OAuth 등록과 발급 토큰을 모두 무효화하려는 경우에만 named volume을 삭제합니다. + +```bash +docker compose down --volumes +``` + +> [!CAUTION] +> 이 명령은 `cokacremote-data` volume을 삭제하므로 복구할 수 없습니다. `WORKSPACE_PATH`와 `SSH_PATH`는 bind mount이므로 삭제되지 않습니다. + +## 12. 문제 해결 + +### 인증키 오류로 시작되지 않음 + +로그에 인증키 길이 또는 placeholder 오류가 나타나면 새 키를 생성해 `.env`를 수정합니다. + +```bash +openssl rand -hex 32 +docker compose up -d --force-recreate +``` + +### 컨테이너가 unhealthy 상태임 + +```bash +docker compose ps +docker compose logs --tail=200 cokacremote +docker compose exec cokacremote curl -i http://localhost:3000/health +``` + +### `/workspace` 또는 `.ssh`에 쓸 수 없음 + +Linux에서는 bind mount 소유권을 확인합니다. + +```bash +ls -ld workspace .ssh +sudo chown -R 1000:1000 workspace .ssh +``` + +### `403 Host header is not allowed` + +요청에 사용한 도메인 또는 로컬 호스트명을 `MCP_ALLOWED_HOSTS`에 추가한 뒤 컨테이너를 재생성합니다. + +### 포트가 이미 사용 중임 + +`.env`에서 호스트와 컨테이너가 함께 사용하는 MCP 포트를 변경합니다. + +```dotenv +MCP_PORT=3100 +``` + +또는 개발 서버가 사용하는 포트를 `3010-3020` 범위 안에서 변경합니다. + +### 설정 변경이 반영되지 않음 + +`env_file` 또는 Compose 변수 변경 후에는 restart만 하지 말고 컨테이너를 재생성합니다. + +```bash +docker compose up -d --force-recreate +``` + +## 13. 운영 보안 체크리스트 + +- `MCP_LISTEN_HOST=127.0.0.1`을 유지했는가? +- 32자 이상의 독립적인 인증키를 사용했는가? +- `MCP_ALLOW_NO_AUTH=false`인가? +- `WORKSPACE_PATH`가 전용 작업 디렉터리인가? +- 호스트의 Docker socket이나 홈 전체를 마운트하지 않았는가? +- 컨테이너 전용 `.ssh`만 사용하고 있는가? +- 공개 연결이 HTTPS 프록시 뒤에 있는가? +- `MCP_TRUST_PROXY_HOPS`가 실제 프록시 단계와 일치하는가? +- OAuth 상태와 SSH 키를 민감정보로 백업·관리하는가? +- 정기적으로 이미지를 다시 빌드하고 `npm audit` 결과를 확인하는가? diff --git a/docs/docker-compose-guide.md b/docs/docker-compose-guide.md new file mode 100644 index 0000000..ec510b9 --- /dev/null +++ b/docs/docker-compose-guide.md @@ -0,0 +1,346 @@ +# Docker Compose Installation and Operations Guide + +This guide explains how to run and operate `cokacremote` safely with Docker Compose. It applies to Docker Desktop on macOS and Docker Engine on Linux. + +> [!WARNING] +> `cokacremote` is a powerful MCP server that can execute commands and modify files. The container runs as the non-root `node` user, but an authenticated client still has unrestricted access to mounted directories and the container network. Do not mount the entire host, the Docker socket, or your personal SSH directory. + +## 1. Deployment layout + +The default Compose configuration uses the following boundaries. + +| Host | Container | Purpose | +|---|---|---| +| `./workspace` | `/workspace` | Default MCP working directory | +| `./.ssh` | `/home/node/.ssh` | Persistent container-only SSH configuration and keys | +| `cokacremote-data` named volume | `/data` | OAuth client and token-hash state | +| `127.0.0.1:3000` | `3000` | MCP HTTP server | +| `127.0.0.1:3010-3020` | `3010-3020` | Development servers started inside the container | + +`./.ssh` is not your host user's `~/.ssh`. It is a project-specific directory that should contain only keys and `known_hosts` entries created for this container. + +## 2. Prerequisites + +- Docker Desktop, or Docker Engine with the Compose plugin +- Git +- OpenSSL for generating authentication secrets + +Verify the required tools: + +```bash +docker version +docker compose version +git --version +openssl version +``` + +## 3. Initial setup + +From the repository root, create the local environment file and mount directories: + +```bash +cp .env.example .env +mkdir -p workspace .ssh +chmod 700 .ssh +``` + +On Linux, if the container's `node` user, whose default UID/GID is `1000`, cannot write to the bind mounts, adjust their ownership: + +```bash +sudo chown -R 1000:1000 workspace .ssh +``` + +Docker Desktop on macOS normally does not require this ownership change. + +### Generate authentication secrets + +Run this command twice to create two independent secrets: + +```bash +openssl rand -hex 32 +``` + +Enter the generated values in `.env`. For static Bearer authentication only, use: + +```dotenv +MCP_AUTH_TOKEN= +MCP_ALLOW_NO_AUTH=false +MCP_OAUTH_ENABLED=false +``` + +Authentication secrets must contain at least 32 characters. The server rejects example values beginning with `replace-with-`. The `.env` file is ignored by Git and must not be committed. + +### Default network settings + +These values are recommended for local-only access: + +```dotenv +MCP_HOST=0.0.0.0 +MCP_LISTEN_HOST=127.0.0.1 +MCP_PORT=3000 +MCP_ALLOWED_HOSTS=127.0.0.1,localhost +MCP_TRUST_PROXY_HOPS=0 +``` + +`MCP_HOST=0.0.0.0` allows the process to accept connections inside the container. `MCP_LISTEN_HOST=127.0.0.1` restricts the published host port to the local machine. + +## 4. Validate and start the service + +Validate the Compose file and variable interpolation first: + +```bash +docker compose config --quiet +``` + +Build the image and start the service in the background: + +```bash +docker compose up -d --build +``` + +Inspect its state and logs: + +```bash +docker compose ps +docker compose logs --tail=100 cokacremote +``` + +A healthy service is shown as `healthy` by `docker compose ps`. Immediately after startup it may briefly appear as `starting` because of the health check's `start_period`. + +## 5. Verify connectivity + +Check the health endpoint: + +```bash +curl -fsS http://127.0.0.1:3000/health +``` + +The default MCP URL is: + +```text +http://127.0.0.1:3000/mcp +``` + +MCP requests require the Bearer secret configured in `.env`. Calling the endpoint without authentication should return `401 Unauthorized`, which confirms that the authentication boundary is active. + +```bash +curl -i -X POST http://127.0.0.1:3000/mcp \ + -H 'Content-Type: application/json' \ + --data '{}' +``` + +The response should also include these security headers: + +```text +Cache-Control: no-store +Referrer-Policy: no-referrer +X-Content-Type-Options: nosniff +``` + +## 6. Change the workspace + +By default, only `./workspace` is mounted at `/workspace`. To use a different project, set `WORKSPACE_PATH` in `.env` to a dedicated directory. + +macOS example: + +```dotenv +WORKSPACE_PATH=/Users/myname/Projects/mcp-workspace +``` + +Linux example: + +```dotenv +WORKSPACE_PATH=/srv/cokacremote-workspace +``` + +Recreate the container after changing the setting: + +```bash +docker compose up -d --force-recreate +``` + +Avoid mounting any of the following: + +- `/` or an entire user home directory +- `/var/run/docker.sock` +- The host user's `~/.ssh` +- Operating-system configuration directories +- Secret directories belonging to other applications + +## 7. Use container-only SSH credentials + +Keys created inside the container persist in the project's `SSH_PATH` directory. + +```bash +docker compose exec cokacremote ssh-keygen -t ed25519 -f /home/node/.ssh/id_ed25519 +docker compose exec -T cokacremote sh -lc \ + 'ssh-keyscan github.com >> /home/node/.ssh/known_hosts && chmod 600 /home/node/.ssh/known_hosts' +``` + +Print the public key and register it with the required Git hosting service: + +```bash +docker compose exec cokacremote cat /home/node/.ssh/id_ed25519.pub +``` + +The `.ssh` directory is listed in both `.gitignore` and `.dockerignore`. It still requires an appropriate backup policy and restrictive file permissions, and it must never be shared publicly. + +## 8. Expose development servers from the container + +Compose publishes ports `3010-3020` on the host loopback interface. A development server started by MCP must bind to `0.0.0.0` inside the container so that the published port can reach it. + +Example: + +```bash +npm run dev -- --host 0.0.0.0 --port 3010 +``` + +Open it on the host at: + +```text +http://127.0.0.1:3010 +``` + +If the development server binds only to `127.0.0.1`, it is reachable only from inside the container. + +## 9. Configure OAuth behind public HTTPS + +Remote MCP clients such as ChatGPT require a public HTTPS URL for OAuth. Put Nginx, a secure tunnel, or a load balancer in front of the service. Do not expose the Node.js port `3000` directly to the internet. + +When the proxy runs on the host and there is exactly one trusted proxy hop, use settings similar to: + +```dotenv +MCP_LISTEN_HOST=127.0.0.1 +MCP_ALLOWED_HOSTS=mcp.example.com,127.0.0.1,localhost +MCP_TRUST_PROXY_HOPS=1 +MCP_AUTH_TOKEN= +MCP_OAUTH_ENABLED=true +MCP_OAUTH_APPROVAL_KEY= +MCP_PUBLIC_URL=https://mcp.example.com +MCP_OAUTH_ISSUER=https://mcp.example.com +MCP_OAUTH_RESOURCE=https://mcp.example.com/mcp +MCP_OAUTH_STATE_FILE=/data/oauth-state.json +``` + +If the proxy chain has a different number of hops, or a tunnel service forwards addresses differently, adjust `MCP_TRUST_PROXY_HOPS` to match the actual topology. Incorrect proxy trust can allow OAuth rate-limit bypasses. + +OAuth state is stored in the `cokacremote-data` named volume. State files created by the server are owned by the container service user and use mode `0600`. + +## 10. Stop, restart, and update + +Stop and start the existing container: + +```bash +docker compose stop +docker compose start +``` + +Recreate it after configuration changes: + +```bash +docker compose up -d --force-recreate +``` + +Update the source and base image: + +```bash +git pull --ff-only +docker compose build --pull +docker compose up -d +docker compose ps +``` + +Remove the service and network while retaining the OAuth named volume: + +```bash +docker compose down +``` + +## 11. Back up and reset persistent data + +The following data survives container replacement: + +- Workspace files under `WORKSPACE_PATH` +- Container-only SSH files under `SSH_PATH` +- OAuth state in the `cokacremote-data` volume + +To copy the OAuth state out of a running container, then restrict the backup permissions: + +```bash +docker compose cp cokacremote:/data/oauth-state.json ./oauth-state.backup.json +chmod 600 oauth-state.backup.json +``` + +The backup contains OAuth client information and token hashes. Protect it as authentication data. + +Delete the named volume only when you intend to invalidate every OAuth registration and issued token: + +```bash +docker compose down --volumes +``` + +> [!CAUTION] +> This command permanently deletes the `cokacremote-data` volume. `WORKSPACE_PATH` and `SSH_PATH` are bind mounts and are not deleted. + +## 12. Troubleshooting + +### The service rejects an authentication secret + +If the logs report a short secret or an example placeholder, generate a new value, update `.env`, and recreate the container: + +```bash +openssl rand -hex 32 +docker compose up -d --force-recreate +``` + +### The container is unhealthy + +```bash +docker compose ps +docker compose logs --tail=200 cokacremote +docker compose exec cokacremote curl -i http://localhost:3000/health +``` + +### The container cannot write to `/workspace` or `.ssh` + +On Linux, inspect and correct the bind-mount ownership: + +```bash +ls -ld workspace .ssh +sudo chown -R 1000:1000 workspace .ssh +``` + +### `403 Host header is not allowed` + +Add the requested domain or local hostname to `MCP_ALLOWED_HOSTS`, then recreate the container. + +### The port is already in use + +Change the MCP port used on both the host and container in `.env`: + +```dotenv +MCP_PORT=3100 +``` + +Alternatively, move a development server to another port within the `3010-3020` range. + +### Configuration changes are not applied + +After changing `env_file` or Compose variables, recreate the container instead of only restarting it: + +```bash +docker compose up -d --force-recreate +``` + +## 13. Operations security checklist + +- Is `MCP_LISTEN_HOST=127.0.0.1` still set? +- Are independent authentication secrets at least 32 characters long? +- Is `MCP_ALLOW_NO_AUTH=false`? +- Does `WORKSPACE_PATH` point to a dedicated workspace? +- Are the Docker socket and the full host home directory excluded from mounts? +- Are only container-specific SSH credentials used? +- Are public connections terminated by an HTTPS proxy? +- Does `MCP_TRUST_PROXY_HOPS` match the actual proxy chain? +- Are OAuth state and SSH keys backed up and handled as sensitive data? +- Are images rebuilt and `npm audit` results reviewed regularly? From 0216267764366913da597acb1dd28f0697b8aa92 Mon Sep 17 00:00:00 2001 From: k Date: Mon, 31 Aug 2026 08:31:32 +0000 Subject: [PATCH 06/30] feat: add process lifecycle diagnostics --- .env.example | 3 ++ README.md | 18 +++++++++++ deploy/remote-dev-mcp.env.example | 3 ++ docker-compose.yml | 3 ++ docs/docker-compose-guide.ko.md | 13 ++++++++ docs/docker-compose-guide.md | 13 ++++++++ package.json | 3 +- src/config.ts | 18 +++++++++++ src/doctor.ts | 32 +++++++++++++++++++ src/mcp-server.ts | 3 ++ src/process-manager.ts | 44 +++++++++++++++++++++++++- test/config.test.ts | 22 +++++++++++++ test/process-manager.test.ts | 52 +++++++++++++++++++++++++++++++ 13 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 src/doctor.ts diff --git a/.env.example b/.env.example index 984ac8f..239efcc 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,9 @@ MCP_MAX_REQUEST_BODY=8mb MCP_MAX_OUTPUT_BYTES=1048576 MCP_MAX_RETAINED_PROCESS_OUTPUT_BYTES=4194304 MCP_PROCESS_RETENTION_MS=3600000 +MCP_PROCESS_IDLE_TIMEOUT_MS=1800000 +MCP_PROCESS_MAX_RUNTIME_MS=14400000 +MCP_TASK_JOURNAL_FILE=/data/task-journal.jsonl MCP_MAX_PROCESSES=128 MCP_MAX_FILE_CHUNK_BYTES=1048576 MCP_MAX_EDIT_FILE_BYTES=67108864 diff --git a/README.md b/README.md index 07e5bab..eff20a7 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ The MCP transport is stateless, but long-running command sessions are kept in me - Read, write, edit, transfer, and delete host files, including absolute paths - Built-in static Bearer authentication and OAuth 2.1/DCR/PKCE for ChatGPT - Stateless JSON transport per request, per-process output retention, and response size limits +- Built-in `npm run doctor` diagnostics for runtime/tooling/workspace readiness +- Optional persistent JSONL process journal plus idle/max-runtime process lifecycle controls - systemd and Nginx deployment examples for Linux VPS/EC2 environments ## Available tools @@ -139,6 +141,22 @@ The server provides 20 tools in total. `remove_path` permanently deletes targets - Python 3 if Python execution through `run_script` is needed - A stable, publicly accessible HTTPS domain when connecting directly from ChatGPT +## Reliability controls + +Three operational controls are available for long-running development sessions: + +- `MCP_PROCESS_IDLE_TIMEOUT_MS`: terminates a process after no stdout/stderr activity for the configured interval. Default: `1800000` (30 minutes). Set `0` to disable. +- `MCP_PROCESS_MAX_RUNTIME_MS`: fallback hard runtime limit when a tool call does not provide its own `timeoutMs`. Default: `14400000` (4 hours). Set `0` to disable. +- `MCP_TASK_JOURNAL_FILE`: optional JSONL journal containing process start/completion/idle-timeout events. Docker Compose defaults this to `/data/task-journal.jsonl`, which is stored in the persistent `cokacremote-data` volume. + +Run a host readiness check with: + +```bash +npm run doctor +``` + +The doctor checks Node.js, Git, the configured shell, npm, Python, workspace read/write access, and the configured task-journal location. + ## Local development The Quick Start above is enough to run a normal local instance. If you are changing the source code itself, development mode automatically watches the TypeScript entry point: diff --git a/deploy/remote-dev-mcp.env.example b/deploy/remote-dev-mcp.env.example index 1e12dc3..c258c90 100644 --- a/deploy/remote-dev-mcp.env.example +++ b/deploy/remote-dev-mcp.env.example @@ -21,6 +21,9 @@ MCP_MAX_REQUEST_BODY=8mb MCP_MAX_OUTPUT_BYTES=1048576 MCP_MAX_RETAINED_PROCESS_OUTPUT_BYTES=4194304 MCP_PROCESS_RETENTION_MS=3600000 +MCP_PROCESS_IDLE_TIMEOUT_MS=1800000 +MCP_PROCESS_MAX_RUNTIME_MS=14400000 +MCP_TASK_JOURNAL_FILE=/data/task-journal.jsonl MCP_MAX_PROCESSES=128 MCP_MAX_FILE_CHUNK_BYTES=1048576 MCP_MAX_EDIT_FILE_BYTES=67108864 diff --git a/docker-compose.yml b/docker-compose.yml index a44d6fc..b0c75f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,9 @@ services: MCP_PORT: ${MCP_PORT:-3000} MCP_DEFAULT_CWD: ${MCP_DEFAULT_CWD:-/workspace} MCP_OAUTH_STATE_FILE: ${MCP_OAUTH_STATE_FILE:-/data/oauth-state.json} + MCP_TASK_JOURNAL_FILE: ${MCP_TASK_JOURNAL_FILE:-/data/task-journal.jsonl} + MCP_PROCESS_IDLE_TIMEOUT_MS: ${MCP_PROCESS_IDLE_TIMEOUT_MS:-1800000} + MCP_PROCESS_MAX_RUNTIME_MS: ${MCP_PROCESS_MAX_RUNTIME_MS:-14400000} MCP_AUTH_TOKEN: ${MCP_AUTH_TOKEN:-} ports: - "${MCP_LISTEN_HOST:-127.0.0.1}:${MCP_PORT:-3000}:${MCP_PORT:-3000}" diff --git a/docs/docker-compose-guide.ko.md b/docs/docker-compose-guide.ko.md index fb64aca..1c94dfe 100644 --- a/docs/docker-compose-guide.ko.md +++ b/docs/docker-compose-guide.ko.md @@ -342,3 +342,16 @@ docker compose up -d --force-recreate - `MCP_TRUST_PROXY_HOPS`가 실제 프록시 단계와 일치하는가? - OAuth 상태와 SSH 키를 민감정보로 백업·관리하는가? - 정기적으로 이미지를 다시 빌드하고 `npm audit` 결과를 확인하는가? + + +## Process lifecycle and diagnostics + +The Docker setup keeps operational state under `/data`. By default: + +```dotenv +MCP_PROCESS_IDLE_TIMEOUT_MS=1800000 +MCP_PROCESS_MAX_RUNTIME_MS=14400000 +MCP_TASK_JOURNAL_FILE=/data/task-journal.jsonl +``` + +Use `docker compose exec cokacremote npm run doctor` to verify runtime dependencies and workspace access. The task journal is retained in the `cokacremote-data` volume across container restarts. diff --git a/docs/docker-compose-guide.md b/docs/docker-compose-guide.md index ec510b9..2c27743 100644 --- a/docs/docker-compose-guide.md +++ b/docs/docker-compose-guide.md @@ -344,3 +344,16 @@ docker compose up -d --force-recreate - Does `MCP_TRUST_PROXY_HOPS` match the actual proxy chain? - Are OAuth state and SSH keys backed up and handled as sensitive data? - Are images rebuilt and `npm audit` results reviewed regularly? + + +## Process lifecycle and diagnostics + +The Docker setup keeps operational state under `/data`. By default: + +```dotenv +MCP_PROCESS_IDLE_TIMEOUT_MS=1800000 +MCP_PROCESS_MAX_RUNTIME_MS=14400000 +MCP_TASK_JOURNAL_FILE=/data/task-journal.jsonl +``` + +Use `docker compose exec cokacremote npm run doctor` to verify runtime dependencies and workspace access. The task journal is retained in the `cokacremote-data` volume across container restarts. diff --git a/package.json b/package.json index 2a27abd..1743010 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "start": "node dist/src/server.js", "test": "vitest run", "test:watch": "vitest", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit", + "doctor": "tsx src/doctor.ts" }, "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", diff --git a/src/config.ts b/src/config.ts index 48f60a7..dad221a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,6 +26,9 @@ export interface AppConfig { maxOutputBytes: number; maxRetainedProcessOutputBytes: number; processRetentionMs: number; + processIdleTimeoutMs: number; + processMaxRuntimeMs: number; + taskJournalFile: string | undefined; maxProcesses: number; maxFileChunkBytes: number; maxEditFileBytes: number; @@ -216,6 +219,21 @@ export function loadConfig( "MCP_PROCESS_RETENTION_MS", 1000, ), + processIdleTimeoutMs: parseInteger( + env.MCP_PROCESS_IDLE_TIMEOUT_MS, + 30 * 60 * 1000, + "MCP_PROCESS_IDLE_TIMEOUT_MS", + 0, + ), + processMaxRuntimeMs: parseInteger( + env.MCP_PROCESS_MAX_RUNTIME_MS, + 4 * 60 * 60 * 1000, + "MCP_PROCESS_MAX_RUNTIME_MS", + 0, + ), + taskJournalFile: env.MCP_TASK_JOURNAL_FILE?.trim() + ? path.resolve(processCwd, env.MCP_TASK_JOURNAL_FILE.trim()) + : undefined, maxProcesses: parseInteger( env.MCP_MAX_PROCESSES, 128, diff --git a/src/doctor.ts b/src/doctor.ts new file mode 100644 index 0000000..6c603a5 --- /dev/null +++ b/src/doctor.ts @@ -0,0 +1,32 @@ +import { access, constants, mkdir, rm, writeFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { loadConfig } from "./config.js"; + +interface Check { name: string; ok: boolean; detail: string } +function commandCheck(name: string, command: string, args = ["--version"]): Check { + const result = spawnSync(command, args, { encoding: "utf8", timeout: 5000 }); + return { name, ok: result.status === 0, detail: (result.stdout || result.stderr || "not available").trim().split("\n")[0]! }; +} +async function main(): Promise { + const env = { ...process.env }; + if (!env.MCP_AUTH_TOKEN && env.MCP_OAUTH_ENABLED !== "true") env.MCP_ALLOW_NO_AUTH = "true"; + const config = loadConfig(env); + const checks: Check[] = [ + { name: "Node.js", ok: Number(process.versions.node.split(".")[0]) >= 22, detail: process.version }, + commandCheck("Git", "git"), commandCheck("Bash", config.defaultShell), commandCheck("npm", "npm"), commandCheck("Python", "python3"), + ]; + try { + await access(config.defaultCwd, constants.R_OK | constants.W_OK); + const probe = path.join(config.defaultCwd, `.cokacremote-doctor-${process.pid}`); + await mkdir(probe); await writeFile(path.join(probe, "probe"), "ok"); await rm(probe, { recursive: true, force: true }); + checks.push({ name: "Default cwd", ok: true, detail: `${config.defaultCwd} (read/write)` }); + } catch (error) { checks.push({ name: "Default cwd", ok: false, detail: String(error) }); } + checks.push({ name: "Task journal", ok: true, detail: config.taskJournalFile ?? "disabled" }); + console.log("cokacremote doctor\n"); + checks.forEach((c) => console.log(`${c.ok ? "OK" : "FAIL"} ${c.name.padEnd(14)} ${c.detail}`)); + const failed = checks.filter((c) => !c.ok).length; + console.log(`\nSTATUS: ${failed ? `UNHEALTHY (${failed} failed)` : "HEALTHY"}`); + if (failed) process.exitCode = 1; +} +void main(); diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 6cadd40..4e20f17 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -18,6 +18,9 @@ export function createServices(config: AppConfig): McpServices { processRetentionMs: config.processRetentionMs, maxProcesses: config.maxProcesses, defaultMaxOutputBytes: config.maxOutputBytes, + processIdleTimeoutMs: config.processIdleTimeoutMs, + processMaxRuntimeMs: config.processMaxRuntimeMs, + taskJournalFile: config.taskJournalFile, }), fileService: new FileService({ defaultCwd: config.defaultCwd, diff --git a/src/process-manager.ts b/src/process-manager.ts index 4be3e08..ac6bee1 100644 --- a/src/process-manager.ts +++ b/src/process-manager.ts @@ -1,6 +1,8 @@ import { randomUUID } from "node:crypto"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { isAscii } from "node:buffer"; +import { appendFile, mkdir } from "node:fs/promises"; +import path from "node:path"; import { errorMessage } from "./errors.js"; @@ -34,6 +36,7 @@ interface ManagedProcess { waiters: Set<() => void>; exitWaiters: Set<() => void>; timeoutHandle: NodeJS.Timeout | undefined; + idleTimeoutHandle: NodeJS.Timeout | undefined; cleanup: (() => Promise) | undefined; } @@ -174,6 +177,9 @@ export interface ProcessManagerOptions { processRetentionMs: number; maxProcesses: number; defaultMaxOutputBytes: number; + processIdleTimeoutMs?: number; + processMaxRuntimeMs?: number; + taskJournalFile?: string; } export class ProcessManager { @@ -216,6 +222,7 @@ export class ProcessManager { waiters: new Set(), exitWaiters: new Set(), timeoutHandle: undefined, + idleTimeoutHandle: undefined, cleanup: request.cleanup, }; this.#processes.set(sessionId, managed); @@ -237,7 +244,8 @@ export class ProcessManager { this.#finish(managed, code, signal); }); - const timeoutMs = request.timeoutMs ?? 0; + const requestedTimeoutMs = request.timeoutMs ?? 0; + const timeoutMs = requestedTimeoutMs > 0 ? requestedTimeoutMs : (this.#options.processMaxRuntimeMs ?? 0); if (timeoutMs > 0) { managed.timeoutHandle = setTimeout(() => { managed.timedOut = true; @@ -253,6 +261,9 @@ export class ProcessManager { managed.timeoutHandle.unref(); } + this.#resetIdleTimeout(managed); + this.#journal("process.started", managed); + if (request.stdin !== undefined && request.stdin.length > 0) { try { child.stdin.write(request.stdin, (error) => { @@ -495,6 +506,7 @@ export class ProcessManager { data: Buffer, ): void { managed.totalOutputBytes += data.length; + this.#resetIdleTimeout(managed); const pending = managed.pendingOutput[stream]; const combined = pending.length > 0 ? Buffer.concat([pending, data]) : data; const split = splitOutputChunks(combined); @@ -563,6 +575,11 @@ export class ProcessManager { clearTimeout(managed.timeoutHandle); managed.timeoutHandle = undefined; } + if (managed.idleTimeoutHandle) { + clearTimeout(managed.idleTimeoutHandle); + managed.idleTimeoutHandle = undefined; + } + this.#journal("process.completed", managed); this.#notify(managed); const exitWaiters = [...managed.exitWaiters]; managed.exitWaiters.clear(); @@ -576,6 +593,31 @@ export class ProcessManager { } } + #resetIdleTimeout(managed: ManagedProcess): void { + if (managed.idleTimeoutHandle) clearTimeout(managed.idleTimeoutHandle); + const idleTimeoutMs = this.#options.processIdleTimeoutMs ?? 0; + if (idleTimeoutMs <= 0 || !this.#isRunning(managed)) { + managed.idleTimeoutHandle = undefined; + return; + } + managed.idleTimeoutHandle = setTimeout(() => { + managed.timedOut = true; + managed.error ??= `Process was idle for ${idleTimeoutMs} ms`; + this.#journal("process.idle_timeout", managed); + this.#signal(managed, "SIGTERM"); + const forceTimer = setTimeout(() => { if (this.#isRunning(managed)) this.#signal(managed, "SIGKILL"); }, 5000); + forceTimer.unref(); + }, idleTimeoutMs); + managed.idleTimeoutHandle.unref(); + } + + #journal(event: string, managed: ManagedProcess): void { + const file = this.#options.taskJournalFile; + if (!file) return; + const line = JSON.stringify({ timestamp: new Date().toISOString(), event, sessionId: managed.sessionId, command: managed.command, cwd: managed.cwd, pid: managed.child.pid, exitCode: managed.exitCode, signal: managed.signal, timedOut: managed.timedOut, error: managed.error, totalOutputBytes: managed.totalOutputBytes }) + "\n"; + void mkdir(path.dirname(file), { recursive: true }).then(() => appendFile(file, line, { encoding: "utf8", mode: 0o600 })).catch((error) => { managed.error ??= `Journal write failed: ${errorMessage(error)}`; }); + } + #notify(managed: ManagedProcess): void { const waiters = [...managed.waiters]; managed.waiters.clear(); diff --git a/test/config.test.ts b/test/config.test.ts index 6031471..b6a1437 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -128,3 +128,25 @@ describe("loadConfig", () => { ).toThrow("must not contain user credentials"); }); }); + +describe("lifecycle configuration", () => { + it("loads lifecycle defaults and explicit overrides", () => { + const defaults = loadConfig({ MCP_AUTH_TOKEN: "x".repeat(32) }, "/tmp"); + expect(defaults.processIdleTimeoutMs).toBe(30 * 60 * 1000); + expect(defaults.processMaxRuntimeMs).toBe(4 * 60 * 60 * 1000); + expect(defaults.taskJournalFile).toBeUndefined(); + + const configured = loadConfig( + { + MCP_AUTH_TOKEN: "x".repeat(32), + MCP_PROCESS_IDLE_TIMEOUT_MS: "1000", + MCP_PROCESS_MAX_RUNTIME_MS: "2000", + MCP_TASK_JOURNAL_FILE: "journal.jsonl", + }, + "/tmp", + ); + expect(configured.processIdleTimeoutMs).toBe(1000); + expect(configured.processMaxRuntimeMs).toBe(2000); + expect(configured.taskJournalFile).toBe("/tmp/journal.jsonl"); + }); +}); diff --git a/test/process-manager.test.ts b/test/process-manager.test.ts index 2500193..80c69b8 100644 --- a/test/process-manager.test.ts +++ b/test/process-manager.test.ts @@ -133,3 +133,55 @@ describe("ProcessManager", () => { expect(first.output + second.output).not.toContain("�"); }); }); + +describe("ProcessManager lifecycle controls", () => { + it("terminates an idle process using the configured idle timeout", async () => { + const manager = new ProcessManager({ + maxRetainedOutputBytes: 1024 * 1024, + processRetentionMs: 60_000, + maxProcesses: 16, + defaultMaxOutputBytes: 1024 * 1024, + processIdleTimeoutMs: 50, + }); + try { + const sessionId = manager.start({ + executable: "/bin/bash", + args: ["-c", "sleep 10"], + commandForDisplay: "idle timeout test", + cwd: process.cwd(), + }); + await manager.waitForExit(sessionId, 3000); + const result = await manager.read(sessionId); + expect(result.running).toBe(false); + expect(result.timedOut).toBe(true); + expect(result.error).toContain("idle"); + } finally { + await manager.shutdown(); + } + }); + + it("applies the configured maximum runtime when no per-command timeout is supplied", async () => { + const manager = new ProcessManager({ + maxRetainedOutputBytes: 1024 * 1024, + processRetentionMs: 60_000, + maxProcesses: 16, + defaultMaxOutputBytes: 1024 * 1024, + processMaxRuntimeMs: 50, + }); + try { + const sessionId = manager.start({ + executable: "/bin/bash", + args: ["-c", "sleep 10"], + commandForDisplay: "max runtime test", + cwd: process.cwd(), + }); + await manager.waitForExit(sessionId, 3000); + const result = await manager.read(sessionId); + expect(result.running).toBe(false); + expect(result.timedOut).toBe(true); + expect(result.error).toContain("timeout"); + } finally { + await manager.shutdown(); + } + }); +}); From 734f059589adee1e4692c74ad7c1813df8faa47f Mon Sep 17 00:00:00 2001 From: k Date: Mon, 31 Aug 2026 08:46:49 +0000 Subject: [PATCH 07/30] feat: add development task journals --- README.md | 11 +++ src/exec-tools.ts | 10 +++ src/file-tools.ts | 68 +++++++++------- src/mcp-server.ts | 9 ++- src/process-manager.ts | 9 ++- src/script-runner.ts | 2 + src/task-journal.ts | 120 +++++++++++++++++++++++++++++ src/task-tools.ts | 37 +++++++++ test/all-tools.integration.test.ts | 20 +++++ 9 files changed, 255 insertions(+), 31 deletions(-) create mode 100644 src/task-journal.ts create mode 100644 src/task-tools.ts diff --git a/README.md b/README.md index eff20a7..3c67f34 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,17 @@ npm run doctor The doctor checks Node.js, Git, the configured shell, npm, Python, workspace read/write access, and the configured task-journal location. +### Development task journal + +When `MCP_TASK_JOURNAL_FILE` is configured, related MCP calls can be grouped into a durable development task: + +1. Call `start_task` and keep the returned `taskId`. +2. Pass `taskId` to `exec_command`, `run_script`, `write_file`, `replace_in_file`, `apply_patch`, or `upload_file`. +3. Use `get_task` or `list_tasks` to review commands and changed files. +4. Call `complete_task` when the work is finished. + +Calls without `taskId` remain fully backward compatible. The journal stores command metadata and changed paths, not stdout/stderr or file contents. + ## Local development The Quick Start above is enough to run a normal local instance. If you are changing the source code itself, development mode automatically watches the TypeScript entry point: diff --git a/src/exec-tools.ts b/src/exec-tools.ts index 2962f72..5f4cc7d 100644 --- a/src/exec-tools.ts +++ b/src/exec-tools.ts @@ -6,6 +6,7 @@ import { FileService } from "./file-service.js"; import { ProcessManager } from "./process-manager.js"; import { runScript } from "./script-runner.js"; import { runTool } from "./tool-result.js"; +import { TaskJournal } from "./task-journal.js"; const fullAccessAnnotations = { readOnlyHint: false, @@ -26,6 +27,7 @@ export function registerExecTools( config: AppConfig, processManager: ProcessManager, fileService: FileService, + taskJournal: TaskJournal, ): void { const environmentSchema = z .record(z.string(), z.string()) @@ -67,6 +69,7 @@ export function registerExecTools( .max(30_000) .default(10_000) .describe("How long to wait for output before returning a running session."), + taskId: z.string().uuid().optional().describe("Optional development task journal ID."), maxOutputBytes: z .number() .int() @@ -86,9 +89,11 @@ export function registerExecTools( stdin, timeoutMs, yieldTimeMs, + taskId, maxOutputBytes, }) => runTool(async () => { + if (taskId && !(await taskJournal.getTask(taskId))) throw new Error(`Unknown task: ${taskId}`); const cwd = fileService.resolve(".", workdir); const executable = shell || config.defaultShell; const sessionId = processManager.start({ @@ -99,6 +104,7 @@ export function registerExecTools( env, timeoutMs, stdin, + taskId, }); await processManager.waitForExit(sessionId, yieldTimeMs); const result = await processManager.read(sessionId, { @@ -147,6 +153,7 @@ export function registerExecTools( .min(0) .max(30_000) .default(10_000), + taskId: z.string().uuid().optional().describe("Optional development task journal ID."), maxOutputBytes: z .number() .int() @@ -171,10 +178,12 @@ export function registerExecTools( stdin, timeoutMs, yieldTimeMs, + taskId, maxOutputBytes, keepScript, }) => runTool(async () => { + if (taskId && !(await taskJournal.getTask(taskId))) throw new Error(`Unknown task: ${taskId}`); const result = await runScript(processManager, { runtime, script, @@ -188,6 +197,7 @@ export function registerExecTools( yieldTimeMs, maxOutputBytes, keepScript, + taskId, }); return processResult(result); }), diff --git a/src/file-tools.ts b/src/file-tools.ts index 1318579..fb36918 100644 --- a/src/file-tools.ts +++ b/src/file-tools.ts @@ -4,6 +4,7 @@ import * as z from "zod/v4"; import type { AppConfig } from "./config.js"; import { FileService } from "./file-service.js"; import { runTool } from "./tool-result.js"; +import { TaskJournal } from "./task-journal.js"; const readAnnotations = { readOnlyHint: true, @@ -29,6 +30,8 @@ const pathSchema = z .min(1) .describe("Absolute path, ~/ path, or a path relative to cwd/default cwd."); +const taskIdSchema = z.string().uuid().optional().describe("Optional development task journal ID."); + const fileModeSchema = z .string() .regex(/^(?:0o)?[0-7]{3,4}$/) @@ -46,6 +49,7 @@ export function registerFileTools( server: McpServer, config: AppConfig, files: FileService, + journal: TaskJournal, ): void { server.registerTool( "list_directory", @@ -125,21 +129,17 @@ export function registerFileTools( mode: z.enum(["overwrite", "append"]).default("overwrite"), createParents: z.boolean().default(true), fileMode: fileModeSchema, + taskId: taskIdSchema, }, annotations: writeAnnotations, }, - async ({ path, cwd, content, encoding, mode, createParents, fileMode }) => - runTool(() => - files.writeFileContent( - path, - cwd, - content, - encoding, - mode, - createParents, - parseMode(fileMode), - ), - ), + async ({ path, cwd, content, encoding, mode, createParents, fileMode, taskId }) => + runTool(async () => { + if (taskId && !(await journal.getTask(taskId))) throw new Error(`Unknown task: ${taskId}`); + const result = await files.writeFileContent(path, cwd, content, encoding, mode, createParents, parseMode(fileMode)); + if (taskId) await journal.record("file.changed", { taskId, path: files.resolve(path, cwd), operation: "write_file" }); + return result; + }), ); server.registerTool( @@ -155,20 +155,17 @@ export function registerFileTools( newText: z.string(), replaceAll: z.boolean().default(false), expectedOccurrences: z.number().int().min(0).optional(), + taskId: taskIdSchema, }, annotations: writeAnnotations, }, - async ({ path, cwd, oldText, newText, replaceAll, expectedOccurrences }) => - runTool(() => - files.replaceInFile( - path, - cwd, - oldText, - newText, - replaceAll, - expectedOccurrences, - ), - ), + async ({ path, cwd, oldText, newText, replaceAll, expectedOccurrences, taskId }) => + runTool(async () => { + if (taskId && !(await journal.getTask(taskId))) throw new Error(`Unknown task: ${taskId}`); + const result = await files.replaceInFile(path, cwd, oldText, newText, replaceAll, expectedOccurrences); + if (taskId) await journal.record("file.changed", { taskId, path: files.resolve(path, cwd), operation: "replace_in_file" }); + return result; + }), ); server.registerTool( @@ -183,11 +180,20 @@ export function registerFileTools( checkOnly: z.boolean().default(false), reverse: z.boolean().default(false), threeWay: z.boolean().default(false), + taskId: taskIdSchema, }, annotations: writeAnnotations, }, - async ({ patch, cwd, checkOnly, reverse, threeWay }) => - runTool(() => files.applyPatch(patch, cwd, { checkOnly, reverse, threeWay })), + async ({ patch, cwd, checkOnly, reverse, threeWay, taskId }) => + runTool(async () => { + if (taskId && !(await journal.getTask(taskId))) throw new Error(`Unknown task: ${taskId}`); + const result = await files.applyPatch(patch, cwd, { checkOnly, reverse, threeWay }); + if (taskId && !checkOnly) { + const names = [...patch.matchAll(/^\+\+\+\s+(?:b\/)?(.+)$/gm)].map((match) => match[1]!).filter((name) => name !== "/dev/null"); + for (const name of new Set(names)) await journal.record("file.changed", { taskId, path: files.resolve(name, cwd), operation: "apply_patch" }); + } + return result; + }), ); server.registerTool( @@ -203,13 +209,17 @@ export function registerFileTools( offset: z.number().int().min(0).default(0), truncate: z.boolean().default(false), createParents: z.boolean().default(true), + taskId: taskIdSchema, }, annotations: writeAnnotations, }, - async ({ path, cwd, dataBase64, offset, truncate, createParents }) => - runTool(() => - files.uploadChunk(path, cwd, dataBase64, offset, truncate, createParents), - ), + async ({ path, cwd, dataBase64, offset, truncate, createParents, taskId }) => + runTool(async () => { + if (taskId && !(await journal.getTask(taskId))) throw new Error(`Unknown task: ${taskId}`); + const result = await files.uploadChunk(path, cwd, dataBase64, offset, truncate, createParents); + if (taskId) await journal.record("file.changed", { taskId, path: files.resolve(path, cwd), operation: "upload_file" }); + return result; + }), ); server.registerTool( diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 4e20f17..c822315 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -5,13 +5,17 @@ import { registerExecTools } from "./exec-tools.js"; import { FileService } from "./file-service.js"; import { registerFileTools } from "./file-tools.js"; import { ProcessManager } from "./process-manager.js"; +import { TaskJournal } from "./task-journal.js"; +import { registerTaskTools } from "./task-tools.js"; export interface McpServices { processManager: ProcessManager; fileService: FileService; + taskJournal: TaskJournal; } export function createServices(config: AppConfig): McpServices { + const taskJournal = new TaskJournal(config.taskJournalFile); return { processManager: new ProcessManager({ maxRetainedOutputBytes: config.maxRetainedProcessOutputBytes, @@ -22,6 +26,7 @@ export function createServices(config: AppConfig): McpServices { processMaxRuntimeMs: config.processMaxRuntimeMs, taskJournalFile: config.taskJournalFile, }), + taskJournal, fileService: new FileService({ defaultCwd: config.defaultCwd, maxChunkBytes: config.maxFileChunkBytes, @@ -50,7 +55,9 @@ export function createMcpServer(config: AppConfig, services: McpServices): McpSe config, services.processManager, services.fileService, + services.taskJournal, ); - registerFileTools(server, config, services.fileService); + registerFileTools(server, config, services.fileService, services.taskJournal); + registerTaskTools(server, services.taskJournal); return server; } diff --git a/src/process-manager.ts b/src/process-manager.ts index ac6bee1..79d1b14 100644 --- a/src/process-manager.ts +++ b/src/process-manager.ts @@ -38,6 +38,7 @@ interface ManagedProcess { timeoutHandle: NodeJS.Timeout | undefined; idleTimeoutHandle: NodeJS.Timeout | undefined; cleanup: (() => Promise) | undefined; + taskId: string | undefined; } function isContinuationByte(value: number): boolean { @@ -142,6 +143,7 @@ export interface StartProcessRequest { timeoutMs?: number | undefined; stdin?: string | undefined; cleanup?: (() => Promise) | undefined; + taskId?: string | undefined; } export interface ReadProcessRequest { @@ -152,6 +154,7 @@ export interface ReadProcessRequest { export interface ProcessReadResult { sessionId: string; + taskId: string | undefined; command: string; cwd: string; running: boolean; @@ -224,6 +227,7 @@ export class ProcessManager { timeoutHandle: undefined, idleTimeoutHandle: undefined, cleanup: request.cleanup, + taskId: request.taskId, }; this.#processes.set(sessionId, managed); @@ -319,6 +323,7 @@ export class ProcessManager { return { sessionId, + taskId: managed.taskId, command: managed.command, cwd: managed.cwd, running: this.#isRunning(managed), @@ -421,6 +426,7 @@ export class ProcessManager { list(): Array<{ sessionId: string; + taskId: string | undefined; pid: number | undefined; command: string; cwd: string; @@ -432,6 +438,7 @@ export class ProcessManager { this.prune(); return [...this.#processes.values()].map((managed) => ({ sessionId: managed.sessionId, + taskId: managed.taskId, pid: managed.child.pid, command: managed.command, cwd: managed.cwd, @@ -614,7 +621,7 @@ export class ProcessManager { #journal(event: string, managed: ManagedProcess): void { const file = this.#options.taskJournalFile; if (!file) return; - const line = JSON.stringify({ timestamp: new Date().toISOString(), event, sessionId: managed.sessionId, command: managed.command, cwd: managed.cwd, pid: managed.child.pid, exitCode: managed.exitCode, signal: managed.signal, timedOut: managed.timedOut, error: managed.error, totalOutputBytes: managed.totalOutputBytes }) + "\n"; + const line = JSON.stringify({ taskId: managed.taskId, timestamp: new Date().toISOString(), event, sessionId: managed.sessionId, command: managed.command, cwd: managed.cwd, pid: managed.child.pid, exitCode: managed.exitCode, signal: managed.signal, timedOut: managed.timedOut, error: managed.error, totalOutputBytes: managed.totalOutputBytes }) + "\n"; void mkdir(path.dirname(file), { recursive: true }).then(() => appendFile(file, line, { encoding: "utf8", mode: 0o600 })).catch((error) => { managed.error ??= `Journal write failed: ${errorMessage(error)}`; }); } diff --git a/src/script-runner.ts b/src/script-runner.ts index 4207e77..7f39d96 100644 --- a/src/script-runner.ts +++ b/src/script-runner.ts @@ -20,6 +20,7 @@ export interface RunScriptRequest { maxOutputBytes?: number | undefined; stdin?: string | undefined; keepScript?: boolean | undefined; + taskId?: string | undefined; } export interface RunScriptResult extends ProcessReadResult { @@ -90,6 +91,7 @@ export async function runScript( timeoutMs: request.timeoutMs, stdin: request.stdin, cleanup, + taskId: request.taskId, }); } catch (error) { await rm(temporaryDirectory, { recursive: true, force: true }); diff --git a/src/task-journal.ts b/src/task-journal.ts new file mode 100644 index 0000000..6d0b151 --- /dev/null +++ b/src/task-journal.ts @@ -0,0 +1,120 @@ +import { randomUUID } from "node:crypto"; +import { appendFile, mkdir, readFile } from "node:fs/promises"; +import path from "node:path"; + +export interface TaskEvent { + timestamp: string; + event: string; + taskId?: string; + [key: string]: unknown; +} + +export interface TaskSummary { + taskId: string; + title: string; + cwd?: string; + status: "active" | "completed"; + startedAt: string; + endedAt?: string; + commands: Array<{ sessionId?: string; command: string; cwd?: string; exitCode?: number | null; timedOut?: boolean }>; + filesChanged: string[]; + eventCount: number; +} + +export class TaskJournal { + readonly #file: string | undefined; + readonly #memory: TaskEvent[] = []; + + constructor(file?: string) { + this.#file = file; + } + + async startTask(title: string, cwd?: string): Promise { + const taskId = randomUUID(); + await this.record("task.started", { taskId, title, cwd }); + return (await this.getTask(taskId))!; + } + + async completeTask(taskId: string, summary?: string): Promise { + if (!(await this.getTask(taskId))) throw new Error(`Unknown task: ${taskId}`); + await this.record("task.completed", { taskId, summary }); + return (await this.getTask(taskId))!; + } + + async record(event: string, data: Record = {}): Promise { + const entry: TaskEvent = { timestamp: new Date().toISOString(), event, ...data }; + this.#memory.push(entry); + if (!this.#file) return; + await mkdir(path.dirname(this.#file), { recursive: true }); + await appendFile(this.#file, `${JSON.stringify(entry)}\n`, { encoding: "utf8", mode: 0o600 }); + } + + async getTask(taskId: string): Promise { + const tasks = await this.#summaries(); + return tasks.find((task) => task.taskId === taskId); + } + + async listTasks(limit = 50): Promise { + const tasks = await this.#summaries(); + return tasks.sort((a, b) => b.startedAt.localeCompare(a.startedAt)).slice(0, limit); + } + + async #events(): Promise { + if (!this.#file) return [...this.#memory]; + let persisted: TaskEvent[] = []; + try { + const text = await readFile(this.#file, "utf8"); + persisted = text.split("\n").filter(Boolean).flatMap((line) => { + try { return [JSON.parse(line) as TaskEvent]; } catch { return []; } + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const persistedKeys = new Set(persisted.map((e) => JSON.stringify(e))); + return [...persisted, ...this.#memory.filter((e) => !persistedKeys.has(JSON.stringify(e)))]; + } + + async #summaries(): Promise { + const tasks = new Map(); + for (const entry of await this.#events()) { + const taskId = typeof entry.taskId === "string" ? entry.taskId : undefined; + if (!taskId) continue; + if (entry.event === "task.started") { + tasks.set(taskId, { + taskId, + title: typeof entry.title === "string" ? entry.title : "Untitled task", + cwd: typeof entry.cwd === "string" ? entry.cwd : undefined, + status: "active", + startedAt: entry.timestamp, + commands: [], + filesChanged: [], + eventCount: 1, + }); + continue; + } + const task = tasks.get(taskId); + if (!task) continue; + task.eventCount += 1; + if (entry.event === "task.completed") { + task.status = "completed"; + task.endedAt = entry.timestamp; + } else if (entry.event === "process.started") { + task.commands.push({ + sessionId: typeof entry.sessionId === "string" ? entry.sessionId : undefined, + command: typeof entry.command === "string" ? entry.command : "", + cwd: typeof entry.cwd === "string" ? entry.cwd : undefined, + }); + } else if (entry.event === "process.completed") { + const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : undefined; + const command = [...task.commands].reverse().find((item) => item.sessionId === sessionId); + if (command) { + command.exitCode = typeof entry.exitCode === "number" || entry.exitCode === null ? entry.exitCode : undefined; + command.timedOut = entry.timedOut === true; + } + } else if (entry.event === "file.changed" && typeof entry.path === "string") { + if (!task.filesChanged.includes(entry.path)) task.filesChanged.push(entry.path); + } + } + return [...tasks.values()]; + } +} diff --git a/src/task-tools.ts b/src/task-tools.ts new file mode 100644 index 0000000..b6d193e --- /dev/null +++ b/src/task-tools.ts @@ -0,0 +1,37 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import * as z from "zod/v4"; +import { TaskJournal } from "./task-journal.js"; +import { runTool } from "./tool-result.js"; + +const readAnnotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }; +const writeAnnotations = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }; + +export function registerTaskTools(server: McpServer, journal: TaskJournal): void { + server.registerTool("start_task", { + title: "Start development task", + description: "Create a task journal that can group command execution and file changes across MCP calls.", + inputSchema: { title: z.string().min(1).max(200), cwd: z.string().optional() }, annotations: writeAnnotations, + }, async ({ title, cwd }) => runTool(async () => ({ ...(await journal.startTask(title, cwd)) }))); + + server.registerTool("get_task", { + title: "Get development task", + description: "Return a task summary including commands and changed files.", + inputSchema: { taskId: z.string().uuid() }, annotations: readAnnotations, + }, async ({ taskId }) => runTool(async () => { + const task = await journal.getTask(taskId); + if (!task) throw new Error(`Unknown task: ${taskId}`); + return { ...task }; + })); + + server.registerTool("list_tasks", { + title: "List development tasks", + description: "List recent task journals.", + inputSchema: { limit: z.number().int().min(1).max(200).default(50) }, annotations: readAnnotations, + }, async ({ limit }) => runTool(async () => ({ tasks: await journal.listTasks(limit) }))); + + server.registerTool("complete_task", { + title: "Complete development task", + description: "Mark a task journal as completed.", + inputSchema: { taskId: z.string().uuid(), summary: z.string().max(4000).optional() }, annotations: writeAnnotations, + }, async ({ taskId, summary }) => runTool(async () => ({ ...(await journal.completeTask(taskId, summary)) }))); +} diff --git a/test/all-tools.integration.test.ts b/test/all-tools.integration.test.ts index df6991c..f68e65a 100644 --- a/test/all-tools.integration.test.ts +++ b/test/all-tools.integration.test.ts @@ -15,12 +15,15 @@ import { createServices } from "../src/mcp-server.js"; const ALL_TOOLS = [ "apply_patch", "chmod_path", + "complete_task", "copy_path", "download_file", "exec_command", + "get_task", "hash_file", "list_directory", "list_processes", + "list_tasks", "make_directory", "move_path", "read_file", @@ -28,6 +31,7 @@ const ALL_TOOLS = [ "remove_path", "replace_in_file", "run_script", + "start_task", "stat_path", "terminate_process", "upload_file", @@ -117,6 +121,7 @@ describe.sequential("all registered MCP tools", () => { MCP_HOST: "127.0.0.1", MCP_DEFAULT_CWD: localDirectory, MCP_MAX_FILE_CHUNK_BYTES: "65536", + MCP_TASK_JOURNAL_FILE: path.join(localDirectory, "task-journal.jsonl"), }, localDirectory, ); @@ -163,6 +168,21 @@ describe.sequential("all registered MCP tools", () => { } }); + it("groups commands and file changes in a development task", async () => { + const started = await callOk("start_task", { title: "E2E task", cwd: testRoot }); + const taskId = String(started.taskId); + await callOk("exec_command", { cmd: "printf task-command", workdir: testRoot, taskId, yieldTimeMs: 3000 }); + await callOk("write_file", { path: "task-file.txt", cwd: testRoot, content: "tracked", taskId }); + const task = await callOk("get_task", { taskId }); + expect(task).toMatchObject({ taskId, title: "E2E task", status: "active" }); + expect(task.commands).toEqual(expect.arrayContaining([expect.objectContaining({ command: "printf task-command", exitCode: 0 })])); + expect(task.filesChanged).toEqual(expect.arrayContaining([path.join(testRoot, "task-file.txt")])); + const listed = await callOk("list_tasks", { limit: 10 }); + expect(listed.tasks).toEqual(expect.arrayContaining([expect.objectContaining({ taskId })])); + const completed = await callOk("complete_task", { taskId, summary: "done" }); + expect(completed).toMatchObject({ taskId, status: "completed" }); + }); + it("executes, polls, writes to, times out, lists, and terminates processes", async () => { const completed = await callOk("exec_command", { cmd: "printf '%s\\n' \"$E2E_VALUE\"; pwd; printf 'stderr-ok' >&2; exit 7", From 909330825ccfa6301814ea822452e65e15f028ea Mon Sep 17 00:00:00 2001 From: k Date: Mon, 31 Aug 2026 09:02:17 +0000 Subject: [PATCH 08/30] feat: add task event tracing --- src/exec-tools.ts | 11 +++++----- src/file-tools.ts | 21 +++++++++----------- src/task-journal.ts | 21 ++++++++++++++++++-- src/task-tools.ts | 14 +++++++++++++ src/task-tracing.ts | 32 ++++++++++++++++++++++++++++++ test/all-tools.integration.test.ts | 12 +++++++++++ 6 files changed, 91 insertions(+), 20 deletions(-) create mode 100644 src/task-tracing.ts diff --git a/src/exec-tools.ts b/src/exec-tools.ts index 5f4cc7d..01a31c1 100644 --- a/src/exec-tools.ts +++ b/src/exec-tools.ts @@ -7,6 +7,7 @@ import { ProcessManager } from "./process-manager.js"; import { runScript } from "./script-runner.js"; import { runTool } from "./tool-result.js"; import { TaskJournal } from "./task-journal.js"; +import { traceTaskTool } from "./task-tracing.js"; const fullAccessAnnotations = { readOnlyHint: false, @@ -92,8 +93,7 @@ export function registerExecTools( taskId, maxOutputBytes, }) => - runTool(async () => { - if (taskId && !(await taskJournal.getTask(taskId))) throw new Error(`Unknown task: ${taskId}`); + runTool(() => traceTaskTool(taskJournal, taskId, "exec_command", async () => { const cwd = fileService.resolve(".", workdir); const executable = shell || config.defaultShell; const sessionId = processManager.start({ @@ -111,7 +111,7 @@ export function registerExecTools( maxOutputBytes, }); return processResult(result); - }), + })), ); server.registerTool( @@ -182,8 +182,7 @@ export function registerExecTools( maxOutputBytes, keepScript, }) => - runTool(async () => { - if (taskId && !(await taskJournal.getTask(taskId))) throw new Error(`Unknown task: ${taskId}`); + runTool(() => traceTaskTool(taskJournal, taskId, "run_script", async () => { const result = await runScript(processManager, { runtime, script, @@ -200,7 +199,7 @@ export function registerExecTools( taskId, }); return processResult(result); - }), + })), ); server.registerTool( diff --git a/src/file-tools.ts b/src/file-tools.ts index fb36918..7629998 100644 --- a/src/file-tools.ts +++ b/src/file-tools.ts @@ -5,6 +5,7 @@ import type { AppConfig } from "./config.js"; import { FileService } from "./file-service.js"; import { runTool } from "./tool-result.js"; import { TaskJournal } from "./task-journal.js"; +import { traceTaskTool } from "./task-tracing.js"; const readAnnotations = { readOnlyHint: true, @@ -134,12 +135,11 @@ export function registerFileTools( annotations: writeAnnotations, }, async ({ path, cwd, content, encoding, mode, createParents, fileMode, taskId }) => - runTool(async () => { - if (taskId && !(await journal.getTask(taskId))) throw new Error(`Unknown task: ${taskId}`); + runTool(() => traceTaskTool(journal, taskId, "write_file", async () => { const result = await files.writeFileContent(path, cwd, content, encoding, mode, createParents, parseMode(fileMode)); if (taskId) await journal.record("file.changed", { taskId, path: files.resolve(path, cwd), operation: "write_file" }); return result; - }), + })), ); server.registerTool( @@ -160,12 +160,11 @@ export function registerFileTools( annotations: writeAnnotations, }, async ({ path, cwd, oldText, newText, replaceAll, expectedOccurrences, taskId }) => - runTool(async () => { - if (taskId && !(await journal.getTask(taskId))) throw new Error(`Unknown task: ${taskId}`); + runTool(() => traceTaskTool(journal, taskId, "replace_in_file", async () => { const result = await files.replaceInFile(path, cwd, oldText, newText, replaceAll, expectedOccurrences); if (taskId) await journal.record("file.changed", { taskId, path: files.resolve(path, cwd), operation: "replace_in_file" }); return result; - }), + })), ); server.registerTool( @@ -185,15 +184,14 @@ export function registerFileTools( annotations: writeAnnotations, }, async ({ patch, cwd, checkOnly, reverse, threeWay, taskId }) => - runTool(async () => { - if (taskId && !(await journal.getTask(taskId))) throw new Error(`Unknown task: ${taskId}`); + runTool(() => traceTaskTool(journal, taskId, "apply_patch", async () => { const result = await files.applyPatch(patch, cwd, { checkOnly, reverse, threeWay }); if (taskId && !checkOnly) { const names = [...patch.matchAll(/^\+\+\+\s+(?:b\/)?(.+)$/gm)].map((match) => match[1]!).filter((name) => name !== "/dev/null"); for (const name of new Set(names)) await journal.record("file.changed", { taskId, path: files.resolve(name, cwd), operation: "apply_patch" }); } return result; - }), + })), ); server.registerTool( @@ -214,12 +212,11 @@ export function registerFileTools( annotations: writeAnnotations, }, async ({ path, cwd, dataBase64, offset, truncate, createParents, taskId }) => - runTool(async () => { - if (taskId && !(await journal.getTask(taskId))) throw new Error(`Unknown task: ${taskId}`); + runTool(() => traceTaskTool(journal, taskId, "upload_file", async () => { const result = await files.uploadChunk(path, cwd, dataBase64, offset, truncate, createParents); if (taskId) await journal.record("file.changed", { taskId, path: files.resolve(path, cwd), operation: "upload_file" }); return result; - }), + })), ); server.registerTool( diff --git a/src/task-journal.ts b/src/task-journal.ts index 6d0b151..58a449c 100644 --- a/src/task-journal.ts +++ b/src/task-journal.ts @@ -3,6 +3,7 @@ import { appendFile, mkdir, readFile } from "node:fs/promises"; import path from "node:path"; export interface TaskEvent { + seq: number; timestamp: string; event: string; taskId?: string; @@ -24,6 +25,7 @@ export interface TaskSummary { export class TaskJournal { readonly #file: string | undefined; readonly #memory: TaskEvent[] = []; + #nextSeq = 1; constructor(file?: string) { this.#file = file; @@ -42,13 +44,21 @@ export class TaskJournal { } async record(event: string, data: Record = {}): Promise { - const entry: TaskEvent = { timestamp: new Date().toISOString(), event, ...data }; + const entry: TaskEvent = { seq: this.#nextSeq++, timestamp: new Date().toISOString(), event, ...data }; this.#memory.push(entry); if (!this.#file) return; await mkdir(path.dirname(this.#file), { recursive: true }); await appendFile(this.#file, `${JSON.stringify(entry)}\n`, { encoding: "utf8", mode: 0o600 }); } + async getTaskEvents(taskId: string, afterSeq = 0, limit = 200): Promise { + if (!(await this.getTask(taskId))) throw new Error(`Unknown task: ${taskId}`); + return (await this.#events()) + .filter((entry) => entry.taskId === taskId && entry.seq > afterSeq) + .sort((a, b) => a.seq - b.seq) + .slice(0, limit); + } + async getTask(taskId: string): Promise { const tasks = await this.#summaries(); return tasks.find((task) => task.taskId === taskId); @@ -64,9 +74,16 @@ export class TaskJournal { let persisted: TaskEvent[] = []; try { const text = await readFile(this.#file, "utf8"); + let fallbackSeq = 1; persisted = text.split("\n").filter(Boolean).flatMap((line) => { - try { return [JSON.parse(line) as TaskEvent]; } catch { return []; } + try { + const parsed = JSON.parse(line) as Partial; + const seq = typeof parsed.seq === "number" ? parsed.seq : fallbackSeq; + fallbackSeq = Math.max(fallbackSeq + 1, seq + 1); + return [{ ...parsed, seq } as TaskEvent]; + } catch { return []; } }); + this.#nextSeq = Math.max(this.#nextSeq, fallbackSeq); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } diff --git a/src/task-tools.ts b/src/task-tools.ts index b6d193e..617c8a7 100644 --- a/src/task-tools.ts +++ b/src/task-tools.ts @@ -23,6 +23,20 @@ export function registerTaskTools(server: McpServer, journal: TaskJournal): void return { ...task }; })); + server.registerTool("get_task_events", { + title: "Get task event timeline", + description: "Return ordered tracing events for a development task. Use afterSeq for incremental polling.", + inputSchema: { + taskId: z.string().uuid(), + afterSeq: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(1000).default(200), + }, + annotations: readAnnotations, + }, async ({ taskId, afterSeq, limit }) => runTool(async () => { + const events = await journal.getTaskEvents(taskId, afterSeq, limit); + return { events, nextSeq: events.at(-1)?.seq ?? afterSeq }; + })); + server.registerTool("list_tasks", { title: "List development tasks", description: "List recent task journals.", diff --git a/src/task-tracing.ts b/src/task-tracing.ts new file mode 100644 index 0000000..6028166 --- /dev/null +++ b/src/task-tracing.ts @@ -0,0 +1,32 @@ +import { TaskJournal } from "./task-journal.js"; +import { errorMessage } from "./errors.js"; + +export async function traceTaskTool( + journal: TaskJournal, + taskId: string | undefined, + toolName: string, + operation: () => Promise, +): Promise { + if (!taskId) return operation(); + const task = await journal.getTask(taskId); + if (!task) throw new Error(`Unknown task: ${taskId}`); + const started = performance.now(); + await journal.record("tool.started", { taskId, toolName }); + try { + const result = await operation(); + await journal.record("tool.completed", { + taskId, + toolName, + durationMs: Math.round((performance.now() - started) * 10) / 10, + }); + return result; + } catch (error) { + await journal.record("tool.failed", { + taskId, + toolName, + durationMs: Math.round((performance.now() - started) * 10) / 10, + error: errorMessage(error), + }); + throw error; + } +} diff --git a/test/all-tools.integration.test.ts b/test/all-tools.integration.test.ts index f68e65a..83a52f4 100644 --- a/test/all-tools.integration.test.ts +++ b/test/all-tools.integration.test.ts @@ -20,6 +20,7 @@ const ALL_TOOLS = [ "download_file", "exec_command", "get_task", + "get_task_events", "hash_file", "list_directory", "list_processes", @@ -177,6 +178,17 @@ describe.sequential("all registered MCP tools", () => { expect(task).toMatchObject({ taskId, title: "E2E task", status: "active" }); expect(task.commands).toEqual(expect.arrayContaining([expect.objectContaining({ command: "printf task-command", exitCode: 0 })])); expect(task.filesChanged).toEqual(expect.arrayContaining([path.join(testRoot, "task-file.txt")])); + const timeline = await callOk("get_task_events", { taskId }); + const events = timeline.events as Array>; + expect(events.map((event) => event.event)).toEqual(expect.arrayContaining([ + "task.started", "tool.started", "process.started", "process.completed", "tool.completed", "file.changed", + ])); + expect(events.map((event) => Number(event.seq))).toEqual([...events.map((event) => Number(event.seq))].sort((a, b) => a - b)); + expect(events.find((event) => event.event === "tool.completed" && event.toolName === "exec_command")?.durationMs).toEqual(expect.any(Number)); + const cursor = Number(events.at(-1)?.seq ?? 0); + const incremental = await callOk("get_task_events", { taskId, afterSeq: cursor }); + expect(incremental.events).toEqual([]); + expect(incremental.nextSeq).toBe(cursor); const listed = await callOk("list_tasks", { limit: 10 }); expect(listed.tasks).toEqual(expect.arrayContaining([expect.objectContaining({ taskId })])); const completed = await callOk("complete_task", { taskId, summary: "done" }); From f21ae992d59ce05566eebf561daac30e92b87857 Mon Sep 17 00:00:00 2001 From: k Date: Mon, 31 Aug 2026 09:03:07 +0000 Subject: [PATCH 09/30] feat: add task timeline dashboard --- README.md | 4 +++ src/dashboard.ts | 42 ++++++++++++++++++++++++++++++ src/http-server.ts | 2 ++ test/dashboard.integration.test.ts | 37 ++++++++++++++++++++++++++ 4 files changed, 85 insertions(+) create mode 100644 src/dashboard.ts create mode 100644 test/dashboard.integration.test.ts diff --git a/README.md b/README.md index 3c67f34..79c3873 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,10 @@ The server provides 20 tools in total. `remove_path` permanently deletes targets - Python 3 if Python execution through `run_script` is needed - A stable, publicly accessible HTTPS domain when connecting directly from ChatGPT +## Task dashboard + +Open `/dashboard` on the same server to inspect recent development tasks, summaries, and their ordered event timelines. The dashboard uses the same bearer/OAuth authentication as the MCP endpoint and refreshes every three seconds. Its JSON endpoints under `/dashboard/api/tasks` can also be used by external monitoring UIs. + ## Reliability controls Three operational controls are available for long-running development sessions: diff --git a/src/dashboard.ts b/src/dashboard.ts new file mode 100644 index 0000000..5a33ff3 --- /dev/null +++ b/src/dashboard.ts @@ -0,0 +1,42 @@ +import type { Express, Request, Response } from "express"; +import type { AppConfig } from "./config.js"; +import type { TaskJournal } from "./task-journal.js"; + +type Middleware = (request: Request, response: Response, next: () => void) => void; + +const dashboardHtml = ` + +cokacremote tasks
cokacremote
Development task timeline
refreshing...
Select a task to inspect its timeline.
+`; + +export function registerDashboard(app: Express, config: AppConfig, journal: TaskJournal, authenticate: Middleware): void { + const base = "/dashboard"; + app.get(base, authenticate, (_request, response) => response.type("html").send(dashboardHtml)); + app.get(`${base}/api/tasks`, authenticate, async (_request, response) => response.json({ tasks: await journal.listTasks(100) })); + app.get(`${base}/api/tasks/:taskId`, authenticate, async (request, response) => { + const taskId = String(request.params.taskId ?? ""); + const task = await journal.getTask(taskId); + if (!task) { response.status(404).json({ error: "Task not found" }); return; } + response.json(task); + }); + app.get(`${base}/api/tasks/:taskId/events`, authenticate, async (request, response) => { + try { + const afterSeq = Math.max(0, Number(request.query.afterSeq) || 0); + const taskId = String(request.params.taskId ?? ""); + const events = await journal.getTaskEvents(taskId, afterSeq, 1000); + response.json({ events, nextSeq: events.at(-1)?.seq ?? afterSeq }); + } catch (error) { + response.status(404).json({ error: error instanceof Error ? error.message : "Task not found" }); + } + }); + void config; +} diff --git a/src/http-server.ts b/src/http-server.ts index 1d076ae..36ced9b 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -12,6 +12,7 @@ import express, { type Request, type Response } from "express"; import { createBearerAuth, createHostValidation } from "./auth.js"; import type { AppConfig } from "./config.js"; import { errorMessage } from "./errors.js"; +import { registerDashboard } from "./dashboard.js"; import { createMcpServer, type McpServices } from "./mcp-server.js"; import { OAUTH_SCOPES, RemoteDevOAuthProvider } from "./oauth.js"; @@ -157,6 +158,7 @@ export async function startHttpServer( } const authenticate = createBearerAuth(config, oauthProvider); const parseMcpJson = express.json({ limit: config.maxRequestBody }); + registerDashboard(app, config, services.taskJournal, authenticate); app.get("/health", (_request, response) => { response.json({ diff --git a/test/dashboard.integration.test.ts b/test/dashboard.integration.test.ts new file mode 100644 index 0000000..f918eca --- /dev/null +++ b/test/dashboard.integration.test.ts @@ -0,0 +1,37 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import type { AddressInfo } from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadConfig } from "../src/config.js"; +import { startHttpServer, type RunningHttpServer } from "../src/http-server.js"; +import { createServices } from "../src/mcp-server.js"; + +describe("task dashboard", () => { + let running: RunningHttpServer | undefined; + let dir: string | undefined; + afterEach(async () => { await running?.close(); running = undefined; if (dir) await rm(dir, { recursive: true, force: true }); }); + + it("protects and serves task timeline APIs", async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "cokacremote-dashboard-")); + const token = "dashboard-test-secret-0123456789abcdef"; + const config = loadConfig({ MCP_AUTH_TOKEN: token, MCP_HOST: "127.0.0.1", MCP_DEFAULT_CWD: dir }, dir); + config.port = 0; + const services = createServices(config); + const task = await services.taskJournal.startTask("Dashboard test", dir); + await services.taskJournal.record("tool.started", { taskId: task.taskId, toolName: "exec_command" }); + await services.taskJournal.record("tool.completed", { taskId: task.taskId, toolName: "exec_command", durationMs: 12.3 }); + running = await startHttpServer(config, services); + const address = running.httpServer.address() as AddressInfo; + const base = `http://127.0.0.1:${address.port}/dashboard`; + expect((await fetch(base)).status).toBe(401); + const headers = { authorization: `Bearer ${token}` }; + const html = await fetch(base, { headers }); + expect(html.status).toBe(200); + expect(await html.text()).toContain("Development task timeline"); + const tasks = await (await fetch(`${base}/api/tasks`, { headers })).json() as { tasks: Array<{ taskId: string }> }; + expect(tasks.tasks.some((item) => item.taskId === task.taskId)).toBe(true); + const events = await (await fetch(`${base}/api/tasks/${task.taskId}/events`, { headers })).json() as { events: Array<{ event: string }> }; + expect(events.events.map((event) => event.event)).toEqual(["task.started", "tool.started", "tool.completed"]); + }); +}); From 2c6a824395eb31bb9f557d7b3b271addf404b3a5 Mon Sep 17 00:00:00 2001 From: k Date: Mon, 31 Aug 2026 09:08:45 +0000 Subject: [PATCH 10/30] feat: add optional safety approval policy --- .env.example | 3 + README.md | 4 + deploy/remote-dev-mcp.env.example | 3 + docker-compose.yml | 1 + src/config.ts | 10 ++ src/dashboard.ts | 13 ++- src/exec-tools.ts | 8 ++ src/file-tools.ts | 57 +++++++++--- src/http-server.ts | 2 +- src/mcp-server.ts | 6 +- src/safety-policy.ts | 144 +++++++++++++++++++++++++++++ test/config.test.ts | 8 ++ test/dashboard.integration.test.ts | 6 ++ test/safety-policy.test.ts | 31 +++++++ 14 files changed, 277 insertions(+), 19 deletions(-) create mode 100644 src/safety-policy.ts create mode 100644 test/safety-policy.test.ts diff --git a/.env.example b/.env.example index 239efcc..1907bcd 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,6 @@ MCP_OAUTH_STATE_FILE=/data/oauth-state.json MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS=3600 MCP_OAUTH_REFRESH_TOKEN_TTL_SECONDS=2592000 MCP_OAUTH_AUTHORIZATION_CODE_TTL_SECONDS=300 + +# unrestricted (default) or safe (human approval for risky operations) +MCP_SAFETY_MODE=unrestricted diff --git a/README.md b/README.md index 79c3873..9114af0 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,10 @@ The server provides 20 tools in total. `remove_path` permanently deletes targets - Python 3 if Python execution through `run_script` is needed - A stable, publicly accessible HTTPS domain when connecting directly from ChatGPT +## Safety policy + +`MCP_SAFETY_MODE=unrestricted` preserves the original full-access behavior. Set `MCP_SAFETY_MODE=safe` to require a one-time human approval for risky shell commands and writes outside `MCP_DEFAULT_CWD`. Extremely destructive host commands such as filesystem formatting, reboot/shutdown, raw device overwrite, and root recursive deletion are denied. Pending approvals appear in `/dashboard`, expire after 10 minutes, and are consumed once when the tool retries with the returned `approvalId`. + ## Task dashboard Open `/dashboard` on the same server to inspect recent development tasks, summaries, and their ordered event timelines. The dashboard uses the same bearer/OAuth authentication as the MCP endpoint and refreshes every three seconds. Its JSON endpoints under `/dashboard/api/tasks` can also be used by external monitoring UIs. diff --git a/deploy/remote-dev-mcp.env.example b/deploy/remote-dev-mcp.env.example index c258c90..06bfe98 100644 --- a/deploy/remote-dev-mcp.env.example +++ b/deploy/remote-dev-mcp.env.example @@ -27,3 +27,6 @@ MCP_TASK_JOURNAL_FILE=/data/task-journal.jsonl MCP_MAX_PROCESSES=128 MCP_MAX_FILE_CHUNK_BYTES=1048576 MCP_MAX_EDIT_FILE_BYTES=67108864 + +# unrestricted (default) or safe (human approval for risky operations) +MCP_SAFETY_MODE=unrestricted diff --git a/docker-compose.yml b/docker-compose.yml index b0c75f8..54a3614 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,7 @@ services: MCP_TASK_JOURNAL_FILE: ${MCP_TASK_JOURNAL_FILE:-/data/task-journal.jsonl} MCP_PROCESS_IDLE_TIMEOUT_MS: ${MCP_PROCESS_IDLE_TIMEOUT_MS:-1800000} MCP_PROCESS_MAX_RUNTIME_MS: ${MCP_PROCESS_MAX_RUNTIME_MS:-14400000} + MCP_SAFETY_MODE: ${MCP_SAFETY_MODE:-unrestricted} MCP_AUTH_TOKEN: ${MCP_AUTH_TOKEN:-} ports: - "${MCP_LISTEN_HOST:-127.0.0.1}:${MCP_PORT:-3000}:${MCP_PORT:-3000}" diff --git a/src/config.ts b/src/config.ts index dad221a..85f6f9a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -32,6 +32,7 @@ export interface AppConfig { maxProcesses: number; maxFileChunkBytes: number; maxEditFileBytes: number; + safetyMode: "unrestricted" | "safe"; } function parseBoolean(value: string | undefined, fallback: boolean): boolean { @@ -68,6 +69,14 @@ function parseInteger( return parsed; } +function parseSafetyMode(value: string | undefined): "unrestricted" | "safe" { + const mode = value?.trim().toLowerCase() || "unrestricted"; + if (mode !== "unrestricted" && mode !== "safe") { + throw new Error("MCP_SAFETY_MODE must be 'unrestricted' or 'safe'"); + } + return mode; +} + function validateAuthSecret(value: string | undefined, name: string): void { if (value === undefined) { return; @@ -246,6 +255,7 @@ export function loadConfig( "MCP_MAX_FILE_CHUNK_BYTES", 4096, ), + safetyMode: parseSafetyMode(env.MCP_SAFETY_MODE), maxEditFileBytes: parseInteger( env.MCP_MAX_EDIT_FILE_BYTES, 64 * 1024 * 1024, diff --git a/src/dashboard.ts b/src/dashboard.ts index 5a33ff3..0cacbb0 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -1,6 +1,7 @@ import type { Express, Request, Response } from "express"; import type { AppConfig } from "./config.js"; import type { TaskJournal } from "./task-journal.js"; +import type { SafetyPolicy } from "./safety-policy.js"; type Middleware = (request: Request, response: Response, next: () => void) => void; @@ -8,20 +9,26 @@ const dashboardHtml = ` cokacremote tasks
cokacremote
Development task timeline
refreshing...
Select a task to inspect its timeline.
+
cokacremote
Development task timeline
refreshing...
Select a task to inspect its timeline.
`; -export function registerDashboard(app: Express, config: AppConfig, journal: TaskJournal, authenticate: Middleware): void { +export function registerDashboard(app: Express, config: AppConfig, journal: TaskJournal, safetyPolicy: SafetyPolicy, authenticate: Middleware): void { const base = "/dashboard"; app.get(base, authenticate, (_request, response) => response.type("html").send(dashboardHtml)); app.get(`${base}/api/tasks`, authenticate, async (_request, response) => response.json({ tasks: await journal.listTasks(100) })); + app.get(`${base}/api/approvals`, authenticate, (_request, response) => response.json({ mode: safetyPolicy.mode, approvals: safetyPolicy.list() })); + app.post(`${base}/api/approvals/:approvalId/approve`, authenticate, (request, response) => { + try { response.json(safetyPolicy.approve(String(request.params.approvalId ?? ""))); } + catch (error) { response.status(404).json({ error: error instanceof Error ? error.message : "Approval not found" }); } + }); app.get(`${base}/api/tasks/:taskId`, authenticate, async (request, response) => { const taskId = String(request.params.taskId ?? ""); const task = await journal.getTask(taskId); diff --git a/src/exec-tools.ts b/src/exec-tools.ts index 01a31c1..72d230b 100644 --- a/src/exec-tools.ts +++ b/src/exec-tools.ts @@ -8,6 +8,7 @@ import { runScript } from "./script-runner.js"; import { runTool } from "./tool-result.js"; import { TaskJournal } from "./task-journal.js"; import { traceTaskTool } from "./task-tracing.js"; +import { enforceAssessment, SafetyPolicy } from "./safety-policy.js"; const fullAccessAnnotations = { readOnlyHint: false, @@ -29,6 +30,7 @@ export function registerExecTools( processManager: ProcessManager, fileService: FileService, taskJournal: TaskJournal, + safetyPolicy: SafetyPolicy, ): void { const environmentSchema = z .record(z.string(), z.string()) @@ -71,6 +73,7 @@ export function registerExecTools( .default(10_000) .describe("How long to wait for output before returning a running session."), taskId: z.string().uuid().optional().describe("Optional development task journal ID."), + approvalId: z.string().uuid().optional().describe("Approved one-time safety approval ID for a risky operation."), maxOutputBytes: z .number() .int() @@ -91,9 +94,11 @@ export function registerExecTools( timeoutMs, yieldTimeMs, taskId, + approvalId, maxOutputBytes, }) => runTool(() => traceTaskTool(taskJournal, taskId, "exec_command", async () => { + enforceAssessment(safetyPolicy, safetyPolicy.assessCommand(cmd), "exec_command", cmd.slice(0, 300), approvalId); const cwd = fileService.resolve(".", workdir); const executable = shell || config.defaultShell; const sessionId = processManager.start({ @@ -154,6 +159,7 @@ export function registerExecTools( .max(30_000) .default(10_000), taskId: z.string().uuid().optional().describe("Optional development task journal ID."), + approvalId: z.string().uuid().optional().describe("Approved one-time safety approval ID for a risky operation."), maxOutputBytes: z .number() .int() @@ -179,10 +185,12 @@ export function registerExecTools( timeoutMs, yieldTimeMs, taskId, + approvalId, maxOutputBytes, keepScript, }) => runTool(() => traceTaskTool(taskJournal, taskId, "run_script", async () => { + enforceAssessment(safetyPolicy, safetyPolicy.assessCommand(script), "run_script", `${runtime} script: ${script.slice(0, 240)}`, approvalId); const result = await runScript(processManager, { runtime, script, diff --git a/src/file-tools.ts b/src/file-tools.ts index 7629998..c9831a6 100644 --- a/src/file-tools.ts +++ b/src/file-tools.ts @@ -6,6 +6,7 @@ import { FileService } from "./file-service.js"; import { runTool } from "./tool-result.js"; import { TaskJournal } from "./task-journal.js"; import { traceTaskTool } from "./task-tracing.js"; +import { enforceAssessment, SafetyPolicy } from "./safety-policy.js"; const readAnnotations = { readOnlyHint: true, @@ -32,6 +33,7 @@ const pathSchema = z .describe("Absolute path, ~/ path, or a path relative to cwd/default cwd."); const taskIdSchema = z.string().uuid().optional().describe("Optional development task journal ID."); +const approvalIdSchema = z.string().uuid().optional().describe("Approved one-time safety approval ID for a risky operation."); const fileModeSchema = z .string() @@ -39,6 +41,11 @@ const fileModeSchema = z .optional() .describe("Unix mode written as an octal string, for example 0755."); +function enforcePath(policy: SafetyPolicy, files: FileService, toolName: string, target: string, cwd: string | undefined, approvalId: string | undefined): void { + const resolved = files.resolve(target, cwd); + enforceAssessment(policy, policy.assessPath(toolName, resolved), toolName, resolved, approvalId); +} + function parseMode(mode: string | undefined): number | undefined { if (mode === undefined) { return undefined; @@ -51,6 +58,7 @@ export function registerFileTools( config: AppConfig, files: FileService, journal: TaskJournal, + safetyPolicy: SafetyPolicy, ): void { server.registerTool( "list_directory", @@ -131,11 +139,13 @@ export function registerFileTools( createParents: z.boolean().default(true), fileMode: fileModeSchema, taskId: taskIdSchema, + approvalId: approvalIdSchema, }, annotations: writeAnnotations, }, - async ({ path, cwd, content, encoding, mode, createParents, fileMode, taskId }) => + async ({ path, cwd, content, encoding, mode, createParents, fileMode, taskId, approvalId }) => runTool(() => traceTaskTool(journal, taskId, "write_file", async () => { + enforcePath(safetyPolicy, files, "write_file", path, cwd, approvalId); const result = await files.writeFileContent(path, cwd, content, encoding, mode, createParents, parseMode(fileMode)); if (taskId) await journal.record("file.changed", { taskId, path: files.resolve(path, cwd), operation: "write_file" }); return result; @@ -156,11 +166,13 @@ export function registerFileTools( replaceAll: z.boolean().default(false), expectedOccurrences: z.number().int().min(0).optional(), taskId: taskIdSchema, + approvalId: approvalIdSchema, }, annotations: writeAnnotations, }, - async ({ path, cwd, oldText, newText, replaceAll, expectedOccurrences, taskId }) => + async ({ path, cwd, oldText, newText, replaceAll, expectedOccurrences, taskId, approvalId }) => runTool(() => traceTaskTool(journal, taskId, "replace_in_file", async () => { + enforcePath(safetyPolicy, files, "replace_in_file", path, cwd, approvalId); const result = await files.replaceInFile(path, cwd, oldText, newText, replaceAll, expectedOccurrences); if (taskId) await journal.record("file.changed", { taskId, path: files.resolve(path, cwd), operation: "replace_in_file" }); return result; @@ -180,11 +192,13 @@ export function registerFileTools( reverse: z.boolean().default(false), threeWay: z.boolean().default(false), taskId: taskIdSchema, + approvalId: approvalIdSchema, }, annotations: writeAnnotations, }, - async ({ patch, cwd, checkOnly, reverse, threeWay, taskId }) => + async ({ patch, cwd, checkOnly, reverse, threeWay, taskId, approvalId }) => runTool(() => traceTaskTool(journal, taskId, "apply_patch", async () => { + if (!checkOnly) enforceAssessment(safetyPolicy, safetyPolicy.assessPath("apply_patch", files.resolve(".", cwd)), "apply_patch", `patch in ${files.resolve(".", cwd)}`, approvalId); const result = await files.applyPatch(patch, cwd, { checkOnly, reverse, threeWay }); if (taskId && !checkOnly) { const names = [...patch.matchAll(/^\+\+\+\s+(?:b\/)?(.+)$/gm)].map((match) => match[1]!).filter((name) => name !== "/dev/null"); @@ -208,11 +222,13 @@ export function registerFileTools( truncate: z.boolean().default(false), createParents: z.boolean().default(true), taskId: taskIdSchema, + approvalId: approvalIdSchema, }, annotations: writeAnnotations, }, - async ({ path, cwd, dataBase64, offset, truncate, createParents, taskId }) => + async ({ path, cwd, dataBase64, offset, truncate, createParents, taskId, approvalId }) => runTool(() => traceTaskTool(journal, taskId, "upload_file", async () => { + enforcePath(safetyPolicy, files, "upload_file", path, cwd, approvalId); const result = await files.uploadChunk(path, cwd, dataBase64, offset, truncate, createParents); if (taskId) await journal.record("file.changed", { taskId, path: files.resolve(path, cwd), operation: "upload_file" }); return result; @@ -252,11 +268,12 @@ export function registerFileTools( cwd: cwdSchema, recursive: z.boolean().default(true), mode: fileModeSchema, + approvalId: approvalIdSchema, }, annotations: writeAnnotations, }, - async ({ path, cwd, recursive, mode }) => - runTool(() => files.makeDirectory(path, cwd, recursive, parseMode(mode))), + async ({ path, cwd, recursive, mode, approvalId }) => + runTool(() => { enforcePath(safetyPolicy, files, "make_directory", path, cwd, approvalId); return files.makeDirectory(path, cwd, recursive, parseMode(mode)); }), ); server.registerTool( @@ -270,11 +287,12 @@ export function registerFileTools( cwd: cwdSchema, recursive: z.boolean().default(true), force: z.boolean().default(true), + approvalId: approvalIdSchema, }, annotations: writeAnnotations, }, - async ({ sourcePath, destinationPath, cwd, recursive, force }) => - runTool(() => files.copyPath(sourcePath, destinationPath, cwd, recursive, force)), + async ({ sourcePath, destinationPath, cwd, recursive, force, approvalId }) => + runTool(() => { enforcePath(safetyPolicy, files, "copy_path", destinationPath, cwd, approvalId); return files.copyPath(sourcePath, destinationPath, cwd, recursive, force); }), ); server.registerTool( @@ -287,11 +305,20 @@ export function registerFileTools( destinationPath: pathSchema, cwd: cwdSchema, overwrite: z.boolean().default(false), + approvalId: approvalIdSchema, }, annotations: writeAnnotations, }, - async ({ sourcePath, destinationPath, cwd, overwrite }) => - runTool(() => files.movePath(sourcePath, destinationPath, cwd, overwrite)), + async ({ sourcePath, destinationPath, cwd, overwrite, approvalId }) => + runTool(() => { + const source = files.resolve(sourcePath, cwd); + const destination = files.resolve(destinationPath, cwd); + const sourceAssessment = safetyPolicy.assessPath("move_path", source); + const destinationAssessment = safetyPolicy.assessPath("move_path", destination); + const assessment = sourceAssessment.decision === "deny" ? sourceAssessment : destinationAssessment.decision === "deny" ? destinationAssessment : sourceAssessment.decision === "approval-required" ? sourceAssessment : destinationAssessment; + enforceAssessment(safetyPolicy, assessment, "move_path", `${source} -> ${destination}`, approvalId); + return files.movePath(sourcePath, destinationPath, cwd, overwrite); + }), ); server.registerTool( @@ -305,11 +332,12 @@ export function registerFileTools( cwd: cwdSchema, recursive: z.boolean().default(false), force: z.boolean().default(false), + approvalId: approvalIdSchema, }, annotations: writeAnnotations, }, - async ({ path, cwd, recursive, force }) => - runTool(() => files.removePath(path, cwd, recursive, force)), + async ({ path, cwd, recursive, force, approvalId }) => + runTool(() => { enforcePath(safetyPolicy, files, "remove_path", path, cwd, approvalId); return files.removePath(path, cwd, recursive, force); }), ); server.registerTool( @@ -321,11 +349,12 @@ export function registerFileTools( path: pathSchema, cwd: cwdSchema, mode: z.string().regex(/^(?:0o)?[0-7]{3,4}$/), + approvalId: approvalIdSchema, }, annotations: writeAnnotations, }, - async ({ path, cwd, mode }) => - runTool(() => files.changeMode(path, cwd, parseMode(mode) ?? 0)), + async ({ path, cwd, mode, approvalId }) => + runTool(() => { enforcePath(safetyPolicy, files, "chmod_path", path, cwd, approvalId); return files.changeMode(path, cwd, parseMode(mode) ?? 0); }), ); server.registerTool( diff --git a/src/http-server.ts b/src/http-server.ts index 36ced9b..cd86461 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -158,7 +158,7 @@ export async function startHttpServer( } const authenticate = createBearerAuth(config, oauthProvider); const parseMcpJson = express.json({ limit: config.maxRequestBody }); - registerDashboard(app, config, services.taskJournal, authenticate); + registerDashboard(app, config, services.taskJournal, services.safetyPolicy, authenticate); app.get("/health", (_request, response) => { response.json({ diff --git a/src/mcp-server.ts b/src/mcp-server.ts index c822315..6594649 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -7,11 +7,13 @@ import { registerFileTools } from "./file-tools.js"; import { ProcessManager } from "./process-manager.js"; import { TaskJournal } from "./task-journal.js"; import { registerTaskTools } from "./task-tools.js"; +import { SafetyPolicy } from "./safety-policy.js"; export interface McpServices { processManager: ProcessManager; fileService: FileService; taskJournal: TaskJournal; + safetyPolicy: SafetyPolicy; } export function createServices(config: AppConfig): McpServices { @@ -27,6 +29,7 @@ export function createServices(config: AppConfig): McpServices { taskJournalFile: config.taskJournalFile, }), taskJournal, + safetyPolicy: new SafetyPolicy(config.safetyMode, config.defaultCwd), fileService: new FileService({ defaultCwd: config.defaultCwd, maxChunkBytes: config.maxFileChunkBytes, @@ -56,8 +59,9 @@ export function createMcpServer(config: AppConfig, services: McpServices): McpSe services.processManager, services.fileService, services.taskJournal, + services.safetyPolicy, ); - registerFileTools(server, config, services.fileService, services.taskJournal); + registerFileTools(server, config, services.fileService, services.taskJournal, services.safetyPolicy); registerTaskTools(server, services.taskJournal); return server; } diff --git a/src/safety-policy.ts b/src/safety-policy.ts new file mode 100644 index 0000000..d057c0c --- /dev/null +++ b/src/safety-policy.ts @@ -0,0 +1,144 @@ +import { randomUUID } from "node:crypto"; +import path from "node:path"; + +export type SafetyMode = "unrestricted" | "safe"; +export type SafetyDecision = "allow" | "approval-required" | "deny"; + +export interface SafetyAssessment { + decision: SafetyDecision; + reason?: string; +} + +export interface PendingApproval { + approvalId: string; + toolName: string; + summary: string; + createdAt: string; + expiresAt: string; + approvedAt?: string; + consumedAt?: string; +} + +const DENY_COMMANDS = [ + /(?:^|[;&|]\s*)mkfs(?:\.|\s)/i, + /(?:^|[;&|]\s*)wipefs\b/i, + /(?:^|[;&|]\s*)shutdown\b/i, + /(?:^|[;&|]\s*)reboot\b/i, + /\bdd\b[^\n]*\bof=\/dev\//i, + /\brm\s+(?:-[^\s]*r[^\s]*f[^\s]*|-[^\s]*f[^\s]*r[^\s]*)\s+\/(?:\s|$)/i, +]; + +const APPROVAL_COMMANDS = [ + /(?:^|[;&|]\s*)sudo\b/i, + /(?:^|[;&|]\s*)su\s/i, + /(?:^|[;&|]\s*)systemctl\b/i, + /(?:^|[;&|]\s*)service\b/i, + /(?:^|[;&|]\s*)(?:apt|apt-get|dnf|yum|pacman)\b/i, + /(?:^|[;&|]\s*)docker\s+(?:rm|rmi|system\s+prune|volume\s+rm)\b/i, + /(?:^|[;&|]\s*)rm\s+[^\n]*-[^\s]*r/i, + /(?:^|[;&|]\s*)chmod\s+[^\n]*(?:777|666)\b/i, +]; + +function within(root: string, target: string): boolean { + const relative = path.relative(root, target); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export class SafetyPolicy { + readonly mode: SafetyMode; + readonly defaultCwd: string; + readonly #approvals = new Map(); + + constructor(mode: SafetyMode, defaultCwd: string) { + this.mode = mode; + this.defaultCwd = path.resolve(defaultCwd); + } + + assessCommand(command: string): SafetyAssessment { + if (this.mode === "unrestricted") return { decision: "allow" }; + if (DENY_COMMANDS.some((pattern) => pattern.test(command))) { + return { decision: "deny", reason: "Command matches a destructive host-level deny rule" }; + } + if (APPROVAL_COMMANDS.some((pattern) => pattern.test(command))) { + return { decision: "approval-required", reason: "Command can modify system-wide state or delete recursively" }; + } + return { decision: "allow" }; + } + + assessPath(toolName: string, targetPath: string): SafetyAssessment { + if (this.mode === "unrestricted") return { decision: "allow" }; + const absolute = path.resolve(targetPath); + if (within(this.defaultCwd, absolute)) return { decision: "allow" }; + if (toolName === "remove_path" && absolute === "/") { + return { decision: "deny", reason: "Removing the filesystem root is denied" }; + } + return { decision: "approval-required", reason: `Write target is outside ${this.defaultCwd}` }; + } + + request(toolName: string, summary: string): PendingApproval { + this.prune(); + const approvalId = randomUUID(); + const created = Date.now(); + const approval: PendingApproval = { + approvalId, + toolName, + summary, + createdAt: new Date(created).toISOString(), + expiresAt: new Date(created + 10 * 60_000).toISOString(), + }; + this.#approvals.set(approvalId, approval); + return { ...approval }; + } + + approve(approvalId: string): PendingApproval { + const approval = this.#require(approvalId); + if (approval.consumedAt) throw new Error("Approval has already been consumed"); + approval.approvedAt = new Date().toISOString(); + return { ...approval }; + } + + consume(approvalId: string | undefined, toolName: string): boolean { + if (!approvalId) return false; + const approval = this.#require(approvalId); + if (approval.toolName !== toolName) throw new Error("Approval is for a different tool"); + if (!approval.approvedAt) throw new Error("Approval is still pending"); + if (approval.consumedAt) throw new Error("Approval has already been consumed"); + approval.consumedAt = new Date().toISOString(); + return true; + } + + list(): PendingApproval[] { + this.prune(); + return [...this.#approvals.values()].map((item) => ({ ...item })).sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + } + + prune(): void { + const now = Date.now(); + for (const [id, approval] of this.#approvals) { + if (Date.parse(approval.expiresAt) < now || (approval.consumedAt && Date.parse(approval.consumedAt) < now - 60 * 60_000)) { + this.#approvals.delete(id); + } + } + } + + #require(approvalId: string): PendingApproval { + this.prune(); + const approval = this.#approvals.get(approvalId); + if (!approval) throw new Error("Unknown or expired approval"); + return approval; + } +} + +export function enforceAssessment( + policy: SafetyPolicy, + assessment: SafetyAssessment, + toolName: string, + summary: string, + approvalId?: string, +): void { + if (assessment.decision === "allow") return; + if (assessment.decision === "deny") throw new Error(`Safety policy denied ${toolName}: ${assessment.reason}`); + if (approvalId && policy.consume(approvalId, toolName)) return; + const pending = policy.request(toolName, summary); + throw new Error(`Safety approval required: ${assessment.reason}. approvalId=${pending.approvalId}. Approve it in /dashboard, then retry with approvalId.`); +} diff --git a/test/config.test.ts b/test/config.test.ts index b6a1437..0ce02ec 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -150,3 +150,11 @@ describe("lifecycle configuration", () => { expect(configured.taskJournalFile).toBe("/tmp/journal.jsonl"); }); }); + +describe("safety configuration", () => { + it("defaults to unrestricted and accepts safe mode", () => { + expect(loadConfig({ MCP_AUTH_TOKEN: AUTH_SECRET }, "/tmp").safetyMode).toBe("unrestricted"); + expect(loadConfig({ MCP_AUTH_TOKEN: AUTH_SECRET, MCP_SAFETY_MODE: "safe" }, "/tmp").safetyMode).toBe("safe"); + expect(() => loadConfig({ MCP_AUTH_TOKEN: AUTH_SECRET, MCP_SAFETY_MODE: "maybe" }, "/tmp")).toThrow("MCP_SAFETY_MODE"); + }); +}); diff --git a/test/dashboard.integration.test.ts b/test/dashboard.integration.test.ts index f918eca..a31e960 100644 --- a/test/dashboard.integration.test.ts +++ b/test/dashboard.integration.test.ts @@ -33,5 +33,11 @@ describe("task dashboard", () => { expect(tasks.tasks.some((item) => item.taskId === task.taskId)).toBe(true); const events = await (await fetch(`${base}/api/tasks/${task.taskId}/events`, { headers })).json() as { events: Array<{ event: string }> }; expect(events.events.map((event) => event.event)).toEqual(["task.started", "tool.started", "tool.completed"]); + const pending = services.safetyPolicy.request("exec_command", "sudo systemctl restart nginx"); + const approvals = await (await fetch(`${base}/api/approvals`, { headers })).json() as { approvals: Array<{ approvalId: string }> }; + expect(approvals.approvals.some((item) => item.approvalId === pending.approvalId)).toBe(true); + const approved = await fetch(`${base}/api/approvals/${pending.approvalId}/approve`, { method: "POST", headers }); + expect(approved.status).toBe(200); + expect(services.safetyPolicy.list().find((item) => item.approvalId === pending.approvalId)?.approvedAt).toBeTruthy(); }); }); diff --git a/test/safety-policy.test.ts b/test/safety-policy.test.ts new file mode 100644 index 0000000..0840fe4 --- /dev/null +++ b/test/safety-policy.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { SafetyPolicy, enforceAssessment } from "../src/safety-policy.js"; + +describe("SafetyPolicy", () => { + it("preserves unrestricted behavior by default", () => { + const policy = new SafetyPolicy("unrestricted", "/workspace/app"); + expect(policy.assessCommand("sudo rm -rf /tmp/x").decision).toBe("allow"); + expect(policy.assessPath("write_file", "/etc/hosts").decision).toBe("allow"); + }); + + it("allows workspace work, requires approval for risky work, and denies catastrophic commands", () => { + const policy = new SafetyPolicy("safe", "/workspace/app"); + expect(policy.assessCommand("npm test").decision).toBe("allow"); + expect(policy.assessPath("write_file", "/workspace/app/src/a.ts").decision).toBe("allow"); + expect(policy.assessCommand("sudo systemctl restart nginx").decision).toBe("approval-required"); + expect(policy.assessPath("write_file", "/etc/nginx/nginx.conf").decision).toBe("approval-required"); + expect(policy.assessCommand("mkfs.ext4 /dev/sda").decision).toBe("deny"); + expect(policy.assessCommand("rm -rf /").decision).toBe("deny"); + }); + + it("uses a human-approved approval only once", () => { + const policy = new SafetyPolicy("safe", "/workspace/app"); + const assessment = policy.assessCommand("sudo systemctl restart nginx"); + expect(() => enforceAssessment(policy, assessment, "exec_command", "restart nginx")).toThrow(/approvalId=/); + const pending = policy.list()[0]!; + expect(() => policy.consume(pending.approvalId, "exec_command")).toThrow("still pending"); + policy.approve(pending.approvalId); + expect(policy.consume(pending.approvalId, "exec_command")).toBe(true); + expect(() => policy.consume(pending.approvalId, "exec_command")).toThrow("already been consumed"); + }); +}); From cbb20a09f8cf24cc2fcf67f74245beba6cdbff49 Mon Sep 17 00:00:00 2001 From: k Date: Mon, 31 Aug 2026 09:11:36 +0000 Subject: [PATCH 11/30] feat: support configurable safety policies --- .env.example | 2 + README.md | 21 ++++++ config/safety-policy.example.json | 33 +++++++++ deploy/remote-dev-mcp.env.example | 2 + docker-compose.yml | 2 + src/config.ts | 2 + src/doctor.ts | 7 ++ src/mcp-server.ts | 3 +- src/safety-policy-file.ts | 114 ++++++++++++++++++++++++++++++ src/safety-policy.ts | 29 ++++++-- test/config.test.ts | 1 + test/safety-policy.test.ts | 45 ++++++++++++ 12 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 config/safety-policy.example.json create mode 100644 src/safety-policy-file.ts diff --git a/.env.example b/.env.example index 1907bcd..43902a2 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,5 @@ MCP_OAUTH_AUTHORIZATION_CODE_TTL_SECONDS=300 # unrestricted (default) or safe (human approval for risky operations) MCP_SAFETY_MODE=unrestricted +# Optional JSON policy file. Docker Compose example: /config/safety-policy.json +# MCP_SAFETY_POLICY_FILE=/config/safety-policy.json diff --git a/README.md b/README.md index 9114af0..a8ab4fa 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,27 @@ The server provides 20 tools in total. `remove_path` permanently deletes targets `MCP_SAFETY_MODE=unrestricted` preserves the original full-access behavior. Set `MCP_SAFETY_MODE=safe` to require a one-time human approval for risky shell commands and writes outside `MCP_DEFAULT_CWD`. Extremely destructive host commands such as filesystem formatting, reboot/shutdown, raw device overwrite, and root recursive deletion are denied. Pending approvals appear in `/dashboard`, expire after 10 minutes, and are consumed once when the tool retries with the returned `approvalId`. +Safe mode can be customized with `MCP_SAFETY_POLICY_FILE`. The file is JSON (`version: 1`) and supports ordered `commands` regex rules, ordered `paths` prefix rules, and default decisions. Rule decisions are `allow`, `approval-required`, or `deny`. User rules run before the built-in approval rules, so an environment can explicitly allow something such as `docker rm`; catastrophic built-in deny rules always run first and cannot be overridden. Path rules support `${workspace}` as an alias for `MCP_DEFAULT_CWD` and can optionally be limited to named MCP tools. + +Docker Compose mounts `${CONFIG_PATH:-./config}` read-only at `/config`. Copy `config/safety-policy.example.json` to `config/safety-policy.json`, set `MCP_SAFETY_MODE=safe` and `MCP_SAFETY_POLICY_FILE=/config/safety-policy.json`, then restart the service. `npm run doctor` validates the configured policy file and fails if the JSON, regex, version, or decision values are invalid. + +Example policy: + +```json +{ + "version": 1, + "commands": [ + { "id": "allow-docker-rm", "pattern": "(?:^|[;&|]\\s*)docker\\s+rm\\b", "decision": "allow" }, + { "id": "approve-sudo", "pattern": "(?:^|[;&|]\\s*)sudo\\b", "decision": "approval-required" } + ], + "paths": [ + { "id": "deny-etc", "prefix": "/etc", "decision": "deny" }, + { "id": "allow-generated", "prefix": "${workspace}/generated", "decision": "allow" } + ], + "defaults": { "unmatchedCommand": "allow", "outsideWorkspace": "approval-required" } +} +``` + ## Task dashboard Open `/dashboard` on the same server to inspect recent development tasks, summaries, and their ordered event timelines. The dashboard uses the same bearer/OAuth authentication as the MCP endpoint and refreshes every three seconds. Its JSON endpoints under `/dashboard/api/tasks` can also be used by external monitoring UIs. diff --git a/config/safety-policy.example.json b/config/safety-policy.example.json new file mode 100644 index 0000000..795949c --- /dev/null +++ b/config/safety-policy.example.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "commands": [ + { + "id": "allow-docker-rm", + "pattern": "(?:^|[;&|]\\s*)docker\\s+rm\\b", + "decision": "allow", + "reason": "Container removal is allowed in this development environment" + }, + { + "id": "approve-sudo", + "pattern": "(?:^|[;&|]\\s*)sudo\\b", + "decision": "approval-required" + } + ], + "paths": [ + { + "id": "deny-etc", + "prefix": "/etc", + "decision": "deny", + "reason": "System configuration is managed outside cokacremote" + }, + { + "id": "allow-workspace-generated", + "prefix": "${workspace}/generated", + "decision": "allow" + } + ], + "defaults": { + "unmatchedCommand": "allow", + "outsideWorkspace": "approval-required" + } +} diff --git a/deploy/remote-dev-mcp.env.example b/deploy/remote-dev-mcp.env.example index 06bfe98..622160a 100644 --- a/deploy/remote-dev-mcp.env.example +++ b/deploy/remote-dev-mcp.env.example @@ -30,3 +30,5 @@ MCP_MAX_EDIT_FILE_BYTES=67108864 # unrestricted (default) or safe (human approval for risky operations) MCP_SAFETY_MODE=unrestricted +# Optional JSON policy file. Docker Compose example: /config/safety-policy.json +# MCP_SAFETY_POLICY_FILE=/config/safety-policy.json diff --git a/docker-compose.yml b/docker-compose.yml index 54a3614..71f1eb0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: MCP_PROCESS_IDLE_TIMEOUT_MS: ${MCP_PROCESS_IDLE_TIMEOUT_MS:-1800000} MCP_PROCESS_MAX_RUNTIME_MS: ${MCP_PROCESS_MAX_RUNTIME_MS:-14400000} MCP_SAFETY_MODE: ${MCP_SAFETY_MODE:-unrestricted} + MCP_SAFETY_POLICY_FILE: ${MCP_SAFETY_POLICY_FILE:-} MCP_AUTH_TOKEN: ${MCP_AUTH_TOKEN:-} ports: - "${MCP_LISTEN_HOST:-127.0.0.1}:${MCP_PORT:-3000}:${MCP_PORT:-3000}" @@ -26,6 +27,7 @@ services: - ${WORKSPACE_PATH:-./workspace}:/workspace - ${SSH_PATH:-./.ssh}:/home/node/.ssh - cokacremote-data:/data + - ${CONFIG_PATH:-./config}:/config:ro healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:$${MCP_PORT:-3000}/health || exit 1"] interval: 30s diff --git a/src/config.ts b/src/config.ts index 85f6f9a..4adff75 100644 --- a/src/config.ts +++ b/src/config.ts @@ -33,6 +33,7 @@ export interface AppConfig { maxFileChunkBytes: number; maxEditFileBytes: number; safetyMode: "unrestricted" | "safe"; + safetyPolicyFile: string | undefined; } function parseBoolean(value: string | undefined, fallback: boolean): boolean { @@ -256,6 +257,7 @@ export function loadConfig( 4096, ), safetyMode: parseSafetyMode(env.MCP_SAFETY_MODE), + safetyPolicyFile: env.MCP_SAFETY_POLICY_FILE?.trim() ? path.resolve(processCwd, env.MCP_SAFETY_POLICY_FILE.trim()) : undefined, maxEditFileBytes: parseInteger( env.MCP_MAX_EDIT_FILE_BYTES, 64 * 1024 * 1024, diff --git a/src/doctor.ts b/src/doctor.ts index 6c603a5..1663ca9 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -2,6 +2,7 @@ import { access, constants, mkdir, rm, writeFile } from "node:fs/promises"; import { spawnSync } from "node:child_process"; import path from "node:path"; import { loadConfig } from "./config.js"; +import { loadSafetyPolicyFile } from "./safety-policy-file.js"; interface Check { name: string; ok: boolean; detail: string } function commandCheck(name: string, command: string, args = ["--version"]): Check { @@ -23,6 +24,12 @@ async function main(): Promise { checks.push({ name: "Default cwd", ok: true, detail: `${config.defaultCwd} (read/write)` }); } catch (error) { checks.push({ name: "Default cwd", ok: false, detail: String(error) }); } checks.push({ name: "Task journal", ok: true, detail: config.taskJournalFile ?? "disabled" }); + try { + if (config.safetyPolicyFile) loadSafetyPolicyFile(config.safetyPolicyFile); + checks.push({ name: "Safety policy", ok: true, detail: config.safetyPolicyFile ?? `${config.safetyMode} (built-in)` }); + } catch (error) { + checks.push({ name: "Safety policy", ok: false, detail: String(error) }); + } console.log("cokacremote doctor\n"); checks.forEach((c) => console.log(`${c.ok ? "OK" : "FAIL"} ${c.name.padEnd(14)} ${c.detail}`)); const failed = checks.filter((c) => !c.ok).length; diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 6594649..0a34eaf 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -8,6 +8,7 @@ import { ProcessManager } from "./process-manager.js"; import { TaskJournal } from "./task-journal.js"; import { registerTaskTools } from "./task-tools.js"; import { SafetyPolicy } from "./safety-policy.js"; +import { loadSafetyPolicyFile } from "./safety-policy-file.js"; export interface McpServices { processManager: ProcessManager; @@ -29,7 +30,7 @@ export function createServices(config: AppConfig): McpServices { taskJournalFile: config.taskJournalFile, }), taskJournal, - safetyPolicy: new SafetyPolicy(config.safetyMode, config.defaultCwd), + safetyPolicy: new SafetyPolicy(config.safetyMode, config.defaultCwd, loadSafetyPolicyFile(config.safetyPolicyFile)), fileService: new FileService({ defaultCwd: config.defaultCwd, maxChunkBytes: config.maxFileChunkBytes, diff --git a/src/safety-policy-file.ts b/src/safety-policy-file.ts new file mode 100644 index 0000000..6d5d903 --- /dev/null +++ b/src/safety-policy-file.ts @@ -0,0 +1,114 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import type { SafetyDecision } from "./safety-policy.js"; + +export interface CommandPolicyRule { + id: string; + pattern: string; + flags?: string; + decision: SafetyDecision; + reason?: string; +} + +export interface PathPolicyRule { + id: string; + prefix: string; + tools?: string[]; + decision: SafetyDecision; + reason?: string; +} + +export interface SafetyPolicyFile { + version: 1; + commands?: CommandPolicyRule[]; + paths?: PathPolicyRule[]; + defaults?: { + unmatchedCommand?: SafetyDecision; + outsideWorkspace?: SafetyDecision; + }; +} + +const DECISIONS = new Set(["allow", "approval-required", "deny"]); + +function asObject(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`); + return value as Record; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== "string" || value.trim() === "") throw new Error(`${label} must be a non-empty string`); + return value; +} + +function decision(value: unknown, label: string): SafetyDecision { + if (typeof value !== "string" || !DECISIONS.has(value as SafetyDecision)) { + throw new Error(`${label} must be allow, approval-required, or deny`); + } + return value as SafetyDecision; +} + +export function parseSafetyPolicyFile(value: unknown): SafetyPolicyFile { + const root = asObject(value, "Safety policy"); + if (root.version !== 1) throw new Error("Safety policy version must be 1"); + + const commands = root.commands === undefined ? undefined : (() => { + if (!Array.isArray(root.commands)) throw new Error("Safety policy commands must be an array"); + return root.commands.map((raw, index) => { + const item = asObject(raw, `commands[${index}]`); + const pattern = requiredString(item.pattern, `commands[${index}].pattern`); + const flags = item.flags === undefined ? "i" : requiredString(item.flags, `commands[${index}].flags`); + try { new RegExp(pattern, flags); } catch (error) { throw new Error(`commands[${index}].pattern is invalid: ${String(error)}`); } + return { + id: requiredString(item.id, `commands[${index}].id`), + pattern, + flags, + decision: decision(item.decision, `commands[${index}].decision`), + ...(typeof item.reason === "string" ? { reason: item.reason } : {}), + }; + }); + })(); + + const paths = root.paths === undefined ? undefined : (() => { + if (!Array.isArray(root.paths)) throw new Error("Safety policy paths must be an array"); + return root.paths.map((raw, index) => { + const item = asObject(raw, `paths[${index}]`); + let tools: string[] | undefined; + if (item.tools !== undefined) { + if (!Array.isArray(item.tools) || item.tools.some((tool) => typeof tool !== "string" || tool.trim() === "")) { + throw new Error(`paths[${index}].tools must be an array of non-empty strings`); + } + tools = item.tools as string[]; + } + return { + id: requiredString(item.id, `paths[${index}].id`), + prefix: requiredString(item.prefix, `paths[${index}].prefix`), + ...(tools ? { tools } : {}), + decision: decision(item.decision, `paths[${index}].decision`), + ...(typeof item.reason === "string" ? { reason: item.reason } : {}), + }; + }); + })(); + + let defaults: SafetyPolicyFile["defaults"]; + if (root.defaults !== undefined) { + const raw = asObject(root.defaults, "Safety policy defaults"); + defaults = { + ...(raw.unmatchedCommand !== undefined ? { unmatchedCommand: decision(raw.unmatchedCommand, "defaults.unmatchedCommand") } : {}), + ...(raw.outsideWorkspace !== undefined ? { outsideWorkspace: decision(raw.outsideWorkspace, "defaults.outsideWorkspace") } : {}), + }; + } + + return { version: 1, ...(commands ? { commands } : {}), ...(paths ? { paths } : {}), ...(defaults ? { defaults } : {}) }; +} + +export function loadSafetyPolicyFile(file: string | undefined): SafetyPolicyFile | undefined { + if (!file) return undefined; + const resolved = path.resolve(file); + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(resolved, "utf8")); + } catch (error) { + throw new Error(`Failed to load safety policy file ${resolved}: ${String(error)}`); + } + return parseSafetyPolicyFile(parsed); +} diff --git a/src/safety-policy.ts b/src/safety-policy.ts index d057c0c..6e1fa39 100644 --- a/src/safety-policy.ts +++ b/src/safety-policy.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import path from "node:path"; +import type { SafetyPolicyFile } from "./safety-policy-file.js"; export type SafetyMode = "unrestricted" | "safe"; export type SafetyDecision = "allow" | "approval-required" | "deny"; @@ -48,10 +49,12 @@ export class SafetyPolicy { readonly mode: SafetyMode; readonly defaultCwd: string; readonly #approvals = new Map(); + readonly #policyFile: SafetyPolicyFile | undefined; - constructor(mode: SafetyMode, defaultCwd: string) { + constructor(mode: SafetyMode, defaultCwd: string, policyFile?: SafetyPolicyFile) { this.mode = mode; this.defaultCwd = path.resolve(defaultCwd); + this.#policyFile = policyFile; } assessCommand(command: string): SafetyAssessment { @@ -59,20 +62,38 @@ export class SafetyPolicy { if (DENY_COMMANDS.some((pattern) => pattern.test(command))) { return { decision: "deny", reason: "Command matches a destructive host-level deny rule" }; } + for (const rule of this.#policyFile?.commands ?? []) { + if (new RegExp(rule.pattern, rule.flags ?? "i").test(command)) { + return { decision: rule.decision, reason: rule.reason ?? `Matched command policy rule ${rule.id}` }; + } + } if (APPROVAL_COMMANDS.some((pattern) => pattern.test(command))) { return { decision: "approval-required", reason: "Command can modify system-wide state or delete recursively" }; } - return { decision: "allow" }; + return { + decision: this.#policyFile?.defaults?.unmatchedCommand ?? "allow", + ...(this.#policyFile?.defaults?.unmatchedCommand ? { reason: "Safety policy default for unmatched commands" } : {}), + }; } assessPath(toolName: string, targetPath: string): SafetyAssessment { if (this.mode === "unrestricted") return { decision: "allow" }; const absolute = path.resolve(targetPath); - if (within(this.defaultCwd, absolute)) return { decision: "allow" }; if (toolName === "remove_path" && absolute === "/") { return { decision: "deny", reason: "Removing the filesystem root is denied" }; } - return { decision: "approval-required", reason: `Write target is outside ${this.defaultCwd}` }; + for (const rule of this.#policyFile?.paths ?? []) { + const expandedPrefix = rule.prefix.replaceAll("${workspace}", this.defaultCwd); + const prefix = path.isAbsolute(expandedPrefix) ? path.resolve(expandedPrefix) : path.resolve(this.defaultCwd, expandedPrefix); + if ((!rule.tools || rule.tools.includes(toolName)) && within(prefix, absolute)) { + return { decision: rule.decision, reason: rule.reason ?? `Matched path policy rule ${rule.id}` }; + } + } + if (within(this.defaultCwd, absolute)) return { decision: "allow" }; + return { + decision: this.#policyFile?.defaults?.outsideWorkspace ?? "approval-required", + reason: `Write target is outside ${this.defaultCwd}`, + }; } request(toolName: string, summary: string): PendingApproval { diff --git a/test/config.test.ts b/test/config.test.ts index 0ce02ec..c15cc30 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -155,6 +155,7 @@ describe("safety configuration", () => { it("defaults to unrestricted and accepts safe mode", () => { expect(loadConfig({ MCP_AUTH_TOKEN: AUTH_SECRET }, "/tmp").safetyMode).toBe("unrestricted"); expect(loadConfig({ MCP_AUTH_TOKEN: AUTH_SECRET, MCP_SAFETY_MODE: "safe" }, "/tmp").safetyMode).toBe("safe"); + expect(loadConfig({ MCP_AUTH_TOKEN: AUTH_SECRET, MCP_SAFETY_POLICY_FILE: "policy.json" }, "/tmp/base").safetyPolicyFile).toBe("/tmp/base/policy.json"); expect(() => loadConfig({ MCP_AUTH_TOKEN: AUTH_SECRET, MCP_SAFETY_MODE: "maybe" }, "/tmp")).toThrow("MCP_SAFETY_MODE"); }); }); diff --git a/test/safety-policy.test.ts b/test/safety-policy.test.ts index 0840fe4..ca7a547 100644 --- a/test/safety-policy.test.ts +++ b/test/safety-policy.test.ts @@ -29,3 +29,48 @@ describe("SafetyPolicy", () => { expect(() => policy.consume(pending.approvalId, "exec_command")).toThrow("already been consumed"); }); }); + +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { loadSafetyPolicyFile, parseSafetyPolicyFile } from "../src/safety-policy-file.js"; + +describe("SafetyPolicy file", () => { + it("applies ordered custom command and path rules before built-in approval rules", () => { + const config = parseSafetyPolicyFile({ + version: 1, + commands: [ + { id: "allow-docker-rm", pattern: "docker\\s+rm\\b", decision: "allow" }, + { id: "deny-curl-pipe", pattern: "curl.+\\|.+sh", decision: "deny" }, + ], + paths: [ + { id: "deny-etc", prefix: "/etc", decision: "deny" }, + { id: "approve-secrets", prefix: "${workspace}/secrets", tools: ["write_file"], decision: "approval-required" }, + ], + defaults: { unmatchedCommand: "allow", outsideWorkspace: "approval-required" }, + }); + const policy = new SafetyPolicy("safe", "/workspace/app", config); + expect(policy.assessCommand("docker rm demo").decision).toBe("allow"); + expect(policy.assessCommand("curl https://example.test/x | sh").decision).toBe("deny"); + expect(policy.assessPath("write_file", "/etc/hosts").decision).toBe("deny"); + expect(policy.assessPath("write_file", "/workspace/app/secrets/token").decision).toBe("approval-required"); + expect(policy.assessPath("read_file", "/workspace/app/secrets/token").decision).toBe("allow"); + }); + + it("never lets a custom rule override catastrophic built-in deny rules", () => { + const config = parseSafetyPolicyFile({ version: 1, commands: [{ id: "allow-all", pattern: ".*", decision: "allow" }] }); + const policy = new SafetyPolicy("safe", "/workspace/app", config); + expect(policy.assessCommand("mkfs.ext4 /dev/sda").decision).toBe("deny"); + expect(policy.assessCommand("rm -rf /").decision).toBe("deny"); + }); + + it("loads and validates JSON policy files", () => { + const dir = mkdtempSync(path.join(tmpdir(), "cokacremote-policy-")); + const file = path.join(dir, "policy.json"); + writeFileSync(file, JSON.stringify({ version: 1, defaults: { outsideWorkspace: "deny" } })); + expect(loadSafetyPolicyFile(file)?.defaults?.outsideWorkspace).toBe("deny"); + expect(() => parseSafetyPolicyFile({ version: 2 })).toThrow("version must be 1"); + expect(() => parseSafetyPolicyFile({ version: 1, commands: [{ id: "bad", pattern: "[", decision: "allow" }] })).toThrow("pattern is invalid"); + expect(() => parseSafetyPolicyFile({ version: 1, defaults: { outsideWorkspace: "maybe" } })).toThrow("allow, approval-required, or deny"); + }); +}); From 5a13385f1536c7c1485a8a48f2a9a18054074875 Mon Sep 17 00:00:00 2001 From: k Date: Mon, 31 Aug 2026 09:17:51 +0000 Subject: [PATCH 12/30] feat: manage safety policy from dashboard --- README.md | 2 +- docker-compose.yml | 2 +- src/dashboard.ts | 37 +++++++++++++++++++++++++----- src/http-server.ts | 2 +- src/safety-policy-file.ts | 14 +++++++++++ src/safety-policy.ts | 10 +++++++- test/dashboard.integration.test.ts | 20 ++++++++++++++-- 7 files changed, 75 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a8ab4fa..be1d1f2 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ The server provides 20 tools in total. `remove_path` permanently deletes targets Safe mode can be customized with `MCP_SAFETY_POLICY_FILE`. The file is JSON (`version: 1`) and supports ordered `commands` regex rules, ordered `paths` prefix rules, and default decisions. Rule decisions are `allow`, `approval-required`, or `deny`. User rules run before the built-in approval rules, so an environment can explicitly allow something such as `docker rm`; catastrophic built-in deny rules always run first and cannot be overridden. Path rules support `${workspace}` as an alias for `MCP_DEFAULT_CWD` and can optionally be limited to named MCP tools. -Docker Compose mounts `${CONFIG_PATH:-./config}` read-only at `/config`. Copy `config/safety-policy.example.json` to `config/safety-policy.json`, set `MCP_SAFETY_MODE=safe` and `MCP_SAFETY_POLICY_FILE=/config/safety-policy.json`, then restart the service. `npm run doctor` validates the configured policy file and fails if the JSON, regex, version, or decision values are invalid. +Docker Compose mounts `${CONFIG_PATH:-./config}` at `/config` so the authenticated dashboard can save policy changes. Copy `config/safety-policy.example.json` to `config/safety-policy.json`, set `MCP_SAFETY_MODE=safe` and `MCP_SAFETY_POLICY_FILE=/config/safety-policy.json`, then restart the service once to load the configured file path. After that, `/dashboard` can validate, save, and reload the policy without restarting the server. `npm run doctor` validates the configured policy file and fails if the JSON, regex, version, or decision values are invalid. Example policy: diff --git a/docker-compose.yml b/docker-compose.yml index 71f1eb0..e060039 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ services: - ${WORKSPACE_PATH:-./workspace}:/workspace - ${SSH_PATH:-./.ssh}:/home/node/.ssh - cokacremote-data:/data - - ${CONFIG_PATH:-./config}:/config:ro + - ${CONFIG_PATH:-./config}:/config healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:$${MCP_PORT:-3000}/health || exit 1"] interval: 30s diff --git a/src/dashboard.ts b/src/dashboard.ts index 0cacbb0..8c9577a 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -2,25 +2,27 @@ import type { Express, Request, Response } from "express"; import type { AppConfig } from "./config.js"; import type { TaskJournal } from "./task-journal.js"; import type { SafetyPolicy } from "./safety-policy.js"; +import { loadSafetyPolicyFile, parseSafetyPolicyFile, saveSafetyPolicyFile } from "./safety-policy-file.js"; type Middleware = (request: Request, response: Response, next: () => void) => void; const dashboardHtml = ` cokacremote tasks
cokacremote
Development task timeline
refreshing...
Select a task to inspect its timeline.
+:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,sans-serif;background:#0b1020;color:#e5e7eb}*{box-sizing:border-box}body{margin:0}header{padding:22px 28px;border-bottom:1px solid #24304a;display:flex;justify-content:space-between;align-items:center}.brand{font-weight:800;font-size:20px}.muted{color:#94a3b8}.layout{display:grid;grid-template-columns:minmax(260px,360px) 1fr;min-height:calc(100vh - 70px)}aside{border-right:1px solid #24304a;padding:16px}.task{padding:14px;border:1px solid #24304a;border-radius:12px;margin-bottom:10px;cursor:pointer;background:#111827}.task:hover,.task.active{border-color:#60a5fa}.task h3{margin:0 0 7px;font-size:15px}.badge{font-size:11px;padding:3px 7px;border-radius:99px;background:#1e293b}.active-status{color:#86efac}.completed-status{color:#93c5fd}main{padding:24px;overflow:auto}.empty{color:#64748b;padding:40px;text-align:center}.summary{display:flex;gap:12px;flex-wrap:wrap;margin:12px 0 24px}.card{background:#111827;border:1px solid #24304a;border-radius:12px;padding:12px 16px}.timeline{border-left:2px solid #24304a;margin-left:10px;padding-left:20px}.event{position:relative;margin:0 0 16px;background:#111827;border:1px solid #24304a;border-radius:10px;padding:12px}.event:before{content:'';position:absolute;width:10px;height:10px;border-radius:50%;background:#60a5fa;left:-26px;top:17px}.event.failed:before{background:#f87171}.event.completed:before{background:#4ade80}.event-head{display:flex;justify-content:space-between;gap:16px}.event-name{font-weight:700}.meta{font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;color:#94a3b8;margin-top:7px;white-space:pre-wrap;word-break:break-word}.toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.btn{border:1px solid #334155;background:#1e293b;color:#e5e7eb;border-radius:8px;padding:8px 11px;cursor:pointer}.btn:hover{border-color:#60a5fa}.editor{width:100%;min-height:460px;background:#080d19;color:#dbeafe;border:1px solid #334155;border-radius:10px;padding:14px;font:13px ui-monospace,SFMono-Regular,monospace;tab-size:2}.notice{padding:10px 12px;border-radius:8px;margin:10px 0;background:#172033}.notice.error{color:#fca5a5}.notice.ok{color:#86efac}@media(max-width:760px){.layout{grid-template-columns:1fr}aside{border-right:0;border-bottom:1px solid #24304a}main{padding:16px}} +
cokacremote
Development task timeline
refreshing...
Select a task to inspect its timeline.
`; -export function registerDashboard(app: Express, config: AppConfig, journal: TaskJournal, safetyPolicy: SafetyPolicy, authenticate: Middleware): void { +export function registerDashboard(app: Express, config: AppConfig, journal: TaskJournal, safetyPolicy: SafetyPolicy, authenticate: Middleware, parseJson: Middleware): void { const base = "/dashboard"; app.get(base, authenticate, (_request, response) => response.type("html").send(dashboardHtml)); app.get(`${base}/api/tasks`, authenticate, async (_request, response) => response.json({ tasks: await journal.listTasks(100) })); @@ -29,6 +31,30 @@ export function registerDashboard(app: Express, config: AppConfig, journal: Task try { response.json(safetyPolicy.approve(String(request.params.approvalId ?? ""))); } catch (error) { response.status(404).json({ error: error instanceof Error ? error.message : "Approval not found" }); } }); + app.get(`${base}/api/policy`, authenticate, (_request, response) => { + response.json({ mode: safetyPolicy.mode, file: config.safetyPolicyFile, editable: Boolean(config.safetyPolicyFile), policy: safetyPolicy.policyFile }); + }); + app.post(`${base}/api/policy/validate`, authenticate, parseJson, (request, response) => { + try { response.json({ valid: true, policy: parseSafetyPolicyFile(request.body) }); } + catch (error) { response.status(400).json({ valid: false, error: error instanceof Error ? error.message : "Invalid policy" }); } + }); + app.put(`${base}/api/policy`, authenticate, parseJson, async (request, response) => { + if (!config.safetyPolicyFile) { response.status(409).json({ error: "MCP_SAFETY_POLICY_FILE is not configured" }); return; } + try { + const policy = parseSafetyPolicyFile(request.body); + await saveSafetyPolicyFile(config.safetyPolicyFile, policy); + safetyPolicy.reload(policy); + response.json({ saved: true, reloaded: true, policy }); + } catch (error) { response.status(400).json({ error: error instanceof Error ? error.message : "Failed to save policy" }); } + }); + app.post(`${base}/api/policy/reload`, authenticate, (_request, response) => { + if (!config.safetyPolicyFile) { response.status(409).json({ error: "MCP_SAFETY_POLICY_FILE is not configured" }); return; } + try { + const policy = loadSafetyPolicyFile(config.safetyPolicyFile); + safetyPolicy.reload(policy); + response.json({ reloaded: true, policy }); + } catch (error) { response.status(400).json({ error: error instanceof Error ? error.message : "Failed to reload policy" }); } + }); app.get(`${base}/api/tasks/:taskId`, authenticate, async (request, response) => { const taskId = String(request.params.taskId ?? ""); const task = await journal.getTask(taskId); @@ -45,5 +71,4 @@ export function registerDashboard(app: Express, config: AppConfig, journal: Task response.status(404).json({ error: error instanceof Error ? error.message : "Task not found" }); } }); - void config; } diff --git a/src/http-server.ts b/src/http-server.ts index cd86461..9b6c20a 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -158,7 +158,7 @@ export async function startHttpServer( } const authenticate = createBearerAuth(config, oauthProvider); const parseMcpJson = express.json({ limit: config.maxRequestBody }); - registerDashboard(app, config, services.taskJournal, services.safetyPolicy, authenticate); + registerDashboard(app, config, services.taskJournal, services.safetyPolicy, authenticate, parseMcpJson); app.get("/health", (_request, response) => { response.json({ diff --git a/src/safety-policy-file.ts b/src/safety-policy-file.ts index 6d5d903..466e5d2 100644 --- a/src/safety-policy-file.ts +++ b/src/safety-policy-file.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { rename, writeFile } from "node:fs/promises"; import path from "node:path"; import type { SafetyDecision } from "./safety-policy.js"; @@ -112,3 +113,16 @@ export function loadSafetyPolicyFile(file: string | undefined): SafetyPolicyFile } return parseSafetyPolicyFile(parsed); } + +export async function saveSafetyPolicyFile(file: string, policy: SafetyPolicyFile): Promise { + const resolved = path.resolve(file); + const validated = parseSafetyPolicyFile(policy); + const temporary = `${resolved}.tmp-${process.pid}-${Date.now()}`; + await writeFile(temporary, `${JSON.stringify(validated, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + try { + await rename(temporary, resolved); + } catch (error) { + await import("node:fs/promises").then(({ rm }) => rm(temporary, { force: true })).catch(() => undefined); + throw error; + } +} diff --git a/src/safety-policy.ts b/src/safety-policy.ts index 6e1fa39..a8f6ccf 100644 --- a/src/safety-policy.ts +++ b/src/safety-policy.ts @@ -49,7 +49,7 @@ export class SafetyPolicy { readonly mode: SafetyMode; readonly defaultCwd: string; readonly #approvals = new Map(); - readonly #policyFile: SafetyPolicyFile | undefined; + #policyFile: SafetyPolicyFile | undefined; constructor(mode: SafetyMode, defaultCwd: string, policyFile?: SafetyPolicyFile) { this.mode = mode; @@ -57,6 +57,14 @@ export class SafetyPolicy { this.#policyFile = policyFile; } + get policyFile(): SafetyPolicyFile | undefined { + return this.#policyFile ? structuredClone(this.#policyFile) : undefined; + } + + reload(policyFile: SafetyPolicyFile | undefined): void { + this.#policyFile = policyFile ? structuredClone(policyFile) : undefined; + } + assessCommand(command: string): SafetyAssessment { if (this.mode === "unrestricted") return { decision: "allow" }; if (DENY_COMMANDS.some((pattern) => pattern.test(command))) { diff --git a/test/dashboard.integration.test.ts b/test/dashboard.integration.test.ts index a31e960..60ccebb 100644 --- a/test/dashboard.integration.test.ts +++ b/test/dashboard.integration.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import type { AddressInfo } from "node:net"; import os from "node:os"; import path from "node:path"; @@ -15,7 +15,9 @@ describe("task dashboard", () => { it("protects and serves task timeline APIs", async () => { dir = await mkdtemp(path.join(os.tmpdir(), "cokacremote-dashboard-")); const token = "dashboard-test-secret-0123456789abcdef"; - const config = loadConfig({ MCP_AUTH_TOKEN: token, MCP_HOST: "127.0.0.1", MCP_DEFAULT_CWD: dir }, dir); + const policyFile = path.join(dir, "safety-policy.json"); + await writeFile(policyFile, JSON.stringify({ version: 1, defaults: { outsideWorkspace: "approval-required" } })); + const config = loadConfig({ MCP_AUTH_TOKEN: token, MCP_HOST: "127.0.0.1", MCP_DEFAULT_CWD: dir, MCP_SAFETY_MODE: "safe", MCP_SAFETY_POLICY_FILE: policyFile }, dir); config.port = 0; const services = createServices(config); const task = await services.taskJournal.startTask("Dashboard test", dir); @@ -39,5 +41,19 @@ describe("task dashboard", () => { const approved = await fetch(`${base}/api/approvals/${pending.approvalId}/approve`, { method: "POST", headers }); expect(approved.status).toBe(200); expect(services.safetyPolicy.list().find((item) => item.approvalId === pending.approvalId)?.approvedAt).toBeTruthy(); + const policyResponse = await (await fetch(`${base}/api/policy`, { headers })).json() as { editable: boolean; policy: { version: number } }; + expect(policyResponse.editable).toBe(true); + expect(policyResponse.policy.version).toBe(1); + const invalid = await fetch(`${base}/api/policy/validate`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify({ version: 2 }) }); + expect(invalid.status).toBe(400); + const updatedPolicy = { version: 1, commands: [{ id: "deny-danger", pattern: "danger", decision: "deny" }] }; + const saved = await fetch(`${base}/api/policy`, { method: "PUT", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify(updatedPolicy) }); + expect(saved.status).toBe(200); + expect(services.safetyPolicy.assessCommand("danger").decision).toBe("deny"); + expect(JSON.parse(await readFile(policyFile, "utf8"))).toMatchObject(updatedPolicy); + await writeFile(policyFile, JSON.stringify({ version: 1, commands: [{ id: "allow-danger", pattern: "danger", decision: "allow" }] })); + const reloaded = await fetch(`${base}/api/policy/reload`, { method: "POST", headers }); + expect(reloaded.status).toBe(200); + expect(services.safetyPolicy.assessCommand("danger").decision).toBe("allow"); }); }); From 774a15c479c9802dc293fbe83ece03c4902f85fa Mon Sep 17 00:00:00 2001 From: k Date: Mon, 31 Aug 2026 09:24:28 +0000 Subject: [PATCH 13/30] feat: add safety policy audit rollback --- README.md | 2 +- src/dashboard.ts | 29 ++++++++++++-- src/safety-policy-audit.ts | 61 ++++++++++++++++++++++++++++++ test/dashboard.integration.test.ts | 13 +++++++ 4 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 src/safety-policy-audit.ts diff --git a/README.md b/README.md index be1d1f2..1d33351 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ The server provides 20 tools in total. `remove_path` permanently deletes targets Safe mode can be customized with `MCP_SAFETY_POLICY_FILE`. The file is JSON (`version: 1`) and supports ordered `commands` regex rules, ordered `paths` prefix rules, and default decisions. Rule decisions are `allow`, `approval-required`, or `deny`. User rules run before the built-in approval rules, so an environment can explicitly allow something such as `docker rm`; catastrophic built-in deny rules always run first and cannot be overridden. Path rules support `${workspace}` as an alias for `MCP_DEFAULT_CWD` and can optionally be limited to named MCP tools. -Docker Compose mounts `${CONFIG_PATH:-./config}` at `/config` so the authenticated dashboard can save policy changes. Copy `config/safety-policy.example.json` to `config/safety-policy.json`, set `MCP_SAFETY_MODE=safe` and `MCP_SAFETY_POLICY_FILE=/config/safety-policy.json`, then restart the service once to load the configured file path. After that, `/dashboard` can validate, save, and reload the policy without restarting the server. `npm run doctor` validates the configured policy file and fails if the JSON, regex, version, or decision values are invalid. +Docker Compose mounts `${CONFIG_PATH:-./config}` at `/config` so the authenticated dashboard can save policy changes. Copy `config/safety-policy.example.json` to `config/safety-policy.json`, set `MCP_SAFETY_MODE=safe` and `MCP_SAFETY_POLICY_FILE=/config/safety-policy.json`, then restart the service once to load the configured file path. After that, `/dashboard` can validate, save, and reload the policy without restarting the server. Each successful save/reload is appended to `.history.jsonl` with a timestamp, revision ID, SHA-256 digest, and validated policy snapshot. The dashboard shows this history and can atomically roll back to any retained revision; rollback itself creates a new audit revision. `npm run doctor` validates the configured policy file and fails if the JSON, regex, version, or decision values are invalid. Example policy: diff --git a/src/dashboard.ts b/src/dashboard.ts index 8c9577a..710a024 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -3,6 +3,7 @@ import type { AppConfig } from "./config.js"; import type { TaskJournal } from "./task-journal.js"; import type { SafetyPolicy } from "./safety-policy.js"; import { loadSafetyPolicyFile, parseSafetyPolicyFile, saveSafetyPolicyFile } from "./safety-policy-file.js"; +import { SafetyPolicyAudit } from "./safety-policy-audit.js"; type Middleware = (request: Request, response: Response, next: () => void) => void; @@ -14,7 +15,7 @@ const dashboardHtml = ` `; export function registerDashboard(app: Express, config: AppConfig, journal: TaskJournal, safetyPolicy: SafetyPolicy, authenticate: Middleware, parseJson: Middleware): void { const base = "/dashboard"; const policyAudit = new SafetyPolicyAudit(config.safetyPolicyFile); - app.get(base, authenticate, (_request, response) => response.type("html").send(dashboardHtml)); - app.get(`${base}/api/tasks`, authenticate, async (_request, response) => response.json({ tasks: await journal.listTasks(100) })); - app.get(`${base}/api/approvals`, authenticate, (_request, response) => response.json({ mode: safetyPolicy.mode, approvals: safetyPolicy.list() })); - app.post(`${base}/api/approvals/:approvalId/approve`, authenticate, (request, response) => { + const dashboardAuth = createDashboardAuth(config, authenticate); + const loginLimiter = createDashboardLoginLimiter(); + const parseLogin = express.urlencoded({ extended: false, limit: "8kb" }); + + app.get(`${base}/login`, (request, response) => { + if (!config.dashboardUsername || !config.dashboardPassword) { response.status(404).type("text").send("Dashboard account login is not configured"); return; } + if (hasDashboardSession(config, request)) { response.redirect(302, base); return; } + response.type("html").send(loginHtml(request.query.error === "1")); + }); + app.post(`${base}/login`, parseLogin, (request, response) => { + if (!config.dashboardUsername || !config.dashboardPassword) { response.status(404).send("Dashboard account login is not configured"); return; } + if (loginLimiter.blocked(request)) { response.status(429).type("html").send(loginHtml(true)); return; } + const username = typeof request.body?.username === "string" ? request.body.username : ""; + const password = typeof request.body?.password === "string" ? request.body.password : ""; + if (!verifyDashboardCredentials(config, username, password)) { + loginLimiter.fail(request); + response.redirect(303, `${base}/login?error=1`); + return; + } + loginLimiter.success(request); + response.setHeader("Set-Cookie", dashboardCookie(config)); + response.redirect(303, base); + }); + app.post(`${base}/logout`, (_request, response) => { + response.setHeader("Set-Cookie", clearDashboardCookie(config)); + response.status(204).end(); + }); + + app.get(base, (request, response, next) => { + if (config.dashboardUsername && config.dashboardPassword && !hasDashboardSession(config, request) && !request.header("authorization")) { + redirectToDashboardLogin(response); + return; + } + dashboardAuth(request, response, next); + }, (_request, response) => response.type("html").send(dashboardHtml)); + app.get(`${base}/api/tasks`, dashboardAuth, async (_request, response) => response.json({ tasks: await journal.listTasks(100) })); + app.get(`${base}/api/approvals`, dashboardAuth, (_request, response) => response.json({ mode: safetyPolicy.mode, approvals: safetyPolicy.list() })); + app.post(`${base}/api/approvals/:approvalId/approve`, dashboardAuth, (request, response) => { try { response.json(safetyPolicy.approve(String(request.params.approvalId ?? ""))); } catch (error) { response.status(404).json({ error: error instanceof Error ? error.message : "Approval not found" }); } }); - app.post(`${base}/api/approvals/:approvalId/deny`, authenticate, (request, response) => { + app.post(`${base}/api/approvals/:approvalId/deny`, dashboardAuth, (request, response) => { try { response.json({ denied: true, approval: safetyPolicy.deny(String(request.params.approvalId ?? "")) }); } catch (error) { response.status(404).json({ error: error instanceof Error ? error.message : "Approval not found" }); } }); - app.get(`${base}/api/policy`, authenticate, (_request, response) => { + app.get(`${base}/api/policy`, dashboardAuth, (_request, response) => { response.json({ mode: safetyPolicy.mode, file: config.safetyPolicyFile, editable: Boolean(config.safetyPolicyFile), policy: safetyPolicy.policyFile }); }); - app.post(`${base}/api/policy/validate`, authenticate, parseJson, (request, response) => { + app.post(`${base}/api/policy/validate`, dashboardAuth, parseJson, (request, response) => { try { response.json({ valid: true, policy: parseSafetyPolicyFile(request.body) }); } catch (error) { response.status(400).json({ valid: false, error: error instanceof Error ? error.message : "Invalid policy" }); } }); - app.put(`${base}/api/policy`, authenticate, parseJson, async (request, response) => { + app.put(`${base}/api/policy`, dashboardAuth, parseJson, async (request, response) => { if (!config.safetyPolicyFile) { response.status(409).json({ error: "MCP_SAFETY_POLICY_FILE is not configured" }); return; } try { const policy = parseSafetyPolicyFile(request.body); @@ -54,7 +93,7 @@ export function registerDashboard(app: Express, config: AppConfig, journal: Task response.json({ saved: true, reloaded: true, policy, revision }); } catch (error) { response.status(400).json({ error: error instanceof Error ? error.message : "Failed to save policy" }); } }); - app.post(`${base}/api/policy/reload`, authenticate, async (_request, response) => { + app.post(`${base}/api/policy/reload`, dashboardAuth, async (_request, response) => { if (!config.safetyPolicyFile) { response.status(409).json({ error: "MCP_SAFETY_POLICY_FILE is not configured" }); return; } try { const policy = loadSafetyPolicyFile(config.safetyPolicyFile); @@ -63,11 +102,11 @@ export function registerDashboard(app: Express, config: AppConfig, journal: Task response.json({ reloaded: true, policy, revision }); } catch (error) { response.status(400).json({ error: error instanceof Error ? error.message : "Failed to reload policy" }); } }); - app.get(`${base}/api/policy/history`, authenticate, async (_request, response) => { + app.get(`${base}/api/policy/history`, dashboardAuth, async (_request, response) => { try { response.json({ history: await policyAudit.list(100), verification: await policyAudit.verify() }); } catch (error) { response.status(500).json({ error: error instanceof Error ? error.message : "Failed to read policy history" }); } }); - app.get(`${base}/api/policy/diff/:revisionId`, authenticate, async (request, response) => { + app.get(`${base}/api/policy/diff/:revisionId`, dashboardAuth, async (request, response) => { try { const revisionId = String(request.params.revisionId ?? ""); const target = await policyAudit.get(revisionId); @@ -76,7 +115,7 @@ export function registerDashboard(app: Express, config: AppConfig, journal: Task response.json({ revisionId, fromSha256: target.sha256, diff: diffSafetyPolicies(target.policy, current) }); } catch (error) { response.status(400).json({ error: error instanceof Error ? error.message : "Failed to diff policy" }); } }); - app.post(`${base}/api/policy/rollback/:revisionId`, authenticate, async (request, response) => { + app.post(`${base}/api/policy/rollback/:revisionId`, dashboardAuth, async (request, response) => { if (!config.safetyPolicyFile) { response.status(409).json({ error: "MCP_SAFETY_POLICY_FILE is not configured" }); return; } try { const revisionId = String(request.params.revisionId ?? ""); @@ -89,13 +128,13 @@ export function registerDashboard(app: Express, config: AppConfig, journal: Task response.json({ rolledBack: true, policy, revision, sourceRevision: target }); } catch (error) { response.status(400).json({ error: error instanceof Error ? error.message : "Failed to rollback policy" }); } }); - app.get(`${base}/api/tasks/:taskId`, authenticate, async (request, response) => { + app.get(`${base}/api/tasks/:taskId`, dashboardAuth, async (request, response) => { const taskId = String(request.params.taskId ?? ""); const task = await journal.getTask(taskId); if (!task) { response.status(404).json({ error: "Task not found" }); return; } response.json(task); }); - app.get(`${base}/api/tasks/:taskId/events`, authenticate, async (request, response) => { + app.get(`${base}/api/tasks/:taskId/events`, dashboardAuth, async (request, response) => { try { const afterSeq = Math.max(0, Number(request.query.afterSeq) || 0); const taskId = String(request.params.taskId ?? ""); diff --git a/test/config.test.ts b/test/config.test.ts index c15cc30..6e5a265 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -127,6 +127,22 @@ describe("loadConfig", () => { ), ).toThrow("must not contain user credentials"); }); + + it("validates optional dashboard account settings", () => { + const config = loadConfig({ + MCP_AUTH_TOKEN: AUTH_SECRET, + MCP_DASHBOARD_USERNAME: "admin", + MCP_DASHBOARD_PASSWORD: "correct-horse-battery-staple", + }, "/tmp"); + expect(config).toMatchObject({ + dashboardUsername: "admin", + dashboardPassword: "correct-horse-battery-staple", + dashboardSessionSecret: AUTH_SECRET, + }); + expect(() => loadConfig({ MCP_AUTH_TOKEN: AUTH_SECRET, MCP_DASHBOARD_USERNAME: "admin" }, "/tmp")).toThrow("configured together"); + expect(() => loadConfig({ MCP_AUTH_TOKEN: AUTH_SECRET, MCP_DASHBOARD_USERNAME: "admin", MCP_DASHBOARD_PASSWORD: "short" }, "/tmp")).toThrow("at least 12 characters"); + expect(() => loadConfig({ MCP_AUTH_TOKEN: AUTH_SECRET, MCP_DASHBOARD_USERNAME: "admin", MCP_DASHBOARD_PASSWORD: "replace-with-dashboard-password" }, "/tmp")).toThrow("example placeholder"); + }); }); describe("lifecycle configuration", () => { From ead19ef25fc020722200e2eab8b5c4b00600def1 Mon Sep 17 00:00:00 2001 From: k Date: Mon, 31 Aug 2026 12:07:41 +0000 Subject: [PATCH 24/30] feat: show automatic command sessions in dashboard --- README.md | 2 +- src/task-journal.ts | 169 ++++++++++++++++++++++++++++++-------- test/task-journal.test.ts | 57 +++++++++++++ 3 files changed, 193 insertions(+), 35 deletions(-) create mode 100644 test/task-journal.test.ts diff --git a/README.md b/README.md index 975c33d..ff6e529 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ Example policy: ## Task dashboard -Open `/dashboard` on the same server to inspect recent development tasks, summaries, and their ordered event timelines. For normal browser access, set `MCP_DASHBOARD_USERNAME` and `MCP_DASHBOARD_PASSWORD` in `.env`; unauthenticated browser visits are redirected to `/dashboard/login`, and a successful login creates a 12-hour HttpOnly, SameSite=Strict session cookie (`Secure` when `MCP_PUBLIC_URL` uses HTTPS). The existing MCP bearer/OAuth authentication remains accepted for dashboard routes and APIs. Login failures are rate-limited per client address. `MCP_DASHBOARD_SESSION_SECRET` is optional when an existing 32+ character MCP auth secret can sign sessions. The dashboard refreshes every three seconds, and its JSON endpoints under `/dashboard/api/tasks` can also be used by external monitoring UIs. +Open `/dashboard` on the same server to inspect recent development tasks, summaries, and their ordered event timelines. For normal browser access, set `MCP_DASHBOARD_USERNAME` and `MCP_DASHBOARD_PASSWORD` in `.env`; unauthenticated browser visits are redirected to `/dashboard/login`, and a successful login creates a 12-hour HttpOnly, SameSite=Strict session cookie (`Secure` when `MCP_PUBLIC_URL` uses HTTPS). The existing MCP bearer/OAuth authentication remains accepted for dashboard routes and APIs. Login failures are rate-limited per client address. `MCP_DASHBOARD_SESSION_SECRET` is optional when an existing 32+ character MCP auth secret can sign sessions. The dashboard refreshes every three seconds, and its JSON endpoints under `/dashboard/api/tasks` can also be used by external monitoring UIs. Commands run without an explicit `start_task` are automatically grouped into dashboard sessions; a 15-minute inactivity gap starts a new automatic session, so ordinary RMCP command activity remains visible without extra task-management calls. ## Reliability controls diff --git a/src/task-journal.ts b/src/task-journal.ts index 58a449c..f56b498 100644 --- a/src/task-journal.ts +++ b/src/task-journal.ts @@ -2,6 +2,9 @@ import { randomUUID } from "node:crypto"; import { appendFile, mkdir, readFile } from "node:fs/promises"; import path from "node:path"; +const AUTO_SESSION_GAP_MS = 15 * 60 * 1000; +const AUTO_PREFIX = "auto-"; + export interface TaskEvent { seq: number; timestamp: string; @@ -20,12 +23,49 @@ export interface TaskSummary { commands: Array<{ sessionId?: string; command: string; cwd?: string; exitCode?: number | null; timedOut?: boolean }>; filesChanged: string[]; eventCount: number; + automatic?: boolean; +} + +interface JournalView { + summaries: TaskSummary[]; + eventsByTask: Map; +} + +function compactCommand(command: string): string { + const first = command.split("\n").map((line) => line.trim()).find(Boolean) ?? "command"; + return first.length > 72 ? `${first.slice(0, 69)}...` : first; +} + +function appendProcessEvent(task: TaskSummary, entry: TaskEvent): void { + task.eventCount += 1; + if (entry.event === "process.started") { + task.commands.push({ + sessionId: typeof entry.sessionId === "string" ? entry.sessionId : undefined, + command: typeof entry.command === "string" ? entry.command : "", + cwd: typeof entry.cwd === "string" ? entry.cwd : undefined, + }); + } else if (entry.event === "process.completed") { + const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : undefined; + const command = [...task.commands].reverse().find((item) => item.sessionId === sessionId); + if (command) { + command.exitCode = typeof entry.exitCode === "number" || entry.exitCode === null ? entry.exitCode : undefined; + command.timedOut = entry.timedOut === true; + } + } else if (entry.event === "file.changed" && typeof entry.path === "string") { + if (!task.filesChanged.includes(entry.path)) task.filesChanged.push(entry.path); + } +} + +function autoSessionComplete(task: TaskSummary): boolean { + return task.commands.length > 0 && task.commands.every((command) => command.exitCode !== undefined || command.timedOut === true); } export class TaskJournal { readonly #file: string | undefined; readonly #memory: TaskEvent[] = []; #nextSeq = 1; + #sequenceInitialized = false; + #recordQueue: Promise = Promise.resolve(); constructor(file?: string) { this.#file = file; @@ -44,58 +84,95 @@ export class TaskJournal { } async record(event: string, data: Record = {}): Promise { - const entry: TaskEvent = { seq: this.#nextSeq++, timestamp: new Date().toISOString(), event, ...data }; - this.#memory.push(entry); - if (!this.#file) return; - await mkdir(path.dirname(this.#file), { recursive: true }); - await appendFile(this.#file, `${JSON.stringify(entry)}\n`, { encoding: "utf8", mode: 0o600 }); + const operation = this.#recordQueue.then(async () => { + await this.#ensureSequence(); + const entry: TaskEvent = { seq: this.#nextSeq++, timestamp: new Date().toISOString(), event, ...data }; + this.#memory.push(entry); + if (!this.#file) return; + await mkdir(path.dirname(this.#file), { recursive: true }); + await appendFile(this.#file, `${JSON.stringify(entry)}\n`, { encoding: "utf8", mode: 0o600 }); + }); + this.#recordQueue = operation.catch(() => undefined); + await operation; } async getTaskEvents(taskId: string, afterSeq = 0, limit = 200): Promise { - if (!(await this.getTask(taskId))) throw new Error(`Unknown task: ${taskId}`); - return (await this.#events()) - .filter((entry) => entry.taskId === taskId && entry.seq > afterSeq) + const view = await this.#view(); + const events = view.eventsByTask.get(taskId); + if (!events) throw new Error(`Unknown task: ${taskId}`); + return events + .filter((entry) => entry.seq > afterSeq) .sort((a, b) => a.seq - b.seq) .slice(0, limit); } async getTask(taskId: string): Promise { - const tasks = await this.#summaries(); - return tasks.find((task) => task.taskId === taskId); + return (await this.#view()).summaries.find((task) => task.taskId === taskId); } async listTasks(limit = 50): Promise { - const tasks = await this.#summaries(); + const tasks = (await this.#view()).summaries; return tasks.sort((a, b) => b.startedAt.localeCompare(a.startedAt)).slice(0, limit); } + async #ensureSequence(): Promise { + if (this.#sequenceInitialized) return; + if (this.#file) { + try { + const text = await readFile(this.#file, "utf8"); + let next = 1; + for (const line of text.split("\n").filter(Boolean)) { + try { + const parsed = JSON.parse(line) as Partial; + const raw = typeof parsed.seq === "number" && Number.isFinite(parsed.seq) ? Math.floor(parsed.seq) : next; + const normalized = Math.max(next, raw); + next = normalized + 1; + } catch { /* Ignore malformed historical lines. */ } + } + this.#nextSeq = Math.max(this.#nextSeq, next); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + this.#sequenceInitialized = true; + } + async #events(): Promise { if (!this.#file) return [...this.#memory]; let persisted: TaskEvent[] = []; try { const text = await readFile(this.#file, "utf8"); - let fallbackSeq = 1; + let nextSeq = 1; persisted = text.split("\n").filter(Boolean).flatMap((line) => { try { const parsed = JSON.parse(line) as Partial; - const seq = typeof parsed.seq === "number" ? parsed.seq : fallbackSeq; - fallbackSeq = Math.max(fallbackSeq + 1, seq + 1); + const raw = typeof parsed.seq === "number" && Number.isFinite(parsed.seq) ? Math.floor(parsed.seq) : nextSeq; + const seq = Math.max(nextSeq, raw); + nextSeq = seq + 1; return [{ ...parsed, seq } as TaskEvent]; } catch { return []; } }); - this.#nextSeq = Math.max(this.#nextSeq, fallbackSeq); + this.#nextSeq = Math.max(this.#nextSeq, nextSeq); + this.#sequenceInitialized = true; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } - const persistedKeys = new Set(persisted.map((e) => JSON.stringify(e))); - return [...persisted, ...this.#memory.filter((e) => !persistedKeys.has(JSON.stringify(e)))]; + const persistedKeys = new Set(persisted.map((e) => `${e.timestamp}|${e.event}|${String(e.sessionId ?? "")}|${String(e.taskId ?? "")}`)); + return [...persisted, ...this.#memory.filter((e) => !persistedKeys.has(`${e.timestamp}|${e.event}|${String(e.sessionId ?? "")}|${String(e.taskId ?? "")}`))] + .sort((a, b) => a.timestamp.localeCompare(b.timestamp) || a.seq - b.seq); } - async #summaries(): Promise { + async #view(): Promise { + const events = await this.#events(); const tasks = new Map(); - for (const entry of await this.#events()) { + const eventsByTask = new Map(); + + for (const entry of events) { const taskId = typeof entry.taskId === "string" ? entry.taskId : undefined; if (!taskId) continue; + const taskEvents = eventsByTask.get(taskId) ?? []; + taskEvents.push(entry); + eventsByTask.set(taskId, taskEvents); if (entry.event === "task.started") { tasks.set(taskId, { taskId, @@ -111,27 +188,51 @@ export class TaskJournal { } const task = tasks.get(taskId); if (!task) continue; - task.eventCount += 1; if (entry.event === "task.completed") { + task.eventCount += 1; task.status = "completed"; task.endedAt = entry.timestamp; - } else if (entry.event === "process.started") { - task.commands.push({ - sessionId: typeof entry.sessionId === "string" ? entry.sessionId : undefined, - command: typeof entry.command === "string" ? entry.command : "", + } else { + appendProcessEvent(task, entry); + } + } + + const orphanEvents = events.filter((entry) => !entry.taskId && entry.event.startsWith("process.")); + let currentId: string | undefined; + let currentLastAt = 0; + for (const entry of orphanEvents) { + const at = Date.parse(entry.timestamp); + const startsNew = !currentId || !Number.isFinite(at) || at - currentLastAt > AUTO_SESSION_GAP_MS; + if (startsNew) { + const seed = typeof entry.sessionId === "string" ? entry.sessionId : `${entry.timestamp}-${entry.seq}`; + currentId = `${AUTO_PREFIX}${seed}`; + const command = typeof entry.command === "string" ? entry.command : "command"; + tasks.set(currentId, { + taskId: currentId, + title: `Auto session · ${compactCommand(command)}`, cwd: typeof entry.cwd === "string" ? entry.cwd : undefined, + status: "active", + startedAt: entry.timestamp, + commands: [], + filesChanged: [], + eventCount: 0, + automatic: true, }); - } else if (entry.event === "process.completed") { - const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : undefined; - const command = [...task.commands].reverse().find((item) => item.sessionId === sessionId); - if (command) { - command.exitCode = typeof entry.exitCode === "number" || entry.exitCode === null ? entry.exitCode : undefined; - command.timedOut = entry.timedOut === true; - } - } else if (entry.event === "file.changed" && typeof entry.path === "string") { - if (!task.filesChanged.includes(entry.path)) task.filesChanged.push(entry.path); + eventsByTask.set(currentId, []); + } + currentLastAt = Number.isFinite(at) ? at : currentLastAt; + const task = tasks.get(currentId!)!; + eventsByTask.get(currentId!)!.push(entry); + appendProcessEvent(task, entry); + if (autoSessionComplete(task)) { + task.status = "completed"; + task.endedAt = entry.timestamp; + } else { + task.status = "active"; + task.endedAt = undefined; } } - return [...tasks.values()]; + + return { summaries: [...tasks.values()], eventsByTask }; } } diff --git a/test/task-journal.test.ts b/test/task-journal.test.ts new file mode 100644 index 0000000..4393583 --- /dev/null +++ b/test/task-journal.test.ts @@ -0,0 +1,57 @@ +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { TaskJournal } from "../src/task-journal.js"; + +describe("TaskJournal automatic sessions", () => { + it("groups unscoped process events into dashboard-visible automatic sessions", async () => { + const journal = new TaskJournal(); + await journal.record("process.started", { sessionId: "s1", command: "npm test", cwd: "/workspace/app" }); + await journal.record("process.completed", { sessionId: "s1", command: "npm test", cwd: "/workspace/app", exitCode: 0 }); + await journal.record("process.started", { sessionId: "s2", command: "git status", cwd: "/workspace/app" }); + await journal.record("process.completed", { sessionId: "s2", command: "git status", cwd: "/workspace/app", exitCode: 0 }); + + const tasks = await journal.listTasks(); + expect(tasks).toHaveLength(1); + expect(tasks[0]).toMatchObject({ automatic: true, status: "completed", cwd: "/workspace/app", eventCount: 4 }); + expect(tasks[0]!.title).toContain("npm test"); + expect(tasks[0]!.commands.map((item) => item.command)).toEqual(["npm test", "git status"]); + expect((await journal.getTaskEvents(tasks[0]!.taskId)).map((item) => item.event)).toEqual([ + "process.started", "process.completed", "process.started", "process.completed", + ]); + }); + + it("keeps explicit tasks separate from automatic sessions", async () => { + const journal = new TaskJournal(); + const explicit = await journal.startTask("Explicit task", "/workspace/app"); + await journal.record("process.started", { taskId: explicit.taskId, sessionId: "explicit", command: "npm build", cwd: "/workspace/app" }); + await journal.record("process.completed", { taskId: explicit.taskId, sessionId: "explicit", command: "npm build", cwd: "/workspace/app", exitCode: 0 }); + await journal.completeTask(explicit.taskId); + await journal.record("process.started", { sessionId: "auto", command: "git status", cwd: "/workspace/app" }); + await journal.record("process.completed", { sessionId: "auto", command: "git status", cwd: "/workspace/app", exitCode: 0 }); + + const tasks = await journal.listTasks(); + expect(tasks).toHaveLength(2); + expect(tasks.some((task) => task.taskId === explicit.taskId && !task.automatic)).toBe(true); + expect(tasks.some((task) => task.automatic)).toBe(true); + }); + + it("normalizes duplicate historical sequences and continues after the persisted maximum", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "cokacremote-journal-")); + const file = path.join(dir, "journal.jsonl"); + const old = [ + { seq: 1, timestamp: "2026-01-01T00:00:00.000Z", event: "process.started", sessionId: "a", command: "one" }, + { seq: 2, timestamp: "2026-01-01T00:00:01.000Z", event: "process.completed", sessionId: "a", command: "one", exitCode: 0 }, + { seq: 1, timestamp: "2026-01-01T00:01:00.000Z", event: "process.started", sessionId: "b", command: "two" }, + ]; + await writeFile(file, old.map((entry) => JSON.stringify(entry)).join("\n") + "\n"); + const journal = new TaskJournal(file); + const tasks = await journal.listTasks(); + const events = await journal.getTaskEvents(tasks[0]!.taskId); + expect(events.map((entry) => entry.seq)).toEqual([1, 2, 3]); + await journal.record("process.completed", { sessionId: "b", command: "two", exitCode: 0 }); + const lines = (await readFile(file, "utf8")).trim().split("\n").map((line) => JSON.parse(line) as { seq: number }); + expect(lines.at(-1)?.seq).toBe(4); + }); +}); From 12647921e06838e8c3f2f1e35c573e7be4f1c6ed Mon Sep 17 00:00:00 2001 From: k Date: Mon, 31 Aug 2026 12:10:15 +0000 Subject: [PATCH 25/30] fix: use dashboard api absolute paths --- src/dashboard.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/dashboard.ts b/src/dashboard.ts index 0105a30..d08fe86 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -20,12 +20,12 @@ const dashboardHtml = ` `; export function registerDashboard(app: Express, config: AppConfig, journal: TaskJournal, safetyPolicy: SafetyPolicy, authenticate: Middleware, parseJson: Middleware): void { From 926e9e9951160303a8725940bc676ca075212976 Mon Sep 17 00:00:00 2001 From: k Date: Mon, 31 Aug 2026 12:14:33 +0000 Subject: [PATCH 26/30] fix: preserve dashboard inline script syntax --- src/dashboard.ts | 2 +- test/dashboard.integration.test.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/dashboard.ts b/src/dashboard.ts index d08fe86..671b9f7 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -20,7 +20,7 @@ const dashboardHtml = ` `; + +cokacremote dashboard +
cokacremote
Development task timeline · 작업 결과를 한눈에 확인합니다
불러오는 중...
+
왼쪽에서 작업을 선택하면 핵심 결과를 요약해서 보여줍니다.
+`; +const dashboardJs = String.raw` +(function(){ + 'use strict'; + var selected=null, currentTab='overview', taskCache=[], filter='all', search=''; + var esc=function(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}; + var fmtTime=function(v){try{return new Date(v).toLocaleString();}catch{return String(v||'');}}; + var fmtClock=function(v){try{return new Date(v).toLocaleTimeString();}catch{return '';}}; + var fmtDuration=function(ms){if(!Number.isFinite(ms)||ms<0)return '-';if(ms<1000)return Math.round(ms)+'ms';var s=Math.round(ms/1000);if(s<60)return s+'초';var m=Math.floor(s/60);s=s%60;if(m<60)return m+'분 '+s+'초';var h=Math.floor(m/60);m=m%60;return h+'시간 '+m+'분';}; + var elapsed=function(t){var a=Date.parse(t.startedAt),b=Date.parse(t.endedAt||new Date().toISOString());return Number.isFinite(a)&&Number.isFinite(b)?Math.max(0,b-a):0;}; + var api=async function(path,options){var r=await fetch(path,Object.assign({cache:'no-store'},options||{}));if(r.status===401){location.href='/dashboard/login';throw new Error('로그인이 필요합니다.');}if(!r.ok)throw new Error(await r.text());return r.json();}; + var cmdState=function(c){if(c.timedOut)return {key:'fail',label:'TIMEOUT'};if(c.exitCode===undefined)return {key:'run',label:'RUNNING'};if(c.exitCode===0)return {key:'ok',label:'SUCCESS'};return {key:'fail',label:c.exitCode===null?'STOPPED':'EXIT '+c.exitCode};}; + var taskFailed=function(t){return (t.commands||[]).some(function(c){return c.timedOut||c.exitCode!==undefined&&c.exitCode!==0;});}; + var taskState=function(t){if(t.status==='active')return {key:'run',label:'진행중'};if(taskFailed(t))return {key:'fail',label:'실패'};return {key:'ok',label:'완료'};}; + var issueEvents=function(events){return events.filter(function(e){return e.event==='tool.failed'||e.event==='process.idle_timeout'||e.event==='process.completed'&&(e.timedOut===true||e.error||typeof e.exitCode==='number'&&e.exitCode!==0);});}; + var humanEvent=function(e){if(e.event==='task.started')return '작업 시작';if(e.event==='task.completed')return '작업 완료';if(e.event==='tool.started')return '도구 실행 · '+(e.toolName||'');if(e.event==='tool.completed')return '도구 완료 · '+(e.toolName||'');if(e.event==='tool.failed')return '도구 실패 · '+(e.toolName||'');if(e.event==='process.started')return '명령 시작';if(e.event==='process.completed')return '명령 종료';if(e.event==='process.idle_timeout')return '명령 유휴 타임아웃';if(e.event==='file.changed')return '파일 변경 · '+(e.operation||'');return e.event;}; + var issueText=function(e){if(e.error)return String(e.error);if(e.timedOut)return '실행 시간이 초과되었습니다.';if(typeof e.exitCode==='number')return '명령이 exit code '+e.exitCode+' 로 종료되었습니다.';return humanEvent(e);}; + var copyButton=function(text){return '';}; + var bindCopies=function(){document.querySelectorAll('[data-copy]').forEach(function(b){b.onclick=async function(ev){ev.stopPropagation();try{await navigator.clipboard.writeText(decodeURIComponent(b.dataset.copy||''));var old=b.textContent;b.textContent='복사됨';setTimeout(function(){b.textContent=old;},900);}catch{}};});}; + + function taskHtml(t){var state=taskState(t), failed=(t.commands||[]).filter(function(c){return cmdState(c).key==='fail';}).length;return '
'+esc(t.title)+'
'+state.label+''+(t.automatic?'AUTO':'')+'명령 '+(t.commands||[]).length+''+(failed?'실패 '+failed+'':'')+''+esc(fmtTime(t.startedAt))+'
';} + function matchesTask(t){var q=search.trim().toLowerCase();if(q){var hay=(t.title+' '+(t.cwd||'')+' '+(t.commands||[]).map(function(c){return c.command;}).join(' ')).toLowerCase();if(!hay.includes(q))return false;}if(filter==='active')return t.status==='active';if(filter==='failed')return taskFailed(t);if(filter==='completed')return t.status==='completed'&&!taskFailed(t);return true;} + async function loadTasks(){try{var d=await api('/dashboard/api/tasks');taskCache=d.tasks||[];var shown=taskCache.filter(matchesTask);document.getElementById('taskCount').textContent=shown.length+' / '+taskCache.length;document.getElementById('tasks').innerHTML=shown.map(taskHtml).join('')||'
조건에 맞는 작업이 없습니다.
';document.querySelectorAll('.task[data-id]').forEach(function(e){e.onclick=function(){selected=e.dataset.id;currentTab='overview';loadTasks();loadDetail();};});document.getElementById('refresh').textContent='업데이트 '+new Date().toLocaleTimeString();}catch(e){document.getElementById('refresh').textContent='업데이트 실패';}} + async function loadApprovals(){try{var d=await api('/dashboard/api/approvals');var pending=d.approvals.filter(function(a){return !a.approvedAt&&!a.consumedAt;});document.getElementById('approvals').innerHTML=pending.length?'

승인 대기 '+pending.length+'건

'+pending.map(function(a){return '
'+esc(a.toolName)+'
'+esc(a.summary)+'
';}).join('
')+'
':'';document.querySelectorAll('[data-approve]').forEach(function(b){b.onclick=async function(){await api('/dashboard/api/approvals/'+b.dataset.approve+'/approve',{method:'POST'});loadApprovals();};});document.querySelectorAll('[data-deny]').forEach(function(b){b.onclick=async function(){await api('/dashboard/api/approvals/'+b.dataset.deny+'/deny',{method:'POST'});loadApprovals();};});}catch{}} + + function commandRows(t){if(!(t.commands||[]).length)return '
실행된 명령이 없습니다.
';return '
'+t.commands.map(function(c,i){var st=cmdState(c);return '
명령 #'+(i+1)+'
'+st.label+' '+copyButton(c.command||'')+'
'+esc(c.command||'')+'
'+(c.cwd?'
cwd · '+esc(c.cwd)+'
':'')+'
';}).join('')+'
';} + function fileRows(t,events){var changes=events.filter(function(e){return e.event==='file.changed'&&e.path;});var seen=new Map();changes.forEach(function(e){seen.set(String(e.path),e.operation||'changed');});(t.filesChanged||[]).forEach(function(p){if(!seen.has(p))seen.set(p,'changed');});if(!seen.size)return '
변경된 파일이 없습니다.
';return '
'+Array.from(seen.entries()).map(function(pair){return '
'+esc(pair[0])+''+esc(pair[1])+'
';}).join('')+'
';} + function issueRows(issues){if(!issues.length)return '
문제 없음기록된 오류나 타임아웃이 없습니다.
';return '
'+issues.map(function(e){return '
'+esc(humanEvent(e))+'
'+esc(fmtTime(e.timestamp))+'
'+esc(issueText(e))+'
'+(e.command?'
'+esc(e.command)+'
':'')+'
';}).join('')+'
';} + function rawRows(events){if(!events.length)return '
이벤트가 없습니다.
';return '
'+events.slice().reverse().map(function(e){var copy=Object.assign({},e);delete copy.seq;delete copy.timestamp;delete copy.event;return '
#'+esc(e.seq)+' · '+esc(humanEvent(e))+' '+esc(fmtClock(e.timestamp))+'
'+esc(JSON.stringify(copy,null,2))+'
';}).join('')+'
';} + function overview(t,events,issues){var failed=(t.commands||[]).filter(function(c){return cmdState(c).key==='fail';}).length;var running=(t.commands||[]).filter(function(c){return cmdState(c).key==='run';}).length;var state=issues.length?'fail':t.status==='active'?'run':'ok';var msg=issues.length?'주의가 필요한 문제 '+issues.length+'건이 있습니다.':t.status==='active'?'현재 작업이 진행 중입니다.':'오류 없이 작업이 종료되었습니다.';var recent=events.slice(-6).reverse();return '
'+(state==='fail'?'확인 필요':state==='run'?'진행 중':'정상 완료')+''+esc(msg)+'
상태
'+esc(taskState(t).label)+'
소요 시간
'+esc(fmtDuration(elapsed(t)))+'
명령
'+(t.commands||[]).length+'
실패 / 실행중
'+failed+' / '+running+'
변경 파일
'+(t.filesChanged||[]).length+'
'+(issues.length?'

문제 요약

'+issueRows(issues)+'
':'')+'

명령 결과

'+commandRows({commands:(t.commands||[]).slice(0,5)})+'
'+(t.commands.length>5?'
전체 '+t.commands.length+'개 명령은 Commands 탭에서 확인할 수 있습니다.
':'')+'

최근 활동

'+recent.map(function(e){return '
'+esc(humanEvent(e))+''+esc(fmtClock(e.timestamp))+'
';}).join('')+'
';} + function tabBar(t,issues){return '
';} + async function loadDetail(){if(!selected)return;try{var pair=await Promise.all([api('/dashboard/api/tasks/'+encodeURIComponent(selected)),api('/dashboard/api/tasks/'+encodeURIComponent(selected)+'/events')]);var t=pair[0],events=pair[1].events||[],issues=issueEvents(events);var state=taskState(t);var body=currentTab==='commands'?commandRows(t):currentTab==='files'?fileRows(t,events):currentTab==='issues'?issueRows(issues):currentTab==='raw'?rawRows(events):overview(t,events,issues);document.getElementById('detail').innerHTML='

'+esc(t.title)+'

'+esc(t.cwd||'작업 경로 정보 없음')+'
'+state.label+''+(t.automatic?' AUTO SESSION':'')+'
'+tabBar(t,issues)+'
'+body+'
';document.querySelectorAll('[data-tab]').forEach(function(b){b.onclick=function(){currentTab=b.dataset.tab;loadDetail();};});bindCopies();}catch(e){document.getElementById('detail').innerHTML='
작업 상세 정보를 불러오지 못했습니다.
'+esc(String(e))+'
';}} + + async function loadPolicy(){try{var d=await api('/dashboard/api/policy');var text=JSON.stringify(d.policy||{version:1},null,2);document.getElementById('detail').innerHTML='

Safety policy

Mode: '+esc(d.mode)+' · '+esc(d.file||'policy file 미설정')+'

변경 이력

불러오는 중...
';var note=function(m,c){document.getElementById('policyNotice').innerHTML='
'+esc(m)+'
';};var loadHistory=async function(){try{var h=await api('/dashboard/api/policy/history');var verify=h.verification||{};document.getElementById('policyHistory').innerHTML='
Audit chain: '+(verify.integrity?'verified':'BROKEN')+' · chained '+esc(verify.chainedEntries||0)+' · legacy '+esc(verify.legacyEntries||0)+'
'+(h.history.length?h.history.map(function(x){return '
'+esc(x.action)+' · '+esc(fmtTime(x.timestamp))+'
policy '+esc(x.sha256.slice(0,16))+' · '+esc(x.revisionId)+'
';}).join(''):'이력이 없습니다.');document.querySelectorAll('[data-diff]').forEach(function(b){b.onclick=async function(){var out=document.querySelector('[data-diff-output="'+b.dataset.diff+'"]');try{var x=await api('/dashboard/api/policy/diff/'+b.dataset.diff);out.innerHTML='
'+x.diff.map(function(l){return ''+esc(l.type==='add'?'+ '+l.line:l.type==='remove'?'- '+l.line:' '+l.line)+'';}).join(String.fromCharCode(10))+'
';}catch(e){out.textContent=String(e);}};});document.querySelectorAll('[data-rollback]').forEach(function(b){b.onclick=async function(){if(!confirm('이 정책 버전으로 롤백할까요?'))return;try{await api('/dashboard/api/policy/rollback/'+b.dataset.rollback,{method:'POST'});loadPolicy();}catch(e){note(String(e),'error');}};});}catch(e){document.getElementById('policyHistory').textContent='이력을 불러오지 못했습니다.';}};loadHistory();document.getElementById('validatePolicy').onclick=async function(){try{var policy=JSON.parse(document.getElementById('policyEditor').value);await api('/dashboard/api/policy/validate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(policy)});note('정책이 유효합니다.','ok');}catch(e){note(String(e),'error');}};document.getElementById('savePolicy').onclick=async function(){try{var policy=JSON.parse(document.getElementById('policyEditor').value);await api('/dashboard/api/policy',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(policy)});note('저장하고 즉시 반영했습니다.','ok');loadHistory();}catch(e){note(String(e),'error');}};document.getElementById('reloadPolicy').onclick=async function(){try{await api('/dashboard/api/policy/reload',{method:'POST'});loadPolicy();}catch(e){note(String(e),'error');}};}catch(e){document.getElementById('detail').innerHTML='
'+esc(String(e))+'
';}} + + document.getElementById('logoutBtn').onclick=async function(){await fetch('/dashboard/logout',{method:'POST'});location.href='/dashboard/login';}; + document.getElementById('policyBtn').onclick=function(){selected=null;loadTasks();loadPolicy();}; + document.getElementById('taskSearch').oninput=function(e){search=e.target.value||'';loadTasks();}; + document.querySelectorAll('[data-filter]').forEach(function(b){b.onclick=function(){filter=b.dataset.filter;document.querySelectorAll('[data-filter]').forEach(function(x){x.classList.toggle('active',x===b);});loadTasks();};}); + loadApprovals();loadTasks();setInterval(function(){loadApprovals();loadTasks();if(selected)loadDetail();},3000); +})(); +`; export function registerDashboard(app: Express, config: AppConfig, journal: TaskJournal, safetyPolicy: SafetyPolicy, authenticate: Middleware, parseJson: Middleware): void { const base = "/dashboard"; const policyAudit = new SafetyPolicyAudit(config.safetyPolicyFile); @@ -59,6 +93,10 @@ export function registerDashboard(app: Express, config: AppConfig, journal: Task response.status(204).end(); }); + app.get(`${base}/app.js`, (_request, response) => { + response.type("application/javascript").set("Cache-Control", "no-store").send(dashboardJs); + }); + app.get(base, (request, response, next) => { if (config.dashboardUsername && config.dashboardPassword && !hasDashboardSession(config, request) && !request.header("authorization")) { redirectToDashboardLogin(response); diff --git a/test/dashboard.integration.test.ts b/test/dashboard.integration.test.ts index 80588de..2be7fa1 100644 --- a/test/dashboard.integration.test.ts +++ b/test/dashboard.integration.test.ts @@ -32,9 +32,14 @@ describe("task dashboard", () => { expect(html.status).toBe(200); const dashboardHtml = await html.text(); expect(dashboardHtml).toContain("Development task timeline"); - const script = dashboardHtml.match(/`; const dashboardJs = String.raw` @@ -32,8 +32,7 @@ const dashboardJs = String.raw` var elapsed=function(t){var a=Date.parse(t.startedAt),b=Date.parse(t.endedAt||new Date().toISOString());return Number.isFinite(a)&&Number.isFinite(b)?Math.max(0,b-a):0;}; var api=async function(path,options){var r=await fetch(path,Object.assign({cache:'no-store'},options||{}));if(r.status===401){location.href='/dashboard/login';throw new Error('로그인이 필요합니다.');}if(!r.ok)throw new Error(await r.text());return r.json();}; var cmdState=function(c){if(c.timedOut)return {key:'fail',label:'TIMEOUT'};if(c.exitCode===undefined)return {key:'run',label:'RUNNING'};if(c.exitCode===0)return {key:'ok',label:'SUCCESS'};return {key:'fail',label:c.exitCode===null?'STOPPED':'EXIT '+c.exitCode};}; - var taskFailed=function(t){return (t.commands||[]).some(function(c){return c.timedOut||c.exitCode!==undefined&&c.exitCode!==0;});}; - var taskState=function(t){if(t.status==='active')return {key:'run',label:'진행중'};if(taskFailed(t))return {key:'fail',label:'실패'};return {key:'ok',label:'완료'};}; + var taskState=function(t){var commands=t.commands||[],failed=Number(t.failedCommandCount||commands.filter(function(c){return cmdState(c).key==='fail';}).length),toolFailed=Number(t.toolFailureCount||0);if(t.status==='active'||t.lastCommandStatus==='running')return {key:'run',code:'active',label:'진행중',message:'현재 작업이 진행 중입니다.'};if(commands.length&&t.lastCommandStatus==='failed')return {key:'fail',code:'failed',label:'최종 실패',message:'마지막 실행 명령이 실패했습니다.'};if(!commands.length&&t.lastToolStatus==='failed')return {key:'fail',code:'failed',label:'최종 실패',message:'마지막 도구 실행이 실패했습니다.'};if(failed>0||toolFailed>0)return {key:'warn',code:'warning',label:'완료 · 중간 오류',message:'중간에 오류가 있었지만 마지막 실행은 정상적으로 끝났습니다.'};return {key:'ok',code:'completed',label:'정상 완료',message:'오류 없이 작업이 정상 종료되었습니다.'};}; var issueEvents=function(events){return events.filter(function(e){return e.event==='tool.failed'||e.event==='process.idle_timeout'||e.event==='process.completed'&&(e.timedOut===true||e.error||typeof e.exitCode==='number'&&e.exitCode!==0);});}; var humanEvent=function(e){if(e.event==='task.started')return '작업 시작';if(e.event==='task.completed')return '작업 완료';if(e.event==='tool.started')return '도구 실행 · '+(e.toolName||'');if(e.event==='tool.completed')return '도구 완료 · '+(e.toolName||'');if(e.event==='tool.failed')return '도구 실패 · '+(e.toolName||'');if(e.event==='process.started')return '명령 시작';if(e.event==='process.completed')return '명령 종료';if(e.event==='process.idle_timeout')return '명령 유휴 타임아웃';if(e.event==='file.changed')return '파일 변경 · '+(e.operation||'');return e.event;}; var issueText=function(e){if(e.error)return String(e.error);if(e.timedOut)return '실행 시간이 초과되었습니다.';if(typeof e.exitCode==='number')return '명령이 exit code '+e.exitCode+' 로 종료되었습니다.';return humanEvent(e);}; @@ -41,7 +40,7 @@ const dashboardJs = String.raw` var bindCopies=function(){document.querySelectorAll('[data-copy]').forEach(function(b){b.onclick=async function(ev){ev.stopPropagation();try{await navigator.clipboard.writeText(decodeURIComponent(b.dataset.copy||''));var old=b.textContent;b.textContent='복사됨';setTimeout(function(){b.textContent=old;},900);}catch{}};});}; function taskHtml(t){var state=taskState(t), failed=(t.commands||[]).filter(function(c){return cmdState(c).key==='fail';}).length;return '
'+esc(t.title)+'
'+state.label+''+(t.automatic?'AUTO':'')+'명령 '+(t.commands||[]).length+''+(failed?'실패 '+failed+'':'')+''+esc(fmtTime(t.startedAt))+'
';} - function matchesTask(t){var q=search.trim().toLowerCase();if(q){var hay=(t.title+' '+(t.cwd||'')+' '+(t.commands||[]).map(function(c){return c.command;}).join(' ')).toLowerCase();if(!hay.includes(q))return false;}if(filter==='active')return t.status==='active';if(filter==='failed')return taskFailed(t);if(filter==='completed')return t.status==='completed'&&!taskFailed(t);return true;} + function matchesTask(t){var q=search.trim().toLowerCase();if(q){var hay=(t.title+' '+(t.cwd||'')+' '+(t.commands||[]).map(function(c){return c.command;}).join(' ')).toLowerCase();if(!hay.includes(q))return false;}var state=taskState(t);if(filter==='active')return state.code==='active';if(filter==='warning')return state.code==='warning';if(filter==='failed')return state.code==='failed';if(filter==='completed')return state.code==='completed';return true;} async function loadTasks(){try{var d=await api('/dashboard/api/tasks');taskCache=d.tasks||[];var shown=taskCache.filter(matchesTask);document.getElementById('taskCount').textContent=shown.length+' / '+taskCache.length;document.getElementById('tasks').innerHTML=shown.map(taskHtml).join('')||'
조건에 맞는 작업이 없습니다.
';document.querySelectorAll('.task[data-id]').forEach(function(e){e.onclick=function(){selected=e.dataset.id;currentTab='overview';loadTasks();loadDetail();};});document.getElementById('refresh').textContent='업데이트 '+new Date().toLocaleTimeString();}catch(e){document.getElementById('refresh').textContent='업데이트 실패';}} async function loadApprovals(){try{var d=await api('/dashboard/api/approvals');var pending=d.approvals.filter(function(a){return !a.approvedAt&&!a.consumedAt;});document.getElementById('approvals').innerHTML=pending.length?'

승인 대기 '+pending.length+'건

'+pending.map(function(a){return '
'+esc(a.toolName)+'
'+esc(a.summary)+'
';}).join('
')+'
':'';document.querySelectorAll('[data-approve]').forEach(function(b){b.onclick=async function(){await api('/dashboard/api/approvals/'+b.dataset.approve+'/approve',{method:'POST'});loadApprovals();};});document.querySelectorAll('[data-deny]').forEach(function(b){b.onclick=async function(){await api('/dashboard/api/approvals/'+b.dataset.deny+'/deny',{method:'POST'});loadApprovals();};});}catch{}} @@ -49,7 +48,7 @@ const dashboardJs = String.raw` function fileRows(t,events){var changes=events.filter(function(e){return e.event==='file.changed'&&e.path;});var seen=new Map();changes.forEach(function(e){seen.set(String(e.path),e.operation||'changed');});(t.filesChanged||[]).forEach(function(p){if(!seen.has(p))seen.set(p,'changed');});if(!seen.size)return '
변경된 파일이 없습니다.
';return '
'+Array.from(seen.entries()).map(function(pair){return '
'+esc(pair[0])+''+esc(pair[1])+'
';}).join('')+'
';} function issueRows(issues){if(!issues.length)return '
문제 없음기록된 오류나 타임아웃이 없습니다.
';return '
'+issues.map(function(e){return '
'+esc(humanEvent(e))+'
'+esc(fmtTime(e.timestamp))+'
'+esc(issueText(e))+'
'+(e.command?'
'+esc(e.command)+'
':'')+'
';}).join('')+'
';} function rawRows(events){if(!events.length)return '
이벤트가 없습니다.
';return '
'+events.slice().reverse().map(function(e){var copy=Object.assign({},e);delete copy.seq;delete copy.timestamp;delete copy.event;return '
#'+esc(e.seq)+' · '+esc(humanEvent(e))+' '+esc(fmtClock(e.timestamp))+'
'+esc(JSON.stringify(copy,null,2))+'
';}).join('')+'
';} - function overview(t,events,issues){var failed=(t.commands||[]).filter(function(c){return cmdState(c).key==='fail';}).length;var running=(t.commands||[]).filter(function(c){return cmdState(c).key==='run';}).length;var state=issues.length?'fail':t.status==='active'?'run':'ok';var msg=issues.length?'주의가 필요한 문제 '+issues.length+'건이 있습니다.':t.status==='active'?'현재 작업이 진행 중입니다.':'오류 없이 작업이 종료되었습니다.';var recent=events.slice(-6).reverse();return '
'+(state==='fail'?'확인 필요':state==='run'?'진행 중':'정상 완료')+''+esc(msg)+'
상태
'+esc(taskState(t).label)+'
소요 시간
'+esc(fmtDuration(elapsed(t)))+'
명령
'+(t.commands||[]).length+'
실패 / 실행중
'+failed+' / '+running+'
변경 파일
'+(t.filesChanged||[]).length+'
'+(issues.length?'

문제 요약

'+issueRows(issues)+'
':'')+'

명령 결과

'+commandRows({commands:(t.commands||[]).slice(0,5)})+'
'+(t.commands.length>5?'
전체 '+t.commands.length+'개 명령은 Commands 탭에서 확인할 수 있습니다.
':'')+'

최근 활동

'+recent.map(function(e){return '
'+esc(humanEvent(e))+''+esc(fmtClock(e.timestamp))+'
';}).join('')+'
';} + function overview(t,events,issues){var failed=(t.commands||[]).filter(function(c){return cmdState(c).key==='fail';}).length;var running=(t.commands||[]).filter(function(c){return cmdState(c).key==='run';}).length;var outcome=taskState(t),state=outcome.key;var recent=events.slice(-6).reverse();return '
'+esc(outcome.label)+''+esc(outcome.message)+(issues.length?' · 기록된 이슈 '+issues.length+'건':'')+'
상태
'+esc(taskState(t).label)+'
소요 시간
'+esc(fmtDuration(elapsed(t)))+'
명령
'+(t.commands||[]).length+'
실패 / 실행중
'+failed+' / '+running+'
변경 파일
'+(t.filesChanged||[]).length+'
'+(issues.length?'

문제 요약

'+issueRows(issues)+'
':'')+'

명령 결과

'+commandRows({commands:(t.commands||[]).slice(0,5)})+'
'+(t.commands.length>5?'
전체 '+t.commands.length+'개 명령은 Commands 탭에서 확인할 수 있습니다.
':'')+'

최근 활동

'+recent.map(function(e){return '
'+esc(humanEvent(e))+''+esc(fmtClock(e.timestamp))+'
';}).join('')+'
';} function tabBar(t,issues){return '
';} async function loadDetail(){if(!selected)return;try{var pair=await Promise.all([api('/dashboard/api/tasks/'+encodeURIComponent(selected)),api('/dashboard/api/tasks/'+encodeURIComponent(selected)+'/events')]);var t=pair[0],events=pair[1].events||[],issues=issueEvents(events);var state=taskState(t);var body=currentTab==='commands'?commandRows(t):currentTab==='files'?fileRows(t,events):currentTab==='issues'?issueRows(issues):currentTab==='raw'?rawRows(events):overview(t,events,issues);document.getElementById('detail').innerHTML='

'+esc(t.title)+'

'+esc(t.cwd||'작업 경로 정보 없음')+'
'+state.label+''+(t.automatic?' AUTO SESSION':'')+'
'+tabBar(t,issues)+'
'+body+'
';document.querySelectorAll('[data-tab]').forEach(function(b){b.onclick=function(){currentTab=b.dataset.tab;loadDetail();};});bindCopies();}catch(e){document.getElementById('detail').innerHTML='
작업 상세 정보를 불러오지 못했습니다.
'+esc(String(e))+'
';}} diff --git a/src/task-journal.ts b/src/task-journal.ts index f56b498..44d3441 100644 --- a/src/task-journal.ts +++ b/src/task-journal.ts @@ -23,6 +23,10 @@ export interface TaskSummary { commands: Array<{ sessionId?: string; command: string; cwd?: string; exitCode?: number | null; timedOut?: boolean }>; filesChanged: string[]; eventCount: number; + failedCommandCount: number; + lastCommandStatus?: "running" | "success" | "failed"; + toolFailureCount: number; + lastToolStatus?: "success" | "failed"; automatic?: boolean; } @@ -44,13 +48,22 @@ function appendProcessEvent(task: TaskSummary, entry: TaskEvent): void { command: typeof entry.command === "string" ? entry.command : "", cwd: typeof entry.cwd === "string" ? entry.cwd : undefined, }); + task.lastCommandStatus = "running"; } else if (entry.event === "process.completed") { const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : undefined; const command = [...task.commands].reverse().find((item) => item.sessionId === sessionId); + const failed = entry.timedOut === true || Boolean(entry.error) || Boolean(entry.signal) || entry.exitCode === null || (typeof entry.exitCode === "number" && entry.exitCode !== 0); + task.lastCommandStatus = failed ? "failed" : "success"; + if (failed) task.failedCommandCount += 1; if (command) { command.exitCode = typeof entry.exitCode === "number" || entry.exitCode === null ? entry.exitCode : undefined; command.timedOut = entry.timedOut === true; } + } else if (entry.event === "tool.failed") { + task.toolFailureCount += 1; + task.lastToolStatus = "failed"; + } else if (entry.event === "tool.completed") { + task.lastToolStatus = "success"; } else if (entry.event === "file.changed" && typeof entry.path === "string") { if (!task.filesChanged.includes(entry.path)) task.filesChanged.push(entry.path); } @@ -183,6 +196,8 @@ export class TaskJournal { commands: [], filesChanged: [], eventCount: 1, + failedCommandCount: 0, + toolFailureCount: 0, }); continue; } @@ -216,6 +231,8 @@ export class TaskJournal { commands: [], filesChanged: [], eventCount: 0, + failedCommandCount: 0, + toolFailureCount: 0, automatic: true, }); eventsByTask.set(currentId, []); diff --git a/test/dashboard.integration.test.ts b/test/dashboard.integration.test.ts index 2be7fa1..00e84a8 100644 --- a/test/dashboard.integration.test.ts +++ b/test/dashboard.integration.test.ts @@ -39,6 +39,9 @@ describe("task dashboard", () => { const appJs = await appJsResponse.text(); expect(appJs).toContain("currentTab='overview'"); expect(appJs).toContain('Raw events'); + expect(appJs).toContain('완료 · 중간 오류'); + expect(appJs).toContain('최종 실패'); + expect(appJs).toContain('정상 완료'); expect(() => new Function(appJs)).not.toThrow(); const tasks = await (await fetch(`${base}/api/tasks`, { headers })).json() as { tasks: Array<{ taskId: string }> }; expect(tasks.tasks.some((item) => item.taskId === task.taskId)).toBe(true); diff --git a/test/task-journal.test.ts b/test/task-journal.test.ts index 4393583..384372e 100644 --- a/test/task-journal.test.ts +++ b/test/task-journal.test.ts @@ -22,6 +22,45 @@ describe("TaskJournal automatic sessions", () => { ]); }); + it("distinguishes recovered intermediate failures from final failures", async () => { + const recovered = new TaskJournal(); + await recovered.record("process.started", { sessionId: "bad", command: "false", cwd: "/workspace/app" }); + await recovered.record("process.completed", { sessionId: "bad", command: "false", cwd: "/workspace/app", exitCode: 1 }); + await recovered.record("process.started", { sessionId: "good", command: "npm test", cwd: "/workspace/app" }); + await recovered.record("process.completed", { sessionId: "good", command: "npm test", cwd: "/workspace/app", exitCode: 0 }); + const recoveredTask = (await recovered.listTasks())[0]!; + expect(recoveredTask).toMatchObject({ + status: "completed", + failedCommandCount: 1, + lastCommandStatus: "success", + }); + + const failed = new TaskJournal(); + await failed.record("process.started", { sessionId: "good", command: "npm test", cwd: "/workspace/app" }); + await failed.record("process.completed", { sessionId: "good", command: "npm test", cwd: "/workspace/app", exitCode: 0 }); + await failed.record("process.started", { sessionId: "bad", command: "npm run broken", cwd: "/workspace/app" }); + await failed.record("process.completed", { sessionId: "bad", command: "npm run broken", cwd: "/workspace/app", exitCode: 2 }); + const failedTask = (await failed.listTasks())[0]!; + expect(failedTask).toMatchObject({ + status: "completed", + failedCommandCount: 1, + lastCommandStatus: "failed", + }); + }); + + it("tracks tool-only recovery for explicit tasks", async () => { + const journal = new TaskJournal(); + const task = await journal.startTask("File edits", "/workspace/app"); + await journal.record("tool.failed", { taskId: task.taskId, toolName: "write_file", error: "first attempt failed" }); + await journal.record("tool.completed", { taskId: task.taskId, toolName: "write_file", durationMs: 1 }); + await journal.completeTask(task.taskId); + expect(await journal.getTask(task.taskId)).toMatchObject({ + status: "completed", + toolFailureCount: 1, + lastToolStatus: "success", + }); + }); + it("keeps explicit tasks separate from automatic sessions", async () => { const journal = new TaskJournal(); const explicit = await journal.startTask("Explicit task", "/workspace/app"); From fe4c6d4830d26cd0809a038bcf5de480eb3c803f Mon Sep 17 00:00:00 2001 From: k Date: Thu, 3 Sep 2026 01:28:48 +0000 Subject: [PATCH 29/30] Improve MCP instructions and fix dependency vulnerabilities --- package-lock.json | 44 ++++++------------------------------ src/mcp-server.ts | 32 ++++++++++++++++++++++++-- test/mcp.integration.test.ts | 5 ++++ 3 files changed, 42 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index 19d473f..392cdc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "vitest": "4.1.11" }, "engines": { - "node": ">=24.0.0" + "node": ">=22.0.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -628,9 +628,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -648,9 +645,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -668,9 +662,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -688,9 +679,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -708,9 +696,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -728,9 +713,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1869,9 +1851,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -2283,9 +2265,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2307,9 +2286,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2331,9 +2307,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2355,9 +2328,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2679,9 +2649,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 7b907b3..e0d678d 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -17,6 +17,35 @@ export interface McpServices { safetyPolicy: SafetyPolicy; } +export const MCP_SERVER_INSTRUCTIONS = `You are operating through cokacremote, an unrestricted remote development environment. Tools run directly on the host with the MCP service process's full OS permissions. + +Task completion: +- Complete the user's requested task end-to-end whenever reasonably possible. Do not stop after only explaining the next step. +- When appropriate, inspect, edit, build, test, run, and verify the affected code or service. +- Do not repeatedly ask for permission for reversible actions already implied by the request. Ask before destructive or irreversible actions, significant scope changes, or when required information cannot be discovered safely. + +Scope and editing: +- Stay within the requested scope. Do not perform unrelated refactors, dependency upgrades, optimizations, or feature additions. +- Report unrelated issues separately instead of silently fixing them. +- Prefer targeted, minimal edits over rewriting entire files. Preserve existing structure, style, and conventions unless the user requests otherwise. + +Tool strategy: +- Inspect the environment before making assumptions. +- Use exec_command for shell, build, test, package, Git, service, and log workflows; run_script for complete Bash, Node.js, or Python scripts; and file tools for direct file operations. +- Batch or parallelize independent inspections and tool calls when the client supports it. Do not repeat commands or searches without a concrete reason. +- For unfamiliar or fast-changing technologies, verify against the repository, installed versions, documentation, or available tools instead of relying only on memory. + +Verification: +- A change is not complete until it is reasonably verified. Prefer the sequence: inspect -> minimal edit -> build/typecheck/lint -> relevant tests -> run or probe the affected component -> inspect errors or logs. +- Do not claim success unless verification supports it. If verification cannot be completed, state exactly what remains unverified. +- Before starting or restarting a service, check for an existing process and port conflict when relevant. After starting a web service, verify the process, listening port, and HTTP response when possible. +- Before repository changes, inspect Git status. After changes, inspect the diff and avoid touching unrelated files. Do not commit or push unless the user explicitly asks or the current task clearly includes it. + +Communication: +- For longer tasks, provide concise progress updates while continuing the work. +- At completion, briefly report what was inspected, changed, verified, and any remaining issue. +- Use direct, precise language. Avoid mannered prose, filler, excessive metaphors, and unnecessary repetition.`; + export function createServices(config: AppConfig): McpServices { const taskJournal = new TaskJournal(config.taskJournalFile); return { @@ -48,8 +77,7 @@ export function createMcpServer(config: AppConfig, services: McpServices): McpSe ...(config.publicUrl ? { websiteUrl: config.publicUrl } : {}), }, { - instructions: - "This server is an unrestricted remote development environment. Tools operate directly on the host with the MCP service process's full OS permissions. Use exec_command for shell, build, test, package, Git, service, and log workflows; run_script for complete Bash, Node.js, or Python scripts; and the file tools for direct file operations. Poll long-running commands with read_process or write_stdin.", + instructions: MCP_SERVER_INSTRUCTIONS, capabilities: { logging: {} }, }, ); diff --git a/test/mcp.integration.test.ts b/test/mcp.integration.test.ts index d2170ed..de06813 100644 --- a/test/mcp.integration.test.ts +++ b/test/mcp.integration.test.ts @@ -13,6 +13,7 @@ import { createServices, type McpServices } from "../src/mcp-server.js"; interface JsonRpcResponse { result?: { + instructions?: string; tools?: Array<{ name: string }>; structuredContent?: Record; }; @@ -184,6 +185,10 @@ describe("remote development MCP server", () => { expect(initializeResponse.headers.get("x-request-id")).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, ); + const initialized = (await initializeResponse.json()) as JsonRpcResponse; + expect(initialized.result?.instructions).toContain("Complete the user's requested task end-to-end"); + expect(initialized.result?.instructions).toContain("Prefer targeted, minimal edits"); + expect(initialized.result?.instructions).toContain("Do not claim success unless verification supports it"); const listResponse = await post( { From 65077d225cd785059a02eece433eea9465e3c70a Mon Sep 17 00:00:00 2001 From: k Date: Thu, 3 Sep 2026 04:39:52 +0000 Subject: [PATCH 30/30] refactor: focus MCP server instructions --- src/mcp-server.ts | 42 ++++++++++++++++-------------------- test/mcp.integration.test.ts | 6 +++--- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/src/mcp-server.ts b/src/mcp-server.ts index e0d678d..4c48382 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -17,34 +17,30 @@ export interface McpServices { safetyPolicy: SafetyPolicy; } -export const MCP_SERVER_INSTRUCTIONS = `You are operating through cokacremote, an unrestricted remote development environment. Tools run directly on the host with the MCP service process's full OS permissions. +export const MCP_SERVER_INSTRUCTIONS = `You are operating through cokacremote, an unrestricted remote development environment. Tools run directly on the host with the MCP service process's OS permissions. -Task completion: -- Complete the user's requested task end-to-end whenever reasonably possible. Do not stop after only explaining the next step. -- When appropriate, inspect, edit, build, test, run, and verify the affected code or service. -- Do not repeatedly ask for permission for reversible actions already implied by the request. Ask before destructive or irreversible actions, significant scope changes, or when required information cannot be discovered safely. +Use cokacremote to inspect, edit, build, test, run, and verify software directly on the remote host. -Scope and editing: -- Stay within the requested scope. Do not perform unrelated refactors, dependency upgrades, optimizations, or feature additions. -- Report unrelated issues separately instead of silently fixing them. -- Prefer targeted, minimal edits over rewriting entire files. Preserve existing structure, style, and conventions unless the user requests otherwise. - -Tool strategy: -- Inspect the environment before making assumptions. -- Use exec_command for shell, build, test, package, Git, service, and log workflows; run_script for complete Bash, Node.js, or Python scripts; and file tools for direct file operations. -- Batch or parallelize independent inspections and tool calls when the client supports it. Do not repeat commands or searches without a concrete reason. -- For unfamiliar or fast-changing technologies, verify against the repository, installed versions, documentation, or available tools instead of relying only on memory. +Tool usage: +- Use exec_command for shell, build, test, package, Git, service, and log workflows. +- Use run_script for complete Bash, Node.js, or Python scripts. +- Use file tools for targeted file inspection and modification. +- Inspect the actual environment before making assumptions. +- Prefer targeted operations over unnecessarily broad commands or file rewrites. Verification: -- A change is not complete until it is reasonably verified. Prefer the sequence: inspect -> minimal edit -> build/typecheck/lint -> relevant tests -> run or probe the affected component -> inspect errors or logs. -- Do not claim success unless verification supports it. If verification cannot be completed, state exactly what remains unverified. -- Before starting or restarting a service, check for an existing process and port conflict when relevant. After starting a web service, verify the process, listening port, and HTTP response when possible. -- Before repository changes, inspect Git status. After changes, inspect the diff and avoid touching unrelated files. Do not commit or push unless the user explicitly asks or the current task clearly includes it. +- Verify changes with the relevant build, typecheck, tests, logs, or runtime checks. +- Do not treat command execution alone as proof that the requested result works. +- Before starting or restarting a service, check existing processes and port usage. +- After starting a web service, verify the process, listening port, and HTTP response when practical. + +Repository handling: +- Inspect Git status before modifying a repository and avoid overwriting unrelated existing changes. +- Review the resulting diff after modifications. +- Do not commit or push unless requested by the user. -Communication: -- For longer tasks, provide concise progress updates while continuing the work. -- At completion, briefly report what was inspected, changed, verified, and any remaining issue. -- Use direct, precise language. Avoid mannered prose, filler, excessive metaphors, and unnecessary repetition.`; +Long-running commands: +- Poll long-running processes using the available process tools rather than repeatedly starting the same command.`; export function createServices(config: AppConfig): McpServices { const taskJournal = new TaskJournal(config.taskJournalFile); diff --git a/test/mcp.integration.test.ts b/test/mcp.integration.test.ts index de06813..2fba279 100644 --- a/test/mcp.integration.test.ts +++ b/test/mcp.integration.test.ts @@ -186,9 +186,9 @@ describe("remote development MCP server", () => { /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, ); const initialized = (await initializeResponse.json()) as JsonRpcResponse; - expect(initialized.result?.instructions).toContain("Complete the user's requested task end-to-end"); - expect(initialized.result?.instructions).toContain("Prefer targeted, minimal edits"); - expect(initialized.result?.instructions).toContain("Do not claim success unless verification supports it"); + expect(initialized.result?.instructions).toContain("Use exec_command for shell, build, test, package, Git, service, and log workflows"); + expect(initialized.result?.instructions).toContain("Do not treat command execution alone as proof"); + expect(initialized.result?.instructions).toContain("Poll long-running processes using the available process tools"); const listResponse = await post( {