fix(cli): align generated consumers with private preview contract

This commit is contained in:
diegosouzapw
2026-09-21 18:55:30 -03:00
parent 4578b61b80
commit ee27d67427
8 changed files with 196 additions and 50 deletions

View File

@@ -8796,6 +8796,11 @@ paths:
tags:
- Cli tools
summary: "POST cli tools apply"
description: >-
Submit the original toolId, apiKey, optional baseUrl/model and optional
dryRun in the JSON body. Returned content is a redacted, non-cacheable
preview, not an importable configuration. A non-dry-run request writes
the original generated configuration; the container write guard remains active.
responses:
"200":
description: OK
@@ -8804,6 +8809,25 @@ paths:
tags:
- Cli tools
summary: "GET cli tools config"
description: >-
Returns redacted, non-cacheable previews. Send the configuration API key
in x-omniroute-config-api-key, separate from management authentication.
API keys in query strings are rejected. Preview content must not be
copied into a credential-bearing configuration or submitted as an apply payload.
parameters:
- in: header
name: x-omniroute-config-api-key
required: true
schema:
type: string
minLength: 1
- in: query
name: baseUrl
required: false
schema:
type: string
format: uri
description: Absolute HTTP(S) URL without embedded username/password credentials.
responses:
"200":
description: OK
@@ -8811,6 +8835,11 @@ paths:
tags:
- Cli tools
summary: "POST cli tools config"
description: >-
Submit toolId, apiKey and optional baseUrl/model as JSON. Unknown fields
are rejected. Returned content is a redacted, non-cacheable preview,
not an importable configuration. To apply, submit the original inputs
to POST /api/cli-tools/apply instead of replaying the preview content.
responses:
"200":
description: OK

View File

@@ -97,8 +97,11 @@ host answers **`422`** with `containerEphemeralTarget: true`, the safe error
text and — for the tools with a host recipe (claude, codex, opencode, cline,
kilo, continue) — a `hostSetupCommand` (e.g. `omniroute setup-opencode`) to run
on the host instead; nothing is written. `dryRun: true` keeps working in container
mode and returns the generated content + target path without touching disk, so
you can preview from the dashboard and apply on the host. This behavior is
mode and returns a redacted preview + target path without touching disk. Preview
content is not a credential-bearing configuration to copy or import. Apply with
the original tool/base URL/API key/model inputs on the host, or use the indicated
host-side setup command. See [CLI configuration security](../security/CLI-CONFIGURATION.md)
for the preview header and request contract. This behavior is
intentional and regression-guarded by
`tests/unit/api/cli-tools/apply-container-guard.test.ts` — never "fix" a 422
by removing the guard.

View File

@@ -398,31 +398,38 @@ curl https://localhost:20128/api/cli-tools/all-statuses \
POST cli tools apply
Submit the original toolId, apiKey, optional baseUrl/model and optional dryRun in the JSON body. Returned content is a redacted, non-cacheable preview, not an importable configuration. A non-dry-run request writes the original generated configuration; the container write guard remains active.
```bash
curl -X POST https://localhost:20128/api/cli-tools/apply \
-H "Authorization: Bearer $OMNIROUTE_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
-d '{"toolId":"claude","apiKey":"<configuration-api-key>","dryRun":true}'
```
### GET /api/cli-tools/config
GET cli tools config
Returns redacted, non-cacheable previews. Send the configuration API key in x-omniroute-config-api-key, separate from management authentication. API keys in query strings are rejected. Preview content must not be copied into a credential-bearing configuration or submitted as an apply payload.
```bash
curl https://localhost:20128/api/cli-tools/config \
-H "Authorization: Bearer $OMNIROUTE_TOKEN"
-H "Authorization: Bearer $OMNIROUTE_TOKEN" \
-H "x-omniroute-config-api-key: <configuration-api-key>"
```
### POST /api/cli-tools/config
POST cli tools config
Submit toolId, apiKey and optional baseUrl/model as JSON. Unknown fields are rejected. Returned content is a redacted, non-cacheable preview, not an importable configuration. To apply, submit the original inputs to POST /api/cli-tools/apply instead of replaying the preview content.
```bash
curl -X POST https://localhost:20128/api/cli-tools/config \
-H "Authorization: Bearer $OMNIROUTE_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
-d '{"toolId":"claude","apiKey":"<configuration-api-key>"}'
```
### GET /api/cli-tools/deepseek-tui-settings

View File

@@ -0,0 +1,49 @@
import { buildCliConfigurationExample } from "./cliConfigurationExample";
interface Operation {
path: string;
method: string;
}
function dashboardExample({ path, method }: Operation): string[] {
if (path === "/api/auth/login" && method === "POST") {
return [
`curl -X POST https://localhost:20128${path} \\`,
' -H "Content-Type: application/json" \\',
" -c cookie.jar \\",
' -d \'{"password":"<management-password>"}\'',
];
}
if (method === "GET") {
return [`curl https://localhost:20128${path} \\`, " -b cookie.jar"];
}
const hasJsonBody = ["POST", "PUT", "PATCH"].includes(method);
return [
"CSRF_TOKEN=$(curl -s https://localhost:20128/api/auth/csrf -b cookie.jar | jq -r .token)",
`curl -X ${method} https://localhost:20128${path} \\`,
" -b cookie.jar \\",
` -H "x-omniroute-csrf: $CSRF_TOKEN"${hasJsonBody ? " \\" : ""}`,
...(hasJsonBody ? [' -H "Content-Type: application/json" \\', " -d '{}'"] : []),
];
}
function bearerExample({ path, method }: Operation): string[] {
const curlMethod = method === "GET" ? "" : `-X ${method} `;
const hasJsonBody = ["POST", "PUT", "PATCH"].includes(method);
return [
`curl ${curlMethod}https://localhost:20128${path} \\`,
` -H "Authorization: Bearer $OMNIROUTE_TOKEN"${hasJsonBody ? " \\" : ""}`,
...(hasJsonBody ? [' -H "Content-Type: application/json" \\', " -d '{}'"] : []),
];
}
/** Preserve the authentication model while supplying a valid configuration example. */
export function buildApiOperationExample(
operation: Operation,
dashboardSession: boolean
): string[] {
return (
buildCliConfigurationExample(operation) ??
(dashboardSession ? dashboardExample(operation) : bearerExample(operation))
);
}

View File

@@ -0,0 +1,27 @@
/** Examples for the configuration API keep credentials out of URLs and apply dry-run-only. */
export function buildCliConfigurationExample(operation: {
path: string;
method: string;
}): string[] | undefined {
const { path, method } = operation;
if (path !== "/api/cli-tools/config" && path !== "/api/cli-tools/apply") return;
if (method === "GET" && path === "/api/cli-tools/config") {
return [
`curl https://localhost:20128${path} \\`,
' -H "Authorization: Bearer $OMNIROUTE_TOKEN" \\',
' -H "x-omniroute-config-api-key: <configuration-api-key>"',
];
}
if (method !== "POST") return;
const body = {
toolId: "claude",
apiKey: "<configuration-api-key>",
...(path.endsWith("/apply") ? { dryRun: true } : {}),
};
return [
`curl -X POST https://localhost:20128${path} \\`,
' -H "Authorization: Bearer $OMNIROUTE_TOKEN" \\',
' -H "Content-Type: application/json" \\',
` -d '${JSON.stringify(body)}'`,
];
}

View File

@@ -19,6 +19,7 @@ import path from "node:path";
import { getCatalog, refreshCatalog } from "./catalog";
import { parseOpenapi } from "./openapiParser";
import { parseCliRegistry } from "./cliRegistryParser";
import { buildApiOperationExample } from "./apiOperationExample";
import type { AgentSkill, GeneratorOptions, GeneratorReport } from "./types";
import type { ParsedOpenapi } from "./openapiParser";
import type { ParsedCliRegistry } from "./cliRegistryParser";
@@ -116,39 +117,7 @@ function buildApiBody(skill: AgentSkill, sources: BuildSources): string {
// Minimal curl example. Only omni-auth establishes and consumes a dashboard
// session; generic API skills use independently usable Bearer examples.
lines.push("```bash");
if (usesDashboardSession && op.path === "/api/auth/login" && op.method === "POST") {
lines.push(`curl -X POST https://localhost:20128${op.path} \\`);
lines.push(' -H "Content-Type: application/json" \\');
lines.push(" -c cookie.jar \\");
lines.push(' -d \'{"password":"<management-password>"}\'');
} else if (usesDashboardSession) {
const curlMethod = op.method === "GET" ? "" : `-X ${op.method} `;
if (op.method === "GET") {
lines.push(`curl ${curlMethod}https://localhost:20128${op.path} \\`);
lines.push(" -b cookie.jar");
} else {
lines.push(
"CSRF_TOKEN=$(curl -s https://localhost:20128/api/auth/csrf -b cookie.jar | jq -r .token)"
);
lines.push(`curl ${curlMethod}https://localhost:20128${op.path} \\`);
lines.push(" -b cookie.jar \\");
const hasJsonBody = ["POST", "PUT", "PATCH"].includes(op.method);
lines.push(` -H "x-omniroute-csrf: $CSRF_TOKEN"${hasJsonBody ? " \\" : ""}`);
if (hasJsonBody) {
lines.push(' -H "Content-Type: application/json" \\');
lines.push(" -d '{}'");
}
}
} else {
const curlMethod = op.method === "GET" ? "" : `-X ${op.method} `;
const hasJsonBody = ["POST", "PUT", "PATCH"].includes(op.method);
lines.push(`curl ${curlMethod}https://localhost:20128${op.path} \\`);
lines.push(` -H "Authorization: Bearer $OMNIROUTE_TOKEN"${hasJsonBody ? " \\" : ""}`);
if (hasJsonBody) {
lines.push(' -H "Content-Type: application/json" \\');
lines.push(" -d '{}'");
}
}
lines.push(...buildApiOperationExample(op, usesDashboardSession));
lines.push("```");
lines.push("");
}

View File

@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildSkillMarkdown } from "../../src/lib/agentSkills/generator.ts";
import { parseOpenapi } from "../../src/lib/agentSkills/openapiParser.ts";
import { buildApiOperationExample } from "../../src/lib/agentSkills/apiOperationExample.ts";
test("generated CLI skill uses header previews and original-input dry-run application", () => {
const { body } = buildSkillMarkdown("omni-cli-tools", {
openapi: parseOpenapi(),
cliRegistry: { commands: new Map(), families: new Map() },
});
const section = (method: string, path: string) =>
body.split(`### ${method} ${path}\n`)[1]?.split("\n### ")[0] || "";
const preview = section("GET", "/api/cli-tools/config");
assert.ok(preview.includes("x-omniroute-config-api-key: <configuration-api-key>"));
assert.ok(!preview.includes("?apiKey="));
assert.ok(preview.includes("must not be"));
for (const endpoint of ["config", "apply"]) {
const example = section("POST", `/api/cli-tools/${endpoint}`);
const payload = JSON.parse(example.match(/-d '(.*)'/)?.[1] || "{}");
assert.equal(payload.toolId, "claude");
assert.equal(payload.apiKey, "<configuration-api-key>");
assert.equal(payload.content, undefined);
assert.equal(payload.dryRun, endpoint === "apply" ? true : undefined);
}
});
test("configuration examples preserve unrelated bearer and dashboard-session authentication", () => {
assert.deepEqual(buildApiOperationExample({ path: "/api/models", method: "GET" }, false), [
"curl https://localhost:20128/api/models \\",
' -H "Authorization: Bearer $OMNIROUTE_TOKEN"',
]);
const login = buildApiOperationExample({ path: "/api/auth/login", method: "POST" }, true).join(
"\n"
);
assert.ok(login.includes("-c cookie.jar"));
assert.ok(!login.includes("OMNIROUTE_TOKEN"));
const mutation = buildApiOperationExample(
{ path: "/api/auth/logout", method: "POST" },
true
).join("\n");
assert.ok(mutation.includes("-b cookie.jar"));
assert.ok(mutation.includes("x-omniroute-csrf: $CSRF_TOKEN"));
const read = buildApiOperationExample({ path: "/api/auth/status", method: "GET" }, true).join(
"\n"
);
assert.ok(read.includes("-b cookie.jar"));
assert.ok(!read.includes("CSRF_TOKEN"));
});

View File

@@ -61,20 +61,27 @@ vi.mock("@/shared/components", async () => {
const React = await import("react");
return {
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Button: ({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
}) => (
Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
ModelSelectModal: () => null,
ManualConfigModal: ({ isOpen, title }: { isOpen: boolean; title?: string }) =>
isOpen ? <div data-testid="manual-config-modal">{title}</div> : null,
ManualConfigModal: ({
isOpen,
title,
configs,
}: {
isOpen: boolean;
title?: string;
configs?: Array<{ filename: string; content: string }>;
}) =>
isOpen ? (
<div data-testid="manual-config-modal">
{title}
<pre data-testid="manual-config-content">{JSON.stringify(configs)}</pre>
</div>
) : null,
};
});
@@ -112,9 +119,8 @@ afterEach(() => {
// ── Import under test (after mocks) ───────────────────────────────────────────
const { default: ClaudeToolCard } = await import(
"@/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard"
);
const { default: ClaudeToolCard } =
await import("@/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard");
async function renderExpanded() {
const container = document.createElement("div");
@@ -172,5 +178,12 @@ describe("ClaudeToolCard — manual-config CTA when CLI is not detected", () =>
});
expect(container.querySelector("[data-testid='manual-config-modal']")).not.toBeNull();
const payload = container.querySelector("[data-testid='manual-config-content']")?.textContent;
const configs = JSON.parse(payload || "[]");
expect(JSON.parse(configs[0].content).env.ANTHROPIC_BASE_URL).toBe("http://localhost:20128");
expect(payload).not.toContain("[redacted]");
// The manual-copy consumer builds its own config; it never imports an API preview.
const calls = vi.mocked(globalThis.fetch).mock.calls.map(([url]) => String(url));
expect(calls.some((url) => /\/api\/cli-tools\/(?:config|apply)(?:\?|$)/.test(url))).toBe(false);
});
});