4040#include <sys/stat.h>
4141#include <unistd.h>
4242#ifdef _WIN32
43+ #include <fcntl.h> // _O_BINARY
4344#include <io.h>
4445#endif
4546#ifndef _WIN32
@@ -74,7 +75,8 @@ int cbm_cli_activation_abort_cleanup_probe_for_test(void);
7475bool cbm_cli_activation_test_ops_installed(void);
7576int cbm_cli_build_yaml_stdio_mcp_block_for_test(const char *binary_path, bool goose_schema,
7677 char *block, size_t block_size);
77- bool cbm_cli_stdin_allowed_for_schema_for_test(const char *schema_str);
78+ bool cbm_cli_stdin_allowed_for_schema_for_test(const char *schema_str,
79+ cbm_cli_stdin_kind_t stdin_kind);
7880
7981/* CLI config lifecycle tests exercise the real activation guard on POSIX. Keep
8082 * that protocol coverage, but bind it to this suite's private endpoint so an
@@ -17338,27 +17340,71 @@ TEST(cli_zero_argument_tool_never_reads_stdin_issue1359) {
1733817340 * never read stdin (nothing it could carry; the read is pure deadlock).
1733917341 * Since #1181 no SHIPPED tool is zero-argument anymore — list_projects,
1734017342 * the original example, now declares pagination properties — so the
17341- * zero-argument branch is exercised directly through the schema seam,
17342- * and list_projects asserts its NEW truth. */
17343- ASSERT_FALSE(
17344- cbm_cli_stdin_allowed_for_schema_for_test("{\"type\":\"object\",\"properties\":{}}"));
17345- ASSERT_FALSE(cbm_cli_stdin_allowed_for_schema_for_test("{\"type\":\"object\"}"));
17346- ASSERT_TRUE(cbm_cli_stdin_allowed_for_schema_for_test(
17347- "{\"type\":\"object\",\"properties\":{\"p\":{\"type\":\"string\"}}}"));
17343+ * zero-argument branch is exercised directly through the schema seam. */
17344+ const char *no_props = "{\"type\":\"object\",\"properties\":{}}";
17345+ const char *no_props_key = "{\"type\":\"object\"}";
17346+ ASSERT_FALSE(cbm_cli_stdin_allowed_for_schema_for_test(no_props, CBM_CLI_STDIN_STREAM));
17347+ ASSERT_FALSE(cbm_cli_stdin_allowed_for_schema_for_test(no_props, CBM_CLI_STDIN_FILE));
17348+ ASSERT_FALSE(cbm_cli_stdin_allowed_for_schema_for_test(no_props_key, CBM_CLI_STDIN_STREAM));
17349+ ASSERT_FALSE(cbm_cli_stdin_allowed_for_schema_for_test(no_props_key, CBM_CLI_STDIN_FILE));
17350+
17351+ /* A required argument: the bare call cannot succeed, so stdin is the
17352+ * documented channel from a pipe and from a file alike. */
17353+ const char *required =
17354+ "{\"type\":\"object\",\"properties\":{\"p\":{\"type\":\"string\"}},\"required\":[\"p\"]}";
17355+ ASSERT_TRUE(cbm_cli_stdin_allowed_for_schema_for_test(required, CBM_CLI_STDIN_STREAM));
17356+ ASSERT_TRUE(cbm_cli_stdin_allowed_for_schema_for_test(required, CBM_CLI_STDIN_FILE));
17357+ ASSERT_FALSE(cbm_cli_stdin_allowed_for_schema_for_test(required, CBM_CLI_STDIN_TERMINAL));
17358+ PASS();
17359+ }
17360+
17361+ /* #1181 gave list_projects optional paging properties, which re-opened #1359:
17362+ * the schema gate only skipped EMPTY schemas, so `cli list_projects` parked in
17363+ * fread(0) again for every caller holding an inherited pipe open (an ssh
17364+ * session's stdin hung the Windows smoke's Phase 1b for 25 minutes). A call
17365+ * complete without arguments must never wait on a stream; a regular file
17366+ * (`< args.json`) always reaches EOF and keeps working. */
17367+ TEST(cli_optional_argument_tool_never_reads_stream_stdin) {
17368+ const char *optional = "{\"type\":\"object\",\"properties\":{\"p\":{\"type\":\"string\"}}}";
17369+ ASSERT_FALSE(cbm_cli_stdin_allowed_for_schema_for_test(optional, CBM_CLI_STDIN_STREAM));
17370+ ASSERT_TRUE(cbm_cli_stdin_allowed_for_schema_for_test(optional, CBM_CLI_STDIN_FILE));
17371+ ASSERT_FALSE(cbm_cli_stdin_allowed_for_schema_for_test(optional, CBM_CLI_STDIN_TERMINAL));
17372+ const char *empty_required =
17373+ "{\"type\":\"object\",\"properties\":{\"p\":{\"type\":\"string\"}},\"required\":[]}";
17374+ ASSERT_FALSE(cbm_cli_stdin_allowed_for_schema_for_test(empty_required, CBM_CLI_STDIN_STREAM));
17375+
17376+ ASSERT_FALSE(cbm_cli_args_from_stdin_allowed("list_projects", CBM_CLI_STDIN_STREAM));
17377+ ASSERT_TRUE(cbm_cli_args_from_stdin_allowed("list_projects", CBM_CLI_STDIN_FILE));
17378+ ASSERT_FALSE(cbm_cli_args_from_stdin_allowed("list_projects", CBM_CLI_STDIN_TERMINAL));
17379+ PASS();
17380+ }
17381+
17382+ /* The classifier behind the gate, on real descriptors: a regular file is FILE
17383+ * (reads always end), a pipe is STREAM (ends only when its writer closes). */
17384+ TEST(cli_stdin_kind_classifies_file_and_pipe) {
17385+ FILE *file = tmpfile();
17386+ ASSERT_NOT_NULL(file);
17387+ ASSERT_EQ(cbm_cli_stdin_kind(fileno(file)), CBM_CLI_STDIN_FILE);
17388+ (void)fclose(file);
1734817389
17349- /* list_projects now takes piped arguments (offset/limit/include_details);
17350- * the TTY guard still refuses regardless of schema. */
17351- ASSERT_TRUE(cbm_cli_args_from_stdin_allowed("list_projects", false));
17352- ASSERT_FALSE(cbm_cli_args_from_stdin_allowed("list_projects", true));
17390+ int fds[2];
17391+ #ifdef _WIN32
17392+ ASSERT_EQ(_pipe(fds, 4096, _O_BINARY), 0);
17393+ #else
17394+ ASSERT_EQ(pipe(fds), 0);
17395+ #endif
17396+ ASSERT_EQ(cbm_cli_stdin_kind(fds[0]), CBM_CLI_STDIN_STREAM);
17397+ (void)close(fds[0]);
17398+ (void)close(fds[1]);
1735317399 PASS();
1735417400}
1735517401
1735617402/* The gate must track the advertised input_schema for the WHOLE tool table, not
17357- * carry a special case for list_projects: a future zero-argument tool would
17358- * otherwise reintroduce #1359 silently. Expectation is derived independently
17359- * from the schema the MCP tools/list publishes. */
17403+ * carry a special case for list_projects: a future zero-argument or
17404+ * optional-only tool would otherwise reintroduce #1359 silently. Expectation is
17405+ * derived independently from the schema the MCP tools/list publishes. */
1736017406TEST(cli_stdin_args_gate_tracks_tool_schema_issue1359) {
17361- int zero_argument_tools = 0;
17407+ int optional_only_tools = 0;
1736217408 for (int i = 0; i < cbm_mcp_tool_count(); i++) {
1736317409 const char *name = cbm_mcp_tool_name(i);
1736417410 ASSERT_NOT_NULL(name);
@@ -17367,22 +17413,25 @@ TEST(cli_stdin_args_gate_tracks_tool_schema_issue1359) {
1736717413
1736817414 yyjson_doc *doc = yyjson_read(schema, strlen(schema), 0);
1736917415 ASSERT_NOT_NULL(doc);
17370- yyjson_val *props = yyjson_obj_get(yyjson_doc_get_root(doc), "properties");
17416+ yyjson_val *root = yyjson_doc_get_root(doc);
17417+ yyjson_val *props = yyjson_obj_get(root, "properties");
17418+ yyjson_val *required = yyjson_obj_get(root, "required");
1737117419 bool declares_properties = props && yyjson_is_obj(props) && yyjson_obj_size(props) > 0;
17420+ bool declares_required =
17421+ required && yyjson_is_arr(required) && yyjson_arr_size(required) > 0;
1737217422 yyjson_doc_free(doc);
1737317423
17374- if (! declares_properties) {
17375- zero_argument_tools ++;
17424+ if (declares_properties && !declares_required ) {
17425+ optional_only_tools ++;
1737617426 }
17377- ASSERT_EQ(cbm_cli_args_from_stdin_allowed(name, false), declares_properties);
17378- ASSERT_FALSE(cbm_cli_args_from_stdin_allowed(name, true));
17427+ ASSERT_EQ(cbm_cli_args_from_stdin_allowed(name, CBM_CLI_STDIN_FILE), declares_properties);
17428+ ASSERT_EQ(cbm_cli_args_from_stdin_allowed(name, CBM_CLI_STDIN_STREAM),
17429+ declares_properties && declares_required);
17430+ ASSERT_FALSE(cbm_cli_args_from_stdin_allowed(name, CBM_CLI_STDIN_TERMINAL));
1737917431 }
17380- /* Since #1181 every shipped tool declares properties, so the sweep's
17381- * zero-argument branch can be empty here — that branch is pinned directly
17382- * against the schema seam in the test above, which survives the registry
17383- * having no zero-argument example. The sweep still proves schema↔gate
17384- * parity for every tool that exists. */
17385- (void)zero_argument_tools;
17432+ /* list_projects is the live optional-only example today; the sweep must
17433+ * actually exercise that branch, not pass vacuously. */
17434+ ASSERT_GT(optional_only_tools, 0);
1738617435 PASS();
1738717436}
1738817437
@@ -18529,6 +18578,8 @@ SUITE(cli) {
1852918578
1853018579 /* Stdin argument gate (#1359) */
1853118580 RUN_TEST(cli_zero_argument_tool_never_reads_stdin_issue1359);
18581+ RUN_TEST(cli_optional_argument_tool_never_reads_stream_stdin);
18582+ RUN_TEST(cli_stdin_kind_classifies_file_and_pipe);
1853218583 RUN_TEST(cli_stdin_args_gate_tracks_tool_schema_issue1359);
1853318584 cbm_cli_set_activation_runtime_parent_for_test(NULL);
1853418585 test_rmdir_r(g_cli_suite_runtime_parent);
0 commit comments