diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index ed8201beb..778cc2359 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -1550,14 +1550,19 @@ func TestInjectRuntimeConfigCommentGuardrailIsProviderAgnostic(t *testing.T) { } } -// TestInjectRuntimeConfigLinuxCommentFormattingEmphasizesStdin pins that the -// "## Comment Formatting" section emits the quoted-HEREDOC stdin mandate on -// non-Windows hosts for EVERY provider, not just Codex. Post-MUL-2904 the -// guardrail is provider-agnostic because the corruption is shell-driven; the -// quoted delimiter is what blocks backtick / `$()` substitution in the body. +// TestInjectRuntimeConfigLinuxCommentFormattingEmphasizesFile pins that the +// "## Comment Formatting" section emits the file-first mandate on non-Windows +// hosts for EVERY provider (post-#4182). The previous quoted-HEREDOC +// `--content-stdin` rule was kept for years to defend against backtick / `$()` +// substitution in the body (MUL-2904), but the heredoc/flag boundary turned out +// to be its own structural bug: when a model wrapped extra flags around the +// heredoc on `multica issue create`, the flags were silently swallowed into +// stdin (OXY-78, OXY-76). The file path defeats both classes — the body never +// reaches the shell, and all flags live on one shell-token line — and converges +// the Linux/macOS template with the long-standing Windows file-only path. // // Not parallel: mutates the package-level runtimeGOOS. -func TestInjectRuntimeConfigLinuxCommentFormattingEmphasizesStdin(t *testing.T) { +func TestInjectRuntimeConfigLinuxCommentFormattingEmphasizesFile(t *testing.T) { saved := runtimeGOOS t.Cleanup(func() { runtimeGOOS = saved }) runtimeGOOS = "linux" @@ -1583,20 +1588,27 @@ func TestInjectRuntimeConfigLinuxCommentFormattingEmphasizesStdin(t *testing.T) for _, want := range []string{ "## Comment Formatting", - "always use `--content-stdin` with a HEREDOC", - "even for short single-line replies", - "<<'COMMENT'", + "always write the comment body to a UTF-8 file with your file-write tool first, then post it with `--content-file `", + "#4182", "Never use inline `--content` for agent-authored comments", "Keep the same `--parent` value", + "rm ./reply.md", "do not rely on `\\n` escapes", } { if !strings.Contains(s, want) { t.Errorf("%s missing comment-formatting guidance %q\n---\n%s", fileName, want, s) } } - // The heading is no longer Codex-scoped. - if strings.Contains(s, "Codex-Specific Comment Formatting") { - t.Errorf("%s still carries the old Codex-scoped heading\n---\n%s", fileName, s) + + // The previous mandate (#1795 / #1851 / MUL-2904) must NOT remain. + for _, banned := range []string{ + "always use `--content-stdin` with a HEREDOC, even for short single-line replies", + "<<'COMMENT'", + "Codex-Specific Comment Formatting", + } { + if strings.Contains(s, banned) { + t.Errorf("%s still carries pre-#4182 stdin mandate %q\n---\n%s", fileName, banned, s) + } } }) } @@ -4072,7 +4084,7 @@ func TestInjectRuntimeConfigIssueMetadataSectionScope(t *testing.T) { // TestInjectRuntimeConfigIssueMetadataCodexFormattingUnchanged guarantees // that the new metadata wiring does not break the codex-specific comment -// formatting rules (HEREDOC on Linux, --content-file on Windows). The +// formatting rules (--content-file on every host, post-#4182). The // comment-formatting block lives below the metadata write step in the // workflow, so any reordering or accidental absorption of the codex // section would surface here. @@ -4082,7 +4094,7 @@ func TestInjectRuntimeConfigIssueMetadataCodexFormattingUnchanged(t *testing.T) oldGOOS := runtimeGOOS t.Cleanup(func() { runtimeGOOS = oldGOOS }) - t.Run("linux_heredoc", func(t *testing.T) { + t.Run("linux_content_file", func(t *testing.T) { runtimeGOOS = "linux" dir := t.TempDir() ctx := TaskContextForEnv{ @@ -4105,9 +4117,9 @@ func TestInjectRuntimeConfigIssueMetadataCodexFormattingUnchanged(t *testing.T) if !strings.Contains(s, "multica issue metadata list issue-md-codex --output json") { t.Fatalf("metadata list step missing\n---\n%s", s) } - // ...AND the codex-specific stdin-only rule is still emitted. - if !strings.Contains(s, "always use `--content-stdin` with a HEREDOC") { - t.Fatalf("codex linux HEREDOC rule missing\n---\n%s", s) + // ...AND the post-#4182 file-first rule is still emitted on Linux. + if !strings.Contains(s, "always write the comment body to a UTF-8 file with your file-write tool first, then post it with `--content-file `") { + t.Fatalf("codex linux --content-file rule missing\n---\n%s", s) } // ...AND the per-turn reply instruction still points at this // turn's trigger comment id. diff --git a/server/internal/daemon/execenv/reply_instructions.go b/server/internal/daemon/execenv/reply_instructions.go index 9723a6802..c81beea96 100644 --- a/server/internal/daemon/execenv/reply_instructions.go +++ b/server/internal/daemon/execenv/reply_instructions.go @@ -119,28 +119,40 @@ func activeThreadID(triggerThreadID, triggerCommentID string) string { // because resumed Claude sessions keep prior turns' tool calls in context // and will otherwise copy the old --parent UUID forward. // -// The template is platform-aware but provider-agnostic — the failure it +// The template is platform-agnostic AND provider-agnostic — the failure it // guards against lives at the shell layer, so it cannot be scoped to one -// provider (MUL-2904): +// provider or one OS: // -// - Windows + any provider → write a UTF-8 file, post with `--content-file`. -// This is the only path that survives Windows shells (PowerShell 5.1 -// defaults to ASCIIEncoding when piping to native commands and drops -// non-ASCII as `?`; cmd.exe is at the mercy of `chcp`). The original -// reports — #2198 (Chinese), #2236 (Chinese), #2376 (Cyrillic, observed -// on a non-Codex agent) — all match this signature. -// - Linux/macOS + any provider → `--content-stdin` with a QUOTED HEREDOC -// (`<<'COMMENT'`). The quoted delimiter stops the shell from expanding -// backticks, `$()`, or `$VAR` inside the body. Inlining `--content "..."` -// instead lets the shell rewrite the body BEFORE the CLI receives it: a -// backtick-wrapped token becomes a failed command substitution that is -// silently deleted, the stored comment no longer matches what the model -// intended, and a model that notices the mismatch can retry forever -// (MUL-2904 / OKK-497). It also sidesteps Codex's habit of emitting -// literal `\n` escapes inside `--content` (MUL-1467). +// - Inline `--content "..."` lets the shell rewrite the body BEFORE the CLI +// receives it: a backtick-wrapped token becomes a failed command +// substitution that is silently deleted, the stored comment no longer +// matches what the model intended, and a model that notices the mismatch +// can retry forever (MUL-2904 / OKK-497). It also lets Codex emit literal +// `\n` escapes inside `--content` (MUL-1467). +// - `--content-stdin` with a HEREDOC has TWO failure modes the model cannot +// see: +// 1. On Windows, PowerShell 5.1's `$OutputEncoding` defaults to +// ASCIIEncoding when piping to native commands and drops non-ASCII as +// `?` before the bytes reach `multica.exe` (#2198 Chinese, #2236 +// Chinese, #2376 Cyrillic). +// 2. On any host, when the model emits a multi-flag command (e.g. +// `multica issue create --title ... --assignee-id ... --project ...`) +// the bash heredoc/flag boundary is fragile: a `BODY \` "terminator +// with trailing token" is not recognised as the heredoc end, so flag +// lines after it are swallowed into the description; or a clean +// terminator turns the trailing `--assignee ...` line into a separate +// shell statement that fails while the create already succeeded with +// no assignee. Both paths exit 0 with silently dropped flags. Github +// issue #4182 documents two confirmed cases (OXY-78, OXY-76). +// +// The single safe path is therefore: write the body to a UTF-8 file with +// the file-write tool, post with `--content-file`, then remove the file. +// All flags live on one shell-token line; the body never touches the shell; +// no heredoc boundary exists for flags to leak across. This converges with +// the long-standing Windows path so the cross-platform template is one shape. // // provider is retained for caller symmetry and future per-provider tweaks; the -// guardrail itself is intentionally identical across providers. +// guardrail itself is intentionally identical across providers and hosts. func BuildCommentReplyInstructions(provider, issueID, triggerCommentID string) string { if triggerCommentID == "" { return "" @@ -154,30 +166,37 @@ func BuildCommentReplyInstructions(provider, issueID, triggerCommentID string) s "Do NOT use inline `--content`; it is easy to lose formatting or accidentally compress a structured reply into one line.\n\n"+ "Use this form, preserving the same issue ID and --parent value:\n\n"+ " # 1. Write the reply body to a UTF-8 file (e.g. reply.md) with your file-write tool.\n"+ - " # 2. Then run:\n"+ - " multica issue comment add %s --parent %s --content-file ./reply.md\n\n"+ + " # 2. Post the comment:\n"+ + " multica issue comment add %s --parent %s --content-file ./reply.md\n"+ + " # 3. Remove the temp file so a later run does not pick up stale content:\n"+ + " Remove-Item ./reply.md\n\n"+ "Do NOT write literal `\\n` escapes to simulate line breaks; the file preserves real newlines.\n", issueID, triggerCommentID, ) } - // Linux/macOS, any provider: `--content-stdin` with a quoted HEREDOC. The - // quoted delimiter (`<<'COMMENT'`) is what makes this safe — it stops the - // shell from running backtick / `$()` substitution or `$VAR` expansion on - // the body. Inlining `--content "..."` is what triggered the MUL-2904 - // duplicate-comment loop, so it is banned for every provider here, not just - // Codex. + // Linux/macOS, any provider: `--content-file`. Switched from `--content-stdin` + + // HEREDOC to converge with the Windows path and close GitHub #4182. The + // HEREDOC pattern was safe for the trivial single-flag case, but as soon as + // the model wrapped extra flags around the heredoc (assignee, project on + // `issue create` / `issue update`) it became fragile to flag/heredoc + // boundary mistakes — flags either got swallowed into the body or executed + // as separate failing shell statements while the create succeeded with + // nulls. The file path eliminates that class of error: all flags live on + // one command line, the body never reaches the shell. return fmt.Sprintf( "If you decide to reply, post it as a comment — always use the trigger comment ID below, "+ "do NOT reuse --parent values from previous turns in this session.\n\n"+ - "Always use `--content-stdin` with a HEREDOC for agent-authored issue comments, even when the reply is a single line. "+ - "Do NOT use inline `--content`; the shell rewrites unescaped backticks, `$()`, `$VAR`, or quotes in the body before the CLI receives them, and it is easy to lose formatting or compress a structured reply into one line.\n\n"+ + "Write the reply body to a UTF-8 file with your file-write tool first, then post it with `--content-file`. "+ + "Do NOT use inline `--content`; the shell rewrites unescaped backticks, `$()`, `$VAR`, or quotes in the body before the CLI receives them. "+ + "Do NOT use `--content-stdin` with a HEREDOC either — when extra flags (e.g. `--assignee`, `--project` on `multica issue create`) accompany the command, the bash heredoc/flag boundary is fragile and flags can be silently swallowed into the stdin stream while the command still exits 0 (see GitHub #4182, OXY-78 / OXY-76). "+ + "It is also easy to lose formatting or compress a structured reply into one line with inline forms.\n\n"+ "Use this form, preserving the same issue ID and --parent value:\n\n"+ - " cat <<'COMMENT' | multica issue comment add %s --parent %s --content-stdin\n"+ - " First paragraph.\n"+ - "\n"+ - " Second paragraph.\n"+ - " COMMENT\n\n"+ - "Do NOT write literal `\\n` escapes to simulate line breaks; the HEREDOC preserves real newlines.\n", + " # 1. Write the reply body to a UTF-8 file (e.g. reply.md) with your file-write tool.\n"+ + " # 2. Post the comment:\n"+ + " multica issue comment add %s --parent %s --content-file ./reply.md\n"+ + " # 3. Remove the temp file so a later run does not pick up stale content:\n"+ + " rm ./reply.md\n\n"+ + "Do NOT write literal `\\n` escapes to simulate line breaks; the file preserves real newlines.\n", issueID, triggerCommentID, ) } diff --git a/server/internal/daemon/execenv/reply_instructions_test.go b/server/internal/daemon/execenv/reply_instructions_test.go index 060484017..005f5c93e 100644 --- a/server/internal/daemon/execenv/reply_instructions_test.go +++ b/server/internal/daemon/execenv/reply_instructions_test.go @@ -7,11 +7,15 @@ import ( "testing" ) -// TestBuildCommentReplyInstructionsCodexLinux pins that the strong -// "MUST use --content-stdin + HEREDOC" mandate stays alive for Codex on -// non-Windows hosts. Codex's habit of emitting literal `\n` inside -// `--content "..."` is the original reason this mandate exists -// (#1795 / #1851); on Linux/macOS stdin is the right answer. +// TestBuildCommentReplyInstructionsCodexLinux pins that the Linux/macOS +// reply template now mandates `--content-file` (post-#4182). The previous +// `--content-stdin` + HEREDOC mandate (#1795 / #1851 / MUL-2904) was kept +// for years to defend against backtick / `$()` substitution in the body, +// but the heredoc/flag boundary turned out to be fragile in its own right: +// when a model wrapped extra flags around the heredoc on `multica issue +// create`, the flags got swallowed into stdin and silently dropped (OXY-78, +// OXY-76). The file path defeats both classes — the body never reaches the +// shell, and all flags live on one shell-token line. // // Not parallel: mutates the package-level runtimeGOOS. func TestBuildCommentReplyInstructionsCodexLinux(t *testing.T) { @@ -25,10 +29,11 @@ func TestBuildCommentReplyInstructionsCodexLinux(t *testing.T) { got := BuildCommentReplyInstructions("codex", issueID, triggerID) for _, want := range []string{ - "multica issue comment add " + issueID + " --parent " + triggerID + " --content-stdin", - "Always use `--content-stdin`", - "even when the reply is a single line", - "<<'COMMENT'", + "multica issue comment add " + issueID + " --parent " + triggerID + " --content-file ./reply.md", + "Write the reply body to a UTF-8 file", + "`--content-file`", + "#4182", + "rm ./reply.md", "Do NOT write literal `\\n` escapes to simulate line breaks", "do NOT reuse --parent values from previous turns", } { @@ -37,19 +42,32 @@ func TestBuildCommentReplyInstructionsCodexLinux(t *testing.T) { } } - if strings.Contains(got, "--content \"...\"") { - t.Fatalf("codex reply instructions should not offer inline --content form\n---\n%s", got) + for _, banned := range []string{ + "--content \"...\"", + "<<'COMMENT'", + "cat <<", + "--parent " + triggerID + " --content-stdin", + } { + if strings.Contains(got, banned) { + t.Fatalf("codex/linux reply instructions should not contain %q\n---\n%s", banned, got) + } } } -// TestBuildCommentReplyInstructionsNonCodexLinux pins the MUL-2904 regression: -// EVERY provider on Linux/macOS — not just Codex — gets the quoted-HEREDOC -// `--content-stdin` template and is steered away from inline `--content "..."`. -// The duplicate-comment loop on OKK-497 happened because an agent inlined a -// backtick-wrapped table name into `--content`; the shell ran it as a command -// substitution, silently deleted it, the stored comment no longer matched the -// model's intent, and the model retried forever. The corruption is shell-driven, -// so the guardrail cannot be scoped to one provider. +// TestBuildCommentReplyInstructionsNonCodexLinux pins that EVERY provider on +// Linux/macOS — not just Codex — gets the `--content-file` template. Two +// shell-driven failure classes motivate the uniform file path: +// - MUL-2904 / OKK-497: an agent inlined a backtick-wrapped table name into +// `--content`; the shell ran it as a command substitution, silently deleted +// it, the stored comment no longer matched the model's intent, and the +// model retried forever. +// - GitHub #4182 (OXY-78 / OXY-76): an agent wrapped extra flags around an +// `--content-stdin` HEREDOC; the bash heredoc/flag boundary swallowed +// `--assignee` / `--project` into stdin or dropped them as failed +// standalone shell statements, while the create still exited 0 with nulls. +// +// Both classes are shell-driven, so the guardrail is uniform across providers +// and across hosts. // // Not parallel: mutates the package-level runtimeGOOS. func TestBuildCommentReplyInstructionsNonCodexLinux(t *testing.T) { @@ -67,8 +85,11 @@ func TestBuildCommentReplyInstructionsNonCodexLinux(t *testing.T) { got := BuildCommentReplyInstructions(provider, issueID, triggerID) for _, want := range []string{ - "cat <<'COMMENT' | multica issue comment add " + issueID + " --parent " + triggerID + " --content-stdin", - "Always use `--content-stdin`", + "multica issue comment add " + issueID + " --parent " + triggerID + " --content-file ./reply.md", + "Write the reply body to a UTF-8 file", + "`--content-file`", + "#4182", + "rm ./reply.md", "do NOT reuse --parent values from previous turns", "If you decide to reply", } { @@ -77,11 +98,18 @@ func TestBuildCommentReplyInstructionsNonCodexLinux(t *testing.T) { } } - // The regression itself: agent-authored comments must never be - // steered at inline `--content "..."`, which the shell can - // rewrite (backticks / `$()` / quotes) before the CLI sees it. - if strings.Contains(got, "--content \"...\"") { - t.Errorf("%s reply instructions still offers the inline --content form\n---\n%s", name, got) + // The two regressions: agent-authored comments must never be + // steered at inline `--content "..."` (MUL-2904) and never at + // `--content-stdin` HEREDOC on multi-flag commands (#4182). + for _, banned := range []string{ + "--content \"...\"", + "<<'COMMENT'", + "cat <<", + "--parent " + triggerID + " --content-stdin", + } { + if strings.Contains(got, banned) { + t.Errorf("%s reply instructions still contains %q\n---\n%s", name, banned, got) + } } }) } diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index 4c64ff254..edadda03c 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -473,8 +473,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string { b.WriteString("### Core\n") b.WriteString("- `multica issue get --output json` — Get full issue details.\n") b.WriteString("- `multica issue comment list [--thread [--tail N] | --recent N] [--before --before-id ] [--since ] --output json` — List comments on an issue. Default returns the full flat timeline (server cap 2000). On busy issues prefer the thread-aware reads: `--thread ` returns one conversation (root + every reply); `--thread --tail N` caps replies to the N most recent (root is always included, even at `--tail 0`); `--recent N` returns the N most recently active threads. `--before` / `--before-id` walks older replies under `--thread --tail` (stderr label: `Next reply cursor`) or older threads under `--recent` (stderr label: `Next thread cursor`). `--since` is for incremental polling and may combine with `--thread` (with or without `--tail`) or `--recent`.\n") - b.WriteString("- `multica issue create --title \"...\" [--description \"...\" | --description-stdin | --description-file ] [--priority X] [--status X] [--assignee X | --assignee-id ] [--parent ] [--project ] [--due-date ] [--attachment ]` — Create a new issue; `--attachment` may be repeated.\n") - b.WriteString("- `multica issue update [--title X] [--description X | --description-stdin | --description-file ] [--priority X] [--status X] [--assignee X | --assignee-id ] [--parent ] [--project ] [--due-date ]` — Update issue fields; use `--parent \"\"` to clear parent.\n") + b.WriteString("- `multica issue create --title \"...\" [--description \"...\" | --description-file | --description-stdin] [--priority X] [--status X] [--assignee X | --assignee-id ] [--parent ] [--project ] [--due-date ] [--attachment ]` — Create a new issue; `--attachment` may be repeated. For agent-authored long descriptions, prefer `--description-file ` — flags after a HEREDOC terminator can be silently swallowed (#4182).\n") + b.WriteString("- `multica issue update [--title X] [--description X | --description-file | --description-stdin] [--priority X] [--status X] [--assignee X | --assignee-id ] [--parent ] [--project ] [--due-date ]` — Update issue fields; use `--parent \"\"` to clear parent. For agent-authored long descriptions, prefer `--description-file ` over stdin (#4182).\n") b.WriteString("- `multica repo checkout [--ref ]` — Check out a repository into the working directory (creates a git worktree with a dedicated branch; use `--ref` for review/QA on a specific branch, tag, or commit)\n") b.WriteString("- `multica issue status ` — Shortcut for `issue update --status` when you only need to flip status (todo, in_progress, in_review, done, blocked, backlog, cancelled)\n") // Available Commands lists `multica issue comment add` with all three input @@ -493,30 +493,49 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string { // non-UTF-8 codepage (issues #2198 / #2236 / #2376) — which is why // Windows uses `--content-file`, not stdin. // Because the corruption is shell-driven, the guardrail is provider-agnostic. - b.WriteString("- `multica issue comment add [--content \"...\" | --content-stdin | --content-file ] [--parent ] [--attachment ]` — Post a comment. For agent-authored bodies, do NOT inline `--content` — the shell can rewrite backticks, `$()`, quotes, or newlines before the CLI sees them; use the platform-correct non-inline mode shown in ## Comment Formatting below. Run `multica issue comment add --help` for details.\n") + b.WriteString("- `multica issue comment add [--content \"...\" | --content-file | --content-stdin] [--parent ] [--attachment ]` — Post a comment. For agent-authored bodies, **write the body to a UTF-8 file and use `--content-file `** — do NOT inline `--content` (the shell rewrites backticks, `$()`, quotes, or newlines before the CLI sees them) and do NOT use `--content-stdin` with a HEREDOC (extra flags around the heredoc can be silently swallowed, #4182). See ## Comment Formatting below. Run `multica issue comment add --help` for details.\n") b.WriteString("- `multica issue metadata list [--output json]` — List every metadata key pinned to an issue. Empty `{}` is normal.\n") b.WriteString("- `multica issue metadata set --key --value [--type string|number|bool]` — Pin (or overwrite) a single metadata key. The CLI auto-infers JSON primitives, so URLs and plain text are stored as strings — pass `--type number` or `--type bool` only when the semantic type matters.\n") b.WriteString("- `multica issue metadata delete --key ` — Remove a metadata key.\n\n") b.WriteString("### Squad maintenance\n") b.WriteString("- `multica squad member set-role --member-id --member-type --role [--output json]` — Change a squad member role in place; use this instead of remove+add when only the role changes.\n\n") - // Comment Formatting guardrail for ALL providers. The MUL-2904 - // duplicate-comment loop happened because an agent inlined a backtick-wrapped - // table name into `--content "..."`; the shell ran it as a command - // substitution, silently deleted it, and the model retried forever. Because - // the corruption is shell-driven, not provider-driven, this directive is not - // scoped to Codex — every agent-authored comment must avoid inline - // `--content`. The platform split mirrors BuildCommentReplyInstructions: - // Windows → file (stdin pipes drop non-ASCII), Linux/macOS → quoted HEREDOC - // over stdin (the quoted delimiter blocks backtick / `$()` / `$VAR`). + // Comment Formatting guardrail for ALL providers and ALL hosts. Two + // shell-layer hazards motivate a single, uniform "write a file, post with + // `--content-file`" rule rather than a per-OS split: + // + // 1. Inline `--content "..."`: backtick / `$()` substitution, `$VAR` + // expansion, and quote / newline mangling on Linux/macOS. A + // backtick-wrapped token in the body is executed and silently deleted, + // corrupting the stored comment and triggering a retry loop + // (MUL-2904 / OKK-497). + // 2. `--content-stdin` with a HEREDOC: TWO failure modes the model cannot + // see — (a) on Windows, PowerShell 5.1's `$OutputEncoding` defaults to + // ASCIIEncoding when piping to a native command and silently drops + // non-ASCII as `?` before the bytes reach `multica.exe` (#2198 / + // #2236 / #2376); (b) on any host, when the model emits a multi-flag + // command (`multica issue create --title ... --assignee-id ... + // --project ...`), the bash heredoc/flag boundary is fragile — a + // `BODY \` "terminator with trailing token" is not recognised as the + // heredoc end (flag lines after it leak into the description), or a + // clean terminator turns the trailing `--assignee ...` line into a + // separate failing shell statement while the create already exited 0 + // with no assignee (GitHub #4182, OXY-78 / OXY-76). + // + // `--content-file` defeats both classes: all flags live on one shell-token + // line, the body never reaches the shell, no heredoc boundary exists for + // flags to leak across. This is identical to the long-standing Windows + // path, so the cross-platform guidance is now one shape. b.WriteString("## Comment Formatting\n\n") if runtimeGOOS == "windows" { b.WriteString("On Windows, **always write the comment body to a UTF-8 file with your file-write tool first, then post it with `--content-file `** — do NOT pipe via `--content-stdin`. PowerShell 5.1's `$OutputEncoding` defaults to ASCIIEncoding when piping to a native command, silently dropping non-ASCII characters as `?` before they reach `multica.exe`. Never use inline `--content` for agent-authored comments. ") b.WriteString("Keep the same `--parent` value from the trigger comment when replying. ") + b.WriteString("After posting, remove the temp file with `Remove-Item ./reply.md` (or your chosen path) so a later run does not pick up stale content. ") b.WriteString("Do not compress a multi-paragraph answer into one line and do not rely on `\\n` escapes.\n\n") } else { - b.WriteString("For issue comments, always use `--content-stdin` with a HEREDOC, even for short single-line replies — use a quoted delimiter (`<<'COMMENT'`) so the shell does not expand backticks, `$()`, or `$VAR` inside the body. `--content-file ` works too. ") - b.WriteString("Never use inline `--content` for agent-authored comments: unescaped backticks, `$()`, `$VAR`, or quotes in the body are rewritten by the shell before the CLI receives them. Keep the same `--parent` value from the trigger comment when replying. ") + b.WriteString("For issue comments, **always write the comment body to a UTF-8 file with your file-write tool first, then post it with `--content-file `**. Never use inline `--content` for agent-authored comments — the shell rewrites backticks, `$()`, `$VAR`, or quotes in the body before the CLI receives them (MUL-2904). Do NOT use `--content-stdin` with a HEREDOC either: when extra flags accompany the command (e.g. `--assignee`, `--project` on `multica issue create`), the bash heredoc/flag boundary is fragile and flags can be silently swallowed into the stdin stream while the command still exits 0 (GitHub #4182). ") + b.WriteString("Keep the same `--parent` value from the trigger comment when replying. ") + b.WriteString("After posting, remove the temp file with `rm ./reply.md` (or your chosen path) so a later run does not pick up stale content. ") b.WriteString("Do not compress a multi-paragraph answer into one line and do not rely on `\\n` escapes.\n\n") } diff --git a/server/internal/daemon/prompt.go b/server/internal/daemon/prompt.go index 69a322ddc..8bec30794 100644 --- a/server/internal/daemon/prompt.go +++ b/server/internal/daemon/prompt.go @@ -11,9 +11,9 @@ import ( // Keep this minimal — detailed instructions live in CLAUDE.md / AGENTS.md // injected by execenv.InjectRuntimeConfig. The provider string is threaded // through to comment-triggered tasks' per-turn reply template; that template -// is provider-agnostic now (Linux/macOS → quoted-HEREDOC stdin, Windows → -// file) because the shell-layer corruption it guards against is not specific -// to any one provider (MUL-2904). +// is provider-agnostic AND host-agnostic now (every OS → write a UTF-8 file, +// post with `--content-file`) because the shell-layer corruption it guards +// against is not specific to any one provider or host (MUL-2904, #4182). func BuildPrompt(task Task, provider string) string { if task.ChatSessionID != "" { return buildChatPrompt(task)