mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
fix: address custom runtime review nits
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -26,6 +26,12 @@ Supported input:
|
||||
- single or double quotes for values with spaces
|
||||
- backslash escaping for literal spaces or quote characters
|
||||
|
||||
The UI parser is argv-oriented, not a full POSIX shell. Inside double quotes,
|
||||
`\` escapes the next character directly; use single quotes when you need `$` or
|
||||
backticks to stay literal. Running tasks keep the launch args they started with;
|
||||
profile command or argument edits apply to newly claimed tasks after the daemon
|
||||
re-registers.
|
||||
|
||||
Unsupported input:
|
||||
|
||||
- pipes, redirects, `;`, `&&`, `||`
|
||||
@@ -45,3 +51,11 @@ multica runtime profile set-path <profile-id> --path /abs/path/to/agent
|
||||
```
|
||||
|
||||
Then restart or refresh the daemon so it re-registers the profile.
|
||||
|
||||
## Upgrade order
|
||||
|
||||
Custom runtime arguments and registration-error reporting require both the
|
||||
server and daemon versions that support `fixed_args` launch specs and
|
||||
`failed_profiles` registration reports. In mixed deployments, upgrade the server
|
||||
before rolling out newer daemons so failed custom-only profiles can be recorded
|
||||
instead of being rejected as an empty runtime registration.
|
||||
|
||||
@@ -324,6 +324,7 @@
|
||||
"error_display_name_required": "Display name is required.",
|
||||
"error_command_required": "Command is required.",
|
||||
"error_command_unclosed_quote": "Close the quote before saving.",
|
||||
"error_command_trailing_escape": "Complete or remove the trailing backslash before saving.",
|
||||
"error_command_shell_syntax": "Shell pipelines, redirects, and control operators are not supported. Use a wrapper script for that.",
|
||||
"error_command_shell_expansion": "Shell variable and command expansion is not supported. Use an absolute path or wrapper script.",
|
||||
"command_preview_executable": "Executable:",
|
||||
|
||||
@@ -312,10 +312,11 @@
|
||||
"error_display_name_required": "表示名は必須です。",
|
||||
"error_command_required": "コマンドは必須です。",
|
||||
"error_command_unclosed_quote": "保存する前に引用符を閉じてください。",
|
||||
"error_command_shell_syntax": "Shell pipelines, redirects, and control operators are not supported. Use a wrapper script for that.",
|
||||
"error_command_shell_expansion": "Shell variable and command expansion is not supported. Use an absolute path or wrapper script.",
|
||||
"command_preview_executable": "Executable:",
|
||||
"command_preview_args": "Args:",
|
||||
"error_command_trailing_escape": "保存する前に末尾のバックスラッシュを補完するか削除してください。",
|
||||
"error_command_shell_syntax": "シェルのパイプ、リダイレクト、制御演算子はサポートされていません。必要な場合はラッパースクリプトを使用してください。",
|
||||
"error_command_shell_expansion": "シェル変数やコマンド展開はサポートされていません。絶対パスまたはラッパースクリプトを使用してください。",
|
||||
"command_preview_executable": "実行ファイル:",
|
||||
"command_preview_args": "引数:",
|
||||
"back": "戻る",
|
||||
"cancel": "キャンセル",
|
||||
"next": "次へ",
|
||||
|
||||
@@ -324,10 +324,11 @@
|
||||
"error_display_name_required": "표시 이름은 필수입니다.",
|
||||
"error_command_required": "명령은 필수입니다.",
|
||||
"error_command_unclosed_quote": "저장하기 전에 따옴표를 닫으세요.",
|
||||
"error_command_shell_syntax": "Shell pipelines, redirects, and control operators are not supported. Use a wrapper script for that.",
|
||||
"error_command_shell_expansion": "Shell variable and command expansion is not supported. Use an absolute path or wrapper script.",
|
||||
"command_preview_executable": "Executable:",
|
||||
"command_preview_args": "Args:",
|
||||
"error_command_trailing_escape": "저장하기 전에 끝의 백슬래시를 완성하거나 제거하세요.",
|
||||
"error_command_shell_syntax": "셸 파이프, 리디렉션, 제어 연산자는 지원되지 않습니다. 이런 동작이 필요하면 래퍼 스크립트를 사용하세요.",
|
||||
"error_command_shell_expansion": "셸 변수 및 명령 치환은 지원되지 않습니다. 절대 경로나 래퍼 스크립트를 사용하세요.",
|
||||
"command_preview_executable": "실행 파일:",
|
||||
"command_preview_args": "인수:",
|
||||
"back": "뒤로",
|
||||
"cancel": "취소",
|
||||
"next": "다음",
|
||||
|
||||
@@ -312,6 +312,7 @@
|
||||
"error_display_name_required": "显示名称为必填项。",
|
||||
"error_command_required": "命令为必填项。",
|
||||
"error_command_unclosed_quote": "保存前请先闭合引号。",
|
||||
"error_command_trailing_escape": "保存前请补全或移除末尾的反斜杠。",
|
||||
"error_command_shell_syntax": "不支持 shell 管道、重定向和控制操作符。需要这些能力时请使用 wrapper 脚本。",
|
||||
"error_command_shell_expansion": "不支持 shell 变量或命令展开。请使用绝对路径或 wrapper 脚本。",
|
||||
"command_preview_executable": "可执行文件:",
|
||||
|
||||
@@ -105,12 +105,27 @@ describe("parseCommandLine", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows literal shell-looking characters inside single quotes", () => {
|
||||
expect(parseCommandLine("agent --note '$5 reward `literal`'")).toEqual({
|
||||
ok: true,
|
||||
commandName: "agent",
|
||||
fixedArgs: ["--note", "$5 reward `literal`"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unclosed quotes", () => {
|
||||
expect(parseCommandLine(`agent --flag "unterminated`)).toEqual({
|
||||
ok: false,
|
||||
error: "unclosed_quote",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a trailing escape", () => {
|
||||
expect(parseCommandLine("agent \\")).toEqual({
|
||||
ok: false,
|
||||
error: "trailing_escape",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCommandLine", () => {
|
||||
|
||||
@@ -69,11 +69,12 @@ export interface ProfileFormValues {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type ProfileFormErrorField = "displayName" | "commandName";
|
||||
export type ProfileFormErrorField = "displayName" | "commandLine";
|
||||
|
||||
export type CommandLineParseError =
|
||||
| "empty"
|
||||
| "unclosed_quote"
|
||||
| "trailing_escape"
|
||||
| "shell_syntax"
|
||||
| "shell_expansion";
|
||||
|
||||
@@ -121,6 +122,9 @@ export function parseCommandLine(input: string): ParsedCommandLine {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
return { ok: false, error: "trailing_escape" };
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
quote = ch;
|
||||
tokenStarted = true;
|
||||
@@ -142,7 +146,10 @@ export function parseCommandLine(input: string): ParsedCommandLine {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "`" || ch === "$") {
|
||||
if (quote === '"' && ch === "\\") {
|
||||
return { ok: false, error: "trailing_escape" };
|
||||
}
|
||||
if (quote !== "'" && (ch === "`" || ch === "$")) {
|
||||
return {
|
||||
ok: false,
|
||||
error: ch === "$" ? "shell_expansion" : "shell_syntax",
|
||||
@@ -179,7 +186,7 @@ export function validateProfileForm(
|
||||
): ProfileFormErrorField[] {
|
||||
const errors: ProfileFormErrorField[] = [];
|
||||
if (!values.displayName.trim()) errors.push("displayName");
|
||||
if (!values.commandLine.trim()) errors.push("commandName");
|
||||
if (!values.commandLine.trim()) errors.push("commandLine");
|
||||
return errors;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import type { RuntimeProfile } from "@multica/core/types";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
@@ -11,6 +11,10 @@ const queryState = vi.hoisted(() => ({
|
||||
profiles: [] as RuntimeProfile[],
|
||||
isLoading: false,
|
||||
}));
|
||||
const mutationState = vi.hoisted(() => ({
|
||||
createProfile: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual =
|
||||
@@ -30,6 +34,20 @@ vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/runtimes", () => ({
|
||||
runtimeProfileListOptions: vi.fn((wsId: string) => ({
|
||||
queryKey: ["runtime-profiles", wsId, "list"],
|
||||
})),
|
||||
useCreateRuntimeProfile: vi.fn(() => ({
|
||||
isPending: false,
|
||||
mutateAsync: mutationState.createProfile,
|
||||
})),
|
||||
useUpdateRuntimeProfile: vi.fn(() => ({
|
||||
isPending: false,
|
||||
mutateAsync: mutationState.updateProfile,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./delete-runtime-profile-dialog", () => ({
|
||||
DeleteRuntimeProfileDialog: () => null,
|
||||
}));
|
||||
@@ -73,6 +91,13 @@ describe("RuntimeProfilesDialog", () => {
|
||||
queryState.profiles = [];
|
||||
queryState.isLoading = false;
|
||||
vi.clearAllMocks();
|
||||
mutationState.createProfile.mockResolvedValue(
|
||||
profile({
|
||||
command_name: "agent",
|
||||
fixed_args: ["--model", "composer-2.5"],
|
||||
}),
|
||||
);
|
||||
mutationState.updateProfile.mockResolvedValue(profile());
|
||||
});
|
||||
|
||||
it("shows the custom empty state and keeps built-in protocols collapsed", () => {
|
||||
@@ -142,4 +167,37 @@ describe("RuntimeProfilesDialog", () => {
|
||||
screen.queryByText(/claude is a built-in protocol family/),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("parses a pasted command line into fixed_args on create", async () => {
|
||||
renderDialog();
|
||||
|
||||
const newRuntimeButtons = screen.getAllByRole("button", {
|
||||
name: "New custom runtime",
|
||||
});
|
||||
expect(newRuntimeButtons[0]).toBeDefined();
|
||||
fireEvent.click(newRuntimeButtons[0]!);
|
||||
fireEvent.click(screen.getByRole("radio", { name: /codex/i }));
|
||||
fireEvent.change(screen.getByLabelText("Display name"), {
|
||||
target: { value: "Composer Agent" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Command"), {
|
||||
target: { value: "agent --model composer-2.5" },
|
||||
});
|
||||
|
||||
expect(screen.getByText("Executable:")).toBeInTheDocument();
|
||||
expect(screen.getByText("agent")).toBeInTheDocument();
|
||||
expect(screen.getByText("--model")).toBeInTheDocument();
|
||||
expect(screen.getByText("composer-2.5")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create runtime" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mutationState.createProfile).toHaveBeenCalledWith({
|
||||
display_name: "Composer Agent",
|
||||
protocol_family: "codex",
|
||||
command_name: "agent",
|
||||
fixed_args: ["--model", "composer-2.5"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -705,8 +705,8 @@ function ProfileDetailsForm({
|
||||
setFormError(null);
|
||||
setDuplicateName(false);
|
||||
const validationErrors = validateProfileForm(values);
|
||||
if (!validationErrors.includes("commandName") && !parsedCommand.ok) {
|
||||
validationErrors.push("commandName");
|
||||
if (!validationErrors.includes("commandLine") && !parsedCommand.ok) {
|
||||
validationErrors.push("commandLine");
|
||||
}
|
||||
setErrors(validationErrors);
|
||||
if (validationErrors.length > 0) return;
|
||||
@@ -759,15 +759,17 @@ function ProfileDetailsForm({
|
||||
const parseErrorMessage =
|
||||
!parsedCommand.ok && parsedCommand.error === "unclosed_quote"
|
||||
? t(($) => $.profiles.form.error_command_unclosed_quote)
|
||||
: !parsedCommand.ok && parsedCommand.error === "trailing_escape"
|
||||
? t(($) => $.profiles.form.error_command_trailing_escape)
|
||||
: !parsedCommand.ok && parsedCommand.error === "shell_expansion"
|
||||
? t(($) => $.profiles.form.error_command_shell_expansion)
|
||||
: !parsedCommand.ok && parsedCommand.error === "shell_syntax"
|
||||
? t(($) => $.profiles.form.error_command_shell_syntax)
|
||||
: null;
|
||||
const commandError =
|
||||
hasError("commandName") && !values.commandLine.trim()
|
||||
hasError("commandLine") && !values.commandLine.trim()
|
||||
? t(($) => $.profiles.form.error_command_required)
|
||||
: hasError("commandName") && !parsedCommand.ok
|
||||
: hasError("commandLine") && !parsedCommand.ok
|
||||
? (parseErrorMessage ?? t(($) => $.profiles.form.error_command_required))
|
||||
: null;
|
||||
|
||||
@@ -847,9 +849,9 @@ function ProfileDetailsForm({
|
||||
value={values.commandLine}
|
||||
onChange={(e) => setField("commandLine", e.target.value)}
|
||||
placeholder={t(($) => $.profiles.form.command_name_placeholder)}
|
||||
aria-invalid={hasError("commandName")}
|
||||
aria-invalid={hasError("commandLine")}
|
||||
aria-describedby={
|
||||
hasError("commandName") ? `${idPrefix}-command-error` : undefined
|
||||
hasError("commandLine") ? `${idPrefix}-command-error` : undefined
|
||||
}
|
||||
className="h-9 font-mono text-sm"
|
||||
/>
|
||||
|
||||
@@ -294,10 +294,6 @@ func (h *Handler) UpdateRuntimeProfile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if req.CommandName != nil {
|
||||
cmd := strings.TrimSpace(*req.CommandName)
|
||||
if cmd == "" {
|
||||
writeError(w, http.StatusBadRequest, "command_name cannot be empty")
|
||||
return
|
||||
}
|
||||
if err := validateRuntimeProfileCommandName(cmd); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user