Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ The server removes secret values from every tool result and from its own log. Th

- **Secret fields.** The built-in field list above is redacted at any depth. A string becomes `[REDACTED]`; booleans, numbers, `null`, empty strings and values the panel has already hidden are kept, so flags such as `secret: true` stay readable. Variable lists (`env`, `buildArgs`, `buildSecrets`, docker `Env`) keep the variable names: `NAME=[REDACTED]`.
- **Free text.** Every other string is scrubbed: logs, compose files, Traefik and container files (base64 file content is decoded, scrubbed and encoded again; binary content is replaced), error text. Values of secret-looking names in `NAME=value`, `key: value` and JSON, URL passwords, `Authorization` and `Bearer` tokens, `--password`-style flags, well-known token formats and PEM private keys become `[REDACTED]`. Variable references such as `${VAR}` and `${{project.VAR}}` are kept; an inline default (`${VAR:-default}`) is redacted.
- **Blind env tools.** `env-keys` lists variable names with `secret` and `empty` flags. `env-apply` changes single variables and never returns a value: `set` a value that is already known, `generate` a random secret on the server, `copy` a value from another service, `remove` a variable. A secret that only the user knows is entered in the Dokploy panel, not in the chat. Changes apply after a redeploy. Both tools, like `superPassword-status`, are loaded with every preset and tag filter. They need a Dokploy version that provides `GET /env.keys` and `POST /env.apply`.
- **Blind env tools.** `env-keys` lists variable names with `secret` and `empty` flags. `env-apply` changes single variables and never returns a value: `set` a value that is already known, `generate` a random secret on the server, `copy` a value from another service, `remove` a variable. A secret that only the user knows is entered in the Dokploy panel, not in the chat. Changes apply after a redeploy. Both tools are loaded with every preset and tag filter. They need a Dokploy version that provides `GET /env.keys` and `POST /env.apply`.
- **Comments and sections.** `env-keys` with `layout=true` also returns the text from top to bottom: variables by name, comments with their text, blank lines, and hidden lines whose text the panel withholds because it could hold a value (a commented-out assignment, an earlier assignment that a later one overrides, text dotenv cannot read). `env-apply` with `layout` takes the edited list back and rewrites only the order, comments and blank lines: section headers and notes are added as comment items, every variable keeps its stored text byte for byte, and the panel refuses the write if any value would read differently. A layout is sent alone with the revision it was read with, every variable and hidden line has to stay in it, and this server checks it against the current layout first, so that problems come back by name. Comment text that is shown also goes through the scrubber. This needs a Dokploy version that provides `GET /env.layout`.
- **No full-replace writes.** Tools that need a whole env text (`*-saveEnvironment`, `settings-writeTraefikEnv`) are not listed. Every other tool rejects the fields `env`, `buildArgs`, `buildSecrets`, `previewEnv`, `previewBuildArgs` and `previewBuildSecrets`, because writing them would replace variables the model cannot see.
- **No write-back of redacted text.** An input that contains `[REDACTED]` or the bare `__DOKPLOY_REDACTED_SECRET__` is rejected.
Expand Down
11 changes: 2 additions & 9 deletions TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

> Auto-generated from the [Dokploy OpenAPI spec](https://docs.dokploy.com/openapi.json). Run `pnpm generate` to update.

- **Total Tools**: 602
- **Categories**: 59
- **Total Tools**: 601
- **Categories**: 58

Tools that need a whole env text (`*-saveEnvironment`, `settings-writeTraefikEnv`) are not exposed. Variables are managed with `env-keys` and `env-apply`, which never return values.

Expand Down Expand Up @@ -61,7 +61,6 @@ Tools that need a whole env text (`*-saveEnvironment`, `settings-writeTraefikEnv
- [sshKey](#sshKey) (7 tools)
- [sso](#sso) (11 tools)
- [stripe](#stripe) (10 tools)
- [superPassword](#superPassword) (1 tools)
- [swarm](#swarm) (4 tools)
- [tag](#tag) (8 tools)
- [user](#user) (26 tools)
Expand Down Expand Up @@ -875,12 +874,6 @@ Tools that need a whole env text (`*-saveEnvironment`, `settings-writeTraefikEnv
| `stripe-updateInvoiceNotifications` | POST | `enabled` (boolean) |
| `stripe-getInvoices` | GET | None |

## superPassword

| Tool | Method | Parameters |
|------|--------|------------|
| `superPassword-status` | GET | None |

## swarm

| Tool | Method | Parameters |
Expand Down
3 changes: 1 addition & 2 deletions scripts/generate-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { resolve } from "node:path";
import { type JsonSchema, jsonSchemaToZod } from "json-schema-to-zod";
import { z } from "zod";
import { envApplyTool, envKeysTool, FULL_ENV_FIELDS } from "../src/envTools.js";
import { superPasswordStatusTool } from "../src/superPassword.js";
import type { ToolDefinition } from "../src/types.js";

interface OpenAPISpec {
Expand Down Expand Up @@ -551,7 +550,7 @@ ${tools.join(",\n")},
writeFileSync(OUTPUT_PATH, output);
console.log(`Generated ${tools.length} tools (${errorCount} errors) -> ${OUTPUT_PATH}`);

for (const tool of [superPasswordStatusTool, envKeysTool, envApplyTool]) {
for (const tool of [envKeysTool, envApplyTool]) {
mdEntries.push(handWrittenToolEntry(tool));
}

Expand Down
13 changes: 0 additions & 13 deletions src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,6 @@ import {
isBlindEnvTool,
parseEnvToolOutput,
} from "./envTools.js";
import {
getSuperPasswordDenial,
summarizeSuperPasswordStatus,
superPasswordDenialResponse,
} from "./superPassword.js";
import type { ToolDefinition } from "./types.js";
import apiClient from "./utils/apiClient.js";
import { getClientConfig } from "./utils/clientConfig.js";
Expand Down Expand Up @@ -307,10 +302,6 @@ async function executeTool(tool: ToolDefinition, input: Record<string, unknown>)
return executeExactDeploy(tool, input);
case "deployment-reconcile":
return executeDeploymentReconcile(tool, input);
case "super-password-status": {
const response = await apiClient.get(tool.path);
return summarizeSuperPasswordStatus(response.data);
}
case "env-keys": {
const { layout, ...params } = input;
const response = await apiClient.get(layout === true ? ENV_LAYOUT_PATH : tool.path, {
Expand Down Expand Up @@ -399,10 +390,6 @@ export function createHandler(tool: ToolDefinition) {
...(status === undefined ? {} : { status }),
});

const superPasswordDenial = getSuperPasswordDenial(error);
if (superPasswordDenial) {
return superPasswordDenialResponse(tool.name, superPasswordDenial);
}
if (status === 401) {
return ResponseFormatter.error(
`Authentication failed for ${tool.name}`,
Expand Down
167 changes: 6 additions & 161 deletions src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ const { generatedTools } = await import("./generated/tools.js");
const { createServer } = await import("./server.js");
const { envApplyTool } = await import("./envTools.js");

// superPassword-status, env-keys and env-apply are loaded on top of every
// preset and tag filter.
const ALWAYS_LOADED_TOOLS = 3;
// env-keys and env-apply are loaded on top of every preset and tag filter.
const ALWAYS_LOADED_TOOLS = 2;

// Tools that need a whole env text are never listed.
const FULL_ENV_REPLACE_TOOLS = [
Expand Down Expand Up @@ -145,7 +144,7 @@ describe("MCP server tools/list", () => {
const tags = new Set(tools.map((tool) => tool.name.split("-")[0]));

expect(tools).toHaveLength(countByTags(["project", "application"]));
expect(tags).toEqual(new Set(["application", "project", "superPassword", "env"]));
expect(tags).toEqual(new Set(["application", "project", "env"]));
});

it("supports DOKPLOY_TOOL_PRESET=core for common application workflows", async () => {
Expand All @@ -155,7 +154,7 @@ describe("MCP server tools/list", () => {
const tags = new Set(tools.map((tool) => tool.name.split("-")[0]));

expect(tools).toHaveLength(countByTags(["project", "server", "application"]));
expect(tags).toEqual(new Set(["application", "project", "server", "superPassword", "env"]));
expect(tags).toEqual(new Set(["application", "project", "server", "env"]));
});

it("lets DOKPLOY_ENABLED_TAGS override presets", async () => {
Expand All @@ -166,7 +165,7 @@ describe("MCP server tools/list", () => {
const tags = new Set(tools.map((tool) => tool.name.split("-")[0]));

expect(tools).toHaveLength(countByTags(["project", "application"]));
expect(tags).toEqual(new Set(["application", "project", "superPassword", "env"]));
expect(tags).toEqual(new Set(["application", "project", "env"]));
});

it("excludes DOKPLOY_DISABLED_TAGS after selecting tools", async () => {
Expand Down Expand Up @@ -876,55 +875,7 @@ describe("MCP server tools/list", () => {
expect(text).not.toContain("remote-sensitive-conflict");
});

it("lists superPassword-status on top of any preset, and no other superPassword tool", async () => {
process.env.DOKPLOY_TOOL_PRESET = "minimal";

const tools = await getToolList();
const superPasswordTools = tools.filter(({ name }) => name.startsWith("superPassword"));

expect(superPasswordTools.map(({ name }) => name)).toEqual(["superPassword-status"]);
});

it("explains each super password denial with a fixed message instead of the server body", async () => {
const cases = [
{
serverMessage: "API key write access is locked. Open it in Profile → Super password.",
expected: "writes through the API key are locked",
},
{
serverMessage:
"This action needs the super password. Open access in Profile → Super password.",
expected: "needs open super password access",
},
{
serverMessage:
"This action needs a browser session with super password access open. API keys cannot perform it.",
expected: "can never perform it, even while access is open",
},
];

for (const { serverMessage, expected } of cases) {
mock.clearAllMocks();
mocked(apiClient.post).mockRejectedValueOnce({
isAxiosError: true,
response: { status: 403, data: { message: serverMessage, code: "FORBIDDEN" } },
});
const client = await createConnectedClient();
const result = await client.callTool({
name: "compose-fetchSourceType",
arguments: { composeId: "compose_1" },
});
await client.close();

const text = responseText(result);
expect(result.isError).toBe(true);
expect(text).toContain("blocked by the Dokploy super password");
expect(text).toContain(expected);
expect(text).not.toContain(serverMessage);
}
});

it("still hides the body of any other 403", async () => {
it("hides the body of a 403", async () => {
mocked(apiClient.post).mockRejectedValueOnce({
isAxiosError: true,
response: { status: 403, data: { message: "remote-sensitive-forbidden" } },
Expand All @@ -939,55 +890,6 @@ describe("MCP server tools/list", () => {
const text = responseText(result);
expect(text).toContain("The Dokploy API request failed with status 403");
expect(text).not.toContain("remote-sensitive-forbidden");
expect(text).not.toContain("super password");
});

it("returns the super password status without the hint or recovery channels", async () => {
const expiresAt = new Date(Date.now() + (17 * 60 + 42) * 60_000 + 30_000).toISOString();
mocked(apiClient.get).mockResolvedValueOnce({
data: {
isSet: true,
hint: "remote-hint-text",
active: true,
expiresAt,
lockedUntil: null,
durationMs: 86_400_000,
canManage: true,
viaApiKey: true,
recoveryChannels: [{ notificationId: "n1", name: "remote-channel-name", type: "telegram" }],
},
});
const client = await createConnectedClient();
const result = await client.callTool({ name: "superPassword-status", arguments: {} });
await client.close();

expect(apiClient.get).toHaveBeenCalledWith("/superPassword.status");
const text = responseText(result);
const parsed = JSON.parse(text);
expect(parsed.data).toMatchObject({
isSet: true,
active: true,
expiresAt,
timeLeft: "17 h 42 min",
apiKeyWrites: "allowed while access is open",
});
expect(text).not.toContain("remote-hint-text");
expect(text).not.toContain("remote-channel-name");
expect(text).not.toContain("recoveryChannels");
});

it("reports locked API-key writes while super password access is closed", async () => {
mocked(apiClient.get).mockResolvedValueOnce({
data: { isSet: true, active: false, expiresAt: null, lockedUntil: null },
});
const client = await createConnectedClient();
const result = await client.callTool({ name: "superPassword-status", arguments: {} });
await client.close();

const parsed = JSON.parse(responseText(result));
expect(parsed.data.active).toBe(false);
expect(parsed.data.timeLeft).toBeNull();
expect(parsed.data.apiKeyWrites).toStartWith("locked:");
});

it("retries exact deploy at most three times with one byte-identical body and key", async () => {
Expand Down Expand Up @@ -2385,63 +2287,6 @@ describe("MCP server tools/list", () => {
expect(logs).not.toContain(SECRET);
}
});

it("says precisely which actions the super password keeps away from API keys", async () => {
const tools = await getToolList();
const description =
tools.find(({ name }) => name === "superPassword-status")?.description ?? "";

expect(description).toContain(
"Never available through an API key, only in the panel in a browser:",
);
expect(description).toContain(
"deleting a whole service, database, project, environment, server",
);
expect(description).toContain(
"Allowed through the API key while access is open: every other write",
);
expect(description).toContain("deleting a schedule, domain, mount, port, redirect");
expect(description).toContain("removing single env variables with env-apply");
expect(description).not.toContain("Dangerous actions");
expect(description).not.toMatch(/\(revealing env, deletions/);

mocked(apiClient.get).mockResolvedValueOnce({
data: { isSet: true, active: true, expiresAt: null, lockedUntil: null },
});
const data = resultData(await callTool("superPassword-status", {}));

expect(data).not.toHaveProperty("dangerousActions");
expect(data.browserOnlyActions).toStartWith(
"never through an API key, only in the panel in a browser with access open: revealing secret values (env, API keys, container or volume file contents, docker inspect), terminals, deleting a whole service,",
);
expect(data.browserOnlyActions).toEndWith("and managing users, API keys and SSO");
expect(data.apiKeyAllowedWrites).toStartWith(
"through the API key while access is open: every other write, including deleting a schedule, domain, mount, port, redirect, basic-auth entry, backup configuration, volume backup, deployment record, tag, container or image,",
);
expect(data.apiKeyAllowedWrites).toEndWith("and removing single env variables with env-apply");
});

it("limits the browser-only denial to the action that was refused", async () => {
mocked(apiClient.post).mockRejectedValueOnce({
isAxiosError: true,
response: {
status: 403,
data: {
message:
"This action needs a browser session with super password access open. API keys cannot perform it.",
},
},
});

const text = resultText(
await callTool("compose-delete", { composeId: "compose_1", deleteVolumes: false }),
);

expect(text).toContain("can never perform it, even while access is open");
expect(text).toContain(
"This applies to this action only: other writes, including most deletions, work through the API key while access is open.",
);
});
});

function FORBIDDEN_PLACEHOLDER_FIXTURES() {
Expand Down
15 changes: 4 additions & 11 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { type ZodObject, type ZodType, z } from "zod";
import { envApplyTool, envKeysTool, requiresFullEnvText } from "./envTools.js";
import { generatedTools } from "./generated/tools.js";
import { createHandler } from "./handler.js";
import { superPasswordStatusTool } from "./superPassword.js";
import type { ToolDefinition } from "./types.js";
import { createLogger } from "./utils/logger.js";

Expand Down Expand Up @@ -94,16 +93,10 @@ function getEnabledTools() {
return filtered;
}

// The status tool is always loaded so the model can explain a 403 from a
// closed super password. Every other superPassword procedure (set, unlock,
// reset) stays out even if a spec sync generates it: the super password must
// never pass through the model.
//
// The blind env tools are always loaded too: they are the only way left to
// manage variables, because tools that need a whole env text are dropped. A
// generated tool with the "env" tag would bypass their checks, so it stays out
// the same way.
const ALWAYS_LOADED_TOOLS = [superPasswordStatusTool, envKeysTool, envApplyTool];
// The blind env tools are always loaded: they are the only way left to manage
// variables, because tools that need a whole env text are dropped. A generated
// tool with the "env" tag would bypass their checks, so it stays out.
const ALWAYS_LOADED_TOOLS = [envKeysTool, envApplyTool];
const HAND_WRITTEN_TAGS = new Set(ALWAYS_LOADED_TOOLS.map((tool) => tool.tag.toLowerCase()));

function withAlwaysLoadedTools(tools: ToolDefinition[]): ToolDefinition[] {
Expand Down
Loading
Loading