Skip to content

Commit 3c372fa

Browse files
Block generic tool input and document idle timeouts
#### Context Docs called turn timeout a total cap, while runtime uses silence. Generic tool-input prompts also received fabricated answers, and approval-looking option labels alone are not enough to identify MCP approval prompts. #### TL;DR *Document idle timeouts and block generic tool input.* #### Summary - Document `turn_timeout_ms` as a reset-on-update silence interval. - Add coverage for active stream updates and silent turns. - Keep auto-approval only for recognized MCP approval question IDs. - Block freeform and generic option-based tool input, including Allow/Deny choices. #### Alternatives - A total-runtime deadline was rejected; healthy long turns should stay alive while updates continue. - Matching approval-looking labels alone was rejected because generic prompts can use the same words. #### Test Plan - [x] `HEX_HOME=/private/tmp/symphony-hex mise exec -- make -C elixir all` - [x] `mise exec -- mix test test/symphony_elixir/app_server_test.exs:619 test/symphony_elixir/app_server_test.exs:788` - [x] `mise exec -- mix test test/symphony_elixir/app_server_test.exs`
1 parent cbd2158 commit 3c372fa

6 files changed

Lines changed: 124 additions & 165 deletions

File tree

SPEC.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,7 +1020,7 @@ Completion conditions:
10201020
- Targeted-protocol turn completion signal -> success
10211021
- Targeted-protocol turn failure signal -> failure
10221022
- Targeted-protocol turn cancellation signal -> failure
1023-
- turn timeout (`turn_timeout_ms`) -> failure
1023+
- turn stream silence timeout (`turn_timeout_ms`) -> failure
10241024
- subprocess exit -> failure
10251025

10261026
Continuation processing:
@@ -1143,7 +1143,8 @@ User-input-required policy:
11431143
Timeouts:
11441144

11451145
- `codex.read_timeout_ms`: request/response timeout during startup and sync requests
1146-
- `codex.turn_timeout_ms`: total turn stream timeout
1146+
- `codex.turn_timeout_ms`: maximum silence interval while a turn stream is active; each
1147+
app-server output resets it, so it is not a total turn runtime cap
11471148
- `codex.stall_timeout_ms`: enforced by orchestrator based on event inactivity
11481149

11491150
Error mapping (RECOMMENDED normalized categories):

elixir/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ Notes:
153153
- `codex.approval_policy` defaults to `{"reject":{"sandbox_approval":true,"rules":true,"mcp_elicitations":true}}`
154154
- `codex.thread_sandbox` defaults to `workspace-write`
155155
- `codex.turn_sandbox_policy` defaults to a `workspaceWrite` policy rooted at the current issue workspace
156+
- `codex.turn_timeout_ms` is the maximum silence interval while a turn is streaming. Each
157+
app-server update resets it; it is not a total turn runtime cap.
156158
- Supported `codex.approval_policy` values depend on the targeted Codex app-server version. In the current local Codex schema, string values include `untrusted`, `on-failure`, `on-request`, and `never`, and object-form `reject` is also supported.
157159
- Supported `codex.thread_sandbox` values: `read-only`, `workspace-write`, `danger-full-access`.
158160
- When `codex.turn_sandbox_policy` is set explicitly, Symphony passes the map through to Codex

elixir/lib/symphony_elixir/codex/app_server.ex

Lines changed: 17 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ defmodule SymphonyElixir.Codex.AppServer do
1111
@turn_start_id 3
1212
@port_line_bytes 1_048_576
1313
@max_stream_log_bytes 1_000
14-
@non_interactive_tool_input_answer "This is a non-interactive session. Operator input is unavailable."
15-
1614
@type session :: %{
1715
port: port(),
1816
metadata: map(),
@@ -819,38 +817,21 @@ defmodule SymphonyElixir.Codex.AppServer do
819817
:approved
820818

821819
:error ->
822-
reply_with_non_interactive_tool_input_answer(
823-
port,
824-
id,
825-
params,
826-
payload,
827-
payload_string,
828-
on_message,
829-
metadata
830-
)
820+
:input_required
831821
end
832822
end
833823

834824
defp maybe_auto_answer_tool_request_user_input(
835-
port,
836-
id,
837-
params,
838-
payload,
839-
payload_string,
840-
on_message,
841-
metadata,
825+
_port,
826+
_id,
827+
_params,
828+
_payload,
829+
_payload_string,
830+
_on_message,
831+
_metadata,
842832
false
843-
) do
844-
reply_with_non_interactive_tool_input_answer(
845-
port,
846-
id,
847-
params,
848-
payload,
849-
payload_string,
850-
on_message,
851-
metadata
852-
)
853-
end
833+
),
834+
do: :input_required
854835

855836
defp tool_request_user_input_approval_answers(%{"questions" => questions}) when is_list(questions) do
856837
answers =
@@ -873,64 +854,15 @@ defmodule SymphonyElixir.Codex.AppServer do
873854

874855
defp tool_request_user_input_approval_answers(_params), do: :error
875856

876-
defp reply_with_non_interactive_tool_input_answer(
877-
port,
878-
id,
879-
params,
880-
payload,
881-
payload_string,
882-
on_message,
883-
metadata
884-
) do
885-
case tool_request_user_input_unavailable_answers(params) do
886-
{:ok, answers} ->
887-
send_message(port, %{"id" => id, "result" => %{"answers" => answers}})
888-
889-
emit_message(
890-
on_message,
891-
:tool_input_auto_answered,
892-
%{payload: payload, raw: payload_string, answer: @non_interactive_tool_input_answer},
893-
metadata
894-
)
895-
896-
:approved
897-
898-
:error ->
899-
:input_required
900-
end
901-
end
902-
903-
defp tool_request_user_input_unavailable_answers(%{"questions" => questions}) when is_list(questions) do
904-
answers =
905-
Enum.reduce_while(questions, %{}, fn question, acc ->
906-
case tool_request_user_input_question_id(question) do
907-
{:ok, question_id} ->
908-
{:cont, Map.put(acc, question_id, %{"answers" => [@non_interactive_tool_input_answer]})}
909-
910-
:error ->
911-
{:halt, :error}
912-
end
913-
end)
914-
915-
case answers do
916-
:error -> :error
917-
answer_map when map_size(answer_map) > 0 -> {:ok, answer_map}
918-
_ -> :error
919-
end
920-
end
921-
922-
defp tool_request_user_input_unavailable_answers(_params), do: :error
923-
924-
defp tool_request_user_input_question_id(%{"id" => question_id}) when is_binary(question_id),
925-
do: {:ok, question_id}
926-
927-
defp tool_request_user_input_question_id(_question), do: :error
928-
929857
defp tool_request_user_input_approval_answer(%{"id" => question_id, "options" => options})
930858
when is_binary(question_id) and is_list(options) do
931-
case tool_request_user_input_approval_option_label(options) do
932-
nil -> :error
933-
answer_label -> {:ok, question_id, answer_label}
859+
if String.starts_with?(question_id, "mcp_tool_call_approval_") do
860+
case tool_request_user_input_approval_option_label(options) do
861+
nil -> :error
862+
answer_label -> {:ok, question_id, answer_label}
863+
end
864+
else
865+
:error
934866
end
935867
end
936868

elixir/lib/symphony_elixir/status_dashboard.ex

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,18 +1125,6 @@ defmodule SymphonyElixir.StatusDashboard do
11251125
if is_binary(decision), do: "#{base}: #{decision}", else: base
11261126
end
11271127

1128-
defp humanize_codex_event(:tool_input_auto_answered, message, payload) do
1129-
answer = map_value(message, ["answer", :answer])
1130-
1131-
base =
1132-
case humanize_codex_method("item/tool/requestUserInput", payload) do
1133-
nil -> "tool input auto-answered"
1134-
text -> "#{text} (auto-answered)"
1135-
end
1136-
1137-
if is_binary(answer), do: "#{base}: #{inline_text(answer)}", else: base
1138-
end
1139-
11401128
defp humanize_codex_event(:tool_call_completed, _message, payload),
11411129
do: humanize_dynamic_tool_event("dynamic tool call completed", payload)
11421130

elixir/test/symphony_elixir/app_server_test.exs

Lines changed: 102 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,95 @@ defmodule SymphonyElixir.AppServerTest do
7676
end
7777
end
7878

79+
test "turn timeout resets on stream updates and fires after silence" do
80+
test_root =
81+
Path.join(
82+
System.tmp_dir!(),
83+
"symphony-elixir-app-server-turn-timeout-#{System.unique_integer([:positive])}"
84+
)
85+
86+
try do
87+
workspace_root = Path.join(test_root, "workspaces")
88+
workspace = Path.join(workspace_root, "MT-TIMEOUT")
89+
codex_binary = Path.join(test_root, "fake-codex")
90+
File.mkdir_p!(workspace)
91+
92+
File.write!(codex_binary, """
93+
#!/bin/sh
94+
count=0
95+
while IFS= read -r _line; do
96+
count=$((count + 1))
97+
case "$count" in
98+
1) printf '%s\n' '{"id":1,"result":{}}' ;;
99+
2) ;;
100+
3) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-timeout"}}}' ;;
101+
4)
102+
printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-timeout"}}}'
103+
sleep 0.15
104+
printf '%s\n' '{"method":"item/updated","params":{"item":{"id":"one"}}}'
105+
sleep 0.15
106+
printf '%s\n' '{"method":"item/updated","params":{"item":{"id":"two"}}}'
107+
sleep 0.15
108+
printf '%s\n' '{"method":"turn/completed"}'
109+
exit 0
110+
;;
111+
*) exit 0 ;;
112+
esac
113+
done
114+
""")
115+
116+
File.chmod!(codex_binary, 0o755)
117+
118+
write_workflow_file!(Workflow.workflow_file_path(),
119+
workspace_root: workspace_root,
120+
codex_command: "#{codex_binary} app-server",
121+
codex_turn_timeout_ms: 250
122+
)
123+
124+
issue = %Issue{
125+
id: "issue-turn-timeout",
126+
identifier: "MT-TIMEOUT",
127+
title: "Stream timeout",
128+
description: "Keep active streams alive",
129+
state: "In Progress",
130+
url: "https://example.org/issues/MT-TIMEOUT",
131+
labels: ["backend"]
132+
}
133+
134+
assert {:ok, _result} = AppServer.run(workspace, "stream updates", issue)
135+
136+
File.write!(codex_binary, """
137+
#!/bin/sh
138+
count=0
139+
while IFS= read -r _line; do
140+
count=$((count + 1))
141+
case "$count" in
142+
1) printf '%s\n' '{"id":1,"result":{}}' ;;
143+
2) ;;
144+
3) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-silent"}}}' ;;
145+
4)
146+
printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-silent"}}}'
147+
sleep 0.4
148+
printf '%s\n' '{"method":"turn/completed"}'
149+
exit 0
150+
;;
151+
*) exit 0 ;;
152+
esac
153+
done
154+
""")
155+
156+
write_workflow_file!(Workflow.workflow_file_path(),
157+
workspace_root: workspace_root,
158+
codex_command: "#{codex_binary} app-server",
159+
codex_turn_timeout_ms: 100
160+
)
161+
162+
assert {:error, :turn_timeout} = AppServer.run(workspace, "silent turn", issue)
163+
after
164+
File.rm_rf(test_root)
165+
end
166+
end
167+
79168
test "app server passes explicit turn sandbox policies through unchanged" do
80169
test_root =
81170
Path.join(
@@ -626,7 +715,7 @@ defmodule SymphonyElixir.AppServerTest do
626715
end
627716
end
628717

629-
test "app server sends a generic non-interactive answer for freeform tool input prompts" do
718+
test "app server blocks freeform tool input prompts" do
630719
test_root =
631720
Path.join(
632721
System.tmp_dir!(),
@@ -687,22 +776,16 @@ defmodule SymphonyElixir.AppServerTest do
687776
labels: ["backend"]
688777
}
689778

690-
on_message = fn message -> send(self(), {:app_server_message, message}) end
691-
692-
assert {:ok, _result} =
693-
AppServer.run(workspace, "Handle generic tool input", issue, on_message: on_message)
779+
assert {:error, {:turn_input_required, payload}} =
780+
AppServer.run(workspace, "Handle generic tool input", issue)
694781

695-
assert_received {:app_server_message,
696-
%{
697-
event: :tool_input_auto_answered,
698-
answer: "This is a non-interactive session. Operator input is unavailable."
699-
}}
782+
assert payload["method"] == "item/tool/requestUserInput"
700783
after
701784
File.rm_rf(test_root)
702785
end
703786
end
704787

705-
test "app server sends a generic non-interactive answer for option-based tool input prompts" do
788+
test "app server blocks option-based tool input prompts" do
706789
test_root =
707790
Path.join(
708791
System.tmp_dir!(),
@@ -713,27 +796,13 @@ defmodule SymphonyElixir.AppServerTest do
713796
workspace_root = Path.join(test_root, "workspaces")
714797
workspace = Path.join(workspace_root, "MT-719")
715798
codex_binary = Path.join(test_root, "fake-codex")
716-
trace_file = Path.join(test_root, "codex-tool-user-input-options.trace")
717-
previous_trace = System.get_env("SYMP_TEST_CODEx_TRACE")
718-
719-
on_exit(fn ->
720-
if is_binary(previous_trace) do
721-
System.put_env("SYMP_TEST_CODEx_TRACE", previous_trace)
722-
else
723-
System.delete_env("SYMP_TEST_CODEx_TRACE")
724-
end
725-
end)
726-
727-
System.put_env("SYMP_TEST_CODEx_TRACE", trace_file)
728799
File.mkdir_p!(workspace)
729800

730801
File.write!(codex_binary, """
731802
#!/bin/sh
732-
trace_file="${SYMP_TEST_CODEx_TRACE:-/tmp/codex-tool-user-input-options.trace}"
733803
count=0
734-
while IFS= read -r line; do
804+
while IFS= read -r _line; do
735805
count=$((count + 1))
736-
printf 'JSON:%s\\n' \"$line\" >> \"$trace_file\"
737806
738807
case \"$count\" in
739808
1)
@@ -746,7 +815,7 @@ defmodule SymphonyElixir.AppServerTest do
746815
;;
747816
4)
748817
printf '%s\\n' '{\"id\":3,\"result\":{\"turn\":{\"id\":\"turn-719\"}}}'
749-
printf '%s\\n' '{\"id\":112,\"method\":\"item/tool/requestUserInput\",\"params\":{\"itemId\":\"call-719\",\"questions\":[{\"header\":\"Choose an action\",\"id\":\"options-719\",\"isOther\":false,\"isSecret\":false,\"options\":[{\"description\":\"Use the default behavior.\",\"label\":\"Use default\"},{\"description\":\"Skip this step.\",\"label\":\"Skip\"}],\"question\":\"How should I proceed?\"}],\"threadId\":\"thread-719\",\"turnId\":\"turn-719\"}}'
818+
printf '%s\\n' '{\"id\":112,\"method\":\"item/tool/requestUserInput\",\"params\":{\"itemId\":\"call-719\",\"questions\":[{\"header\":\"Choose an action\",\"id\":\"options-719\",\"isOther\":false,\"isSecret\":false,\"options\":[{\"description\":\"Proceed with the requested action.\",\"label\":\"Allow\"},{\"description\":\"Do not proceed.\",\"label\":\"Deny\"}],\"question\":\"How should I proceed?\"}],\"threadId\":\"thread-719\",\"turnId\":\"turn-719\"}}'
750819
;;
751820
5)
752821
printf '%s\\n' '{\"method\":\"turn/completed\"}'
@@ -763,40 +832,24 @@ defmodule SymphonyElixir.AppServerTest do
763832

764833
write_workflow_file!(Workflow.workflow_file_path(),
765834
workspace_root: workspace_root,
766-
codex_command: "#{codex_binary} app-server"
835+
codex_command: "#{codex_binary} app-server",
836+
codex_approval_policy: "never"
767837
)
768838

769839
issue = %Issue{
770840
id: "issue-tool-user-input-options",
771841
identifier: "MT-719",
772-
title: "Option based tool input answer",
773-
description: "Ensure option prompts receive a generic non-interactive answer",
842+
title: "Option based tool input block",
843+
description: "Ensure option prompts require operator input",
774844
state: "In Progress",
775845
url: "https://example.org/issues/MT-719",
776846
labels: ["backend"]
777847
}
778848

779-
assert {:ok, _result} =
849+
assert {:error, {:turn_input_required, payload}} =
780850
AppServer.run(workspace, "Handle option based tool input", issue)
781851

782-
trace = File.read!(trace_file)
783-
lines = String.split(trace, "\n", trim: true)
784-
785-
assert Enum.any?(lines, fn line ->
786-
if String.starts_with?(line, "JSON:") do
787-
payload =
788-
line
789-
|> String.trim_leading("JSON:")
790-
|> Jason.decode!()
791-
792-
payload["id"] == 112 and
793-
get_in(payload, ["result", "answers", "options-719", "answers"]) == [
794-
"This is a non-interactive session. Operator input is unavailable."
795-
]
796-
else
797-
false
798-
end
799-
end)
852+
assert payload["method"] == "item/tool/requestUserInput"
800853
after
801854
File.rm_rf(test_root)
802855
end

0 commit comments

Comments
 (0)