fix: address custom runtime review nits

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Eve
2026-06-22 17:45:57 +08:00
parent d107a93d0b
commit 5f8b091233
10 changed files with 118 additions and 22 deletions

View File

@@ -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.

View File

@@ -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:",

View File

@@ -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": "次へ",

View File

@@ -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": "다음",

View File

@@ -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": "可执行文件:",

View File

@@ -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", () => {

View File

@@ -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;
}

View File

@@ -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"],
}),
);
});
});

View File

@@ -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"
/>

View File

@@ -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