Skip to content

Commit 4942472

Browse files
authored
Break-glass merge PR #2633
2 parents 7f7e35c + 7ef75e5 commit 4942472

4 files changed

Lines changed: 148 additions & 58 deletions

File tree

‎src/cli/cli.c‎

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14088,13 +14088,29 @@ static void cli_add_typed(yyjson_mut_doc *out, yyjson_mut_val *obj, const char *
1408814088
yyjson_mut_obj_add(obj, yyjson_mut_strcpy(out, key), vv);
1408914089
}
1409014090

14091-
static bool cli_stdin_allowed_for_schema(const char *schema_str);
14091+
static bool cli_stdin_allowed_for_schema(const char *schema_str, cbm_cli_stdin_kind_t stdin_kind);
1409214092

14093-
bool cbm_cli_args_from_stdin_allowed(const char *tool_name, bool stdin_is_tty) {
14093+
cbm_cli_stdin_kind_t cbm_cli_stdin_kind(int fd) {
14094+
#ifdef _WIN32
14095+
bool terminal = _isatty(fd) != 0;
14096+
#else
14097+
bool terminal = isatty(fd) != 0;
14098+
#endif
14099+
if (terminal) {
14100+
return CBM_CLI_STDIN_TERMINAL;
14101+
}
14102+
struct stat state;
14103+
if (fstat(fd, &state) == 0 && S_ISREG(state.st_mode)) {
14104+
return CBM_CLI_STDIN_FILE;
14105+
}
14106+
return CBM_CLI_STDIN_STREAM;
14107+
}
14108+
14109+
bool cbm_cli_args_from_stdin_allowed(const char *tool_name, cbm_cli_stdin_kind_t stdin_kind) {
1409414110
/* An interactive run has nothing piped to read: consulting the terminal
1409514111
* would just sit waiting for the user to type JSON. Unchanged by #1359 —
1409614112
* this is why the hang was only ever reachable from automation. */
14097-
if (stdin_is_tty) {
14113+
if (stdin_kind == CBM_CLI_STDIN_TERMINAL) {
1409814114
return false;
1409914115
}
1410014116

@@ -14105,48 +14121,60 @@ bool cbm_cli_args_from_stdin_allowed(const char *tool_name, bool stdin_is_tty) {
1410514121
* parent must call child.stdin.end() explicitly, which almost nobody does
1410614122
* for a command they are not writing to. fd 0 then stays open with no
1410714123
* writer and the read never returns; the reporter's shell script was still
14108-
* parked in fread(0) fourteen minutes later. For a tool whose input_schema
14109-
* declares no properties (list_projects) stdin cannot carry anything the
14110-
* tool would accept, so that read is pure deadlock with nothing to gain:
14111-
* never start it, and let the caller fall through to `{}` exactly as an
14112-
* interactive run already did. Tools that DO declare properties keep the
14113-
* documented `echo '<json>' | cli <tool>` channel untouched. */
14124+
* parked in fread(0) fourteen minutes later. A call that is complete
14125+
* without arguments must therefore never wait on such a stream: see
14126+
* cli_stdin_allowed_for_schema for which schemas that covers. */
1411414127
const char *schema_str = cbm_mcp_tool_input_schema(tool_name);
1411514128
if (!schema_str) {
1411614129
/* Unknown tool: dispatch rejects it by name and no stdin content can
1411714130
* change that verdict, so do not block for input we would discard. */
1411814131
return false;
1411914132
}
1412014133

14121-
return cli_stdin_allowed_for_schema(schema_str);
14134+
return cli_stdin_allowed_for_schema(schema_str, stdin_kind);
1412214135
}
1412314136

14124-
/* Schema→decision core of the stdin gate, split out so the zero-argument
14125-
* branch stays TESTED even when no shipped tool is zero-argument anymore:
14126-
* #1181 gave list_projects pagination parameters, retiring the last
14127-
* empty-properties schema, and the #1359 regression tests had leaned on it
14128-
* as their live example. The product contract is unchanged — a schema that
14129-
* declares no properties means stdin has nothing to carry and the read is
14130-
* pure deadlock; the tests now exercise this path directly. */
14131-
static bool cli_stdin_allowed_for_schema(const char *schema_str) {
14137+
/* Schema→decision core of the stdin gate, split out so every schema shape
14138+
* stays TESTED whatever the shipped tool table holds.
14139+
* no properties -> never: stdin cannot carry anything the tool accepts.
14140+
* optional only -> a regular file only. The bare call is already complete
14141+
* with `{}`, so blocking on a stream for optional extras
14142+
* is the #1359 deadlock again — #1181 gave list_projects
14143+
* paging properties and re-opened exactly that hang for
14144+
* every caller that leaves an inherited pipe open (ssh,
14145+
* Node spawn). A `< args.json` file always reaches EOF.
14146+
* any required -> file or stream: the bare call cannot succeed anyway, so
14147+
* the documented `echo '<json>' | cli <tool>` channel is
14148+
* the only way this invocation can carry its arguments. */
14149+
static bool cli_stdin_allowed_for_schema(const char *schema_str, cbm_cli_stdin_kind_t stdin_kind) {
14150+
if (stdin_kind == CBM_CLI_STDIN_TERMINAL) {
14151+
return false;
14152+
}
1413214153
yyjson_doc *schema_doc = yyjson_read(schema_str, strlen(schema_str), 0);
1413314154
if (!schema_doc) {
1413414155
/* The schemas are compile-time constants, so a parse failure means a
1413514156
* malformed literal rather than caller input. Keep the documented stdin
1413614157
* channel rather than silently narrowing it on a schema we could not
14137-
* read — the gate must only ever fire on positive evidence that the
14138-
* tool takes no arguments. */
14158+
* read — the gate must only ever fire on positive evidence about the
14159+
* tool's arguments. */
1413914160
return true;
1414014161
}
14141-
yyjson_val *props = yyjson_obj_get(yyjson_doc_get_root(schema_doc), "properties");
14162+
yyjson_val *root = yyjson_doc_get_root(schema_doc);
14163+
yyjson_val *props = yyjson_obj_get(root, "properties");
14164+
yyjson_val *required = yyjson_obj_get(root, "required");
1414214165
bool accepts_arguments = props && yyjson_is_obj(props) && yyjson_obj_size(props) > 0;
14166+
bool requires_arguments = required && yyjson_is_arr(required) && yyjson_arr_size(required) > 0;
1414314167
yyjson_doc_free(schema_doc);
14144-
return accepts_arguments;
14168+
if (!accepts_arguments) {
14169+
return false;
14170+
}
14171+
return requires_arguments || stdin_kind == CBM_CLI_STDIN_FILE;
1414514172
}
1414614173

1414714174
#ifdef CBM_CLI_ENABLE_TEST_API
14148-
bool cbm_cli_stdin_allowed_for_schema_for_test(const char *schema_str) {
14149-
return cli_stdin_allowed_for_schema(schema_str);
14175+
bool cbm_cli_stdin_allowed_for_schema_for_test(const char *schema_str,
14176+
cbm_cli_stdin_kind_t stdin_kind) {
14177+
return cli_stdin_allowed_for_schema(schema_str, stdin_kind);
1415014178
}
1415114179
#endif
1415214180

‎src/cli/cli.h‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,24 @@ const char *cbm_cli_get_version(void);
4848
* must free. Caller frees the returned JSON string. */
4949
char *cbm_cli_build_args_json(const char *tool_name, int argc, char **argv, char **err_out);
5050

51+
/* What a descriptor is, as far as the stdin argument gate cares. */
52+
typedef enum {
53+
CBM_CLI_STDIN_TERMINAL = 0, /* interactive: nothing piped to read */
54+
CBM_CLI_STDIN_FILE = 1, /* regular file (`< args.json`): always reaches EOF */
55+
CBM_CLI_STDIN_STREAM = 2, /* pipe, socket, device: EOF only when the writer closes */
56+
} cbm_cli_stdin_kind_t;
57+
58+
/* Classify an open descriptor: terminal (isatty), regular file (fstat), else stream. */
59+
cbm_cli_stdin_kind_t cbm_cli_stdin_kind(int fd);
60+
5161
/* Decide whether `cli <tool>` may take its JSON arguments from stdin, given
5262
* that no --args-file, raw-JSON positional and no --flag form was supplied.
53-
* stdin_is_tty is the caller's isatty(0) result. True only for a non-terminal
54-
* stdin AND a tool whose input_schema declares at least one property; a
55-
* zero-argument tool such as list_projects must never read stdin, because an
56-
* inherited-but-never-closed pipe makes that read block forever (#1359). */
57-
bool cbm_cli_args_from_stdin_allowed(const char *tool_name, bool stdin_is_tty);
63+
* Never from a terminal, never for a tool whose input_schema declares no
64+
* properties. A regular file is read for any tool that takes arguments; a
65+
* stream only for a tool with a REQUIRED argument. A tool callable without
66+
* arguments (list_projects) is complete with `{}`, and an inherited stream
67+
* nobody closes would block that read forever (#1359). */
68+
bool cbm_cli_args_from_stdin_allowed(const char *tool_name, cbm_cli_stdin_kind_t stdin_kind);
5869

5970
/* Print per-tool help (usage + the tool's flags with type/description/required)
6071
* derived from its input_schema, to stdout. Returns 0 if the tool is known,

‎src/main.c‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -974,9 +974,9 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo
974974
return SKIP_ONE;
975975
}
976976
args_json = heap_args;
977-
} else if (cbm_cli_args_from_stdin_allowed(tool_name, cli_isatty(0) != 0)) {
977+
} else if (cbm_cli_args_from_stdin_allowed(tool_name, cbm_cli_stdin_kind(0))) {
978978
/* piped stdin (UTF-8 clean, no shell quoting): cli <tool> < args.json.
979-
* Gated (#1359): a tool that declares no arguments must not read a pipe
979+
* Gated (#1359): a call complete without arguments must not read a pipe
980980
* nobody is going to write to or close — see the WHY on the predicate. */
981981
heap_args = cli_slurp_stream(stdin);
982982
if (heap_args && heap_args[0]) {

‎tests/test_cli.c‎

Lines changed: 78 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
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);
7475
bool cbm_cli_activation_test_ops_installed(void);
7576
int 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. */
1736017406
TEST(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

Comments
 (0)