diff --git a/docs/custom-runtimes.md b/docs/custom-runtimes.md index 4d88c05b1..126b9e2cc 100644 --- a/docs/custom-runtimes.md +++ b/docs/custom-runtimes.md @@ -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 --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. diff --git a/packages/views/locales/en/runtimes.json b/packages/views/locales/en/runtimes.json index d84391e22..e1fd1247c 100644 --- a/packages/views/locales/en/runtimes.json +++ b/packages/views/locales/en/runtimes.json @@ -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:", diff --git a/packages/views/locales/ja/runtimes.json b/packages/views/locales/ja/runtimes.json index 1498fcbea..90043fcab 100644 --- a/packages/views/locales/ja/runtimes.json +++ b/packages/views/locales/ja/runtimes.json @@ -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": "次へ", diff --git a/packages/views/locales/ko/runtimes.json b/packages/views/locales/ko/runtimes.json index 041c04bb2..ed5dbcbf9 100644 --- a/packages/views/locales/ko/runtimes.json +++ b/packages/views/locales/ko/runtimes.json @@ -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": "다음", diff --git a/packages/views/locales/zh-Hans/runtimes.json b/packages/views/locales/zh-Hans/runtimes.json index a6dd913b5..465884a06 100644 --- a/packages/views/locales/zh-Hans/runtimes.json +++ b/packages/views/locales/zh-Hans/runtimes.json @@ -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": "可执行文件:", diff --git a/packages/views/runtimes/components/runtime-profile-catalog.test.ts b/packages/views/runtimes/components/runtime-profile-catalog.test.ts index 65c955c79..454e16443 100644 --- a/packages/views/runtimes/components/runtime-profile-catalog.test.ts +++ b/packages/views/runtimes/components/runtime-profile-catalog.test.ts @@ -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", () => { diff --git a/packages/views/runtimes/components/runtime-profile-catalog.ts b/packages/views/runtimes/components/runtime-profile-catalog.ts index 4bdd6a332..6076bf904 100644 --- a/packages/views/runtimes/components/runtime-profile-catalog.ts +++ b/packages/views/runtimes/components/runtime-profile-catalog.ts @@ -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; } diff --git a/packages/views/runtimes/components/runtime-profiles-dialog.test.tsx b/packages/views/runtimes/components/runtime-profiles-dialog.test.tsx index a783e2877..743900f7a 100644 --- a/packages/views/runtimes/components/runtime-profiles-dialog.test.tsx +++ b/packages/views/runtimes/components/runtime-profiles-dialog.test.tsx @@ -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"], + }), + ); + }); }); diff --git a/packages/views/runtimes/components/runtime-profiles-dialog.tsx b/packages/views/runtimes/components/runtime-profiles-dialog.tsx index 589ff7773..630641844 100644 --- a/packages/views/runtimes/components/runtime-profiles-dialog.tsx +++ b/packages/views/runtimes/components/runtime-profiles-dialog.tsx @@ -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" /> diff --git a/server/internal/handler/runtime_profile.go b/server/internal/handler/runtime_profile.go index 2bfe247ea..4335e6efd 100644 --- a/server/internal/handler/runtime_profile.go +++ b/server/internal/handler/runtime_profile.go @@ -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