Skip to content

Commit 51c48e2

Browse files
authored
Merge pull request #1858 from DeusData/fix/issue-1834-pi-stdin-transport
fix(pi): send tool arguments over stdin
2 parents ef8a8dc + ee32816 commit 51c48e2

4 files changed

Lines changed: 141 additions & 13 deletions

File tree

scripts/smoke-test.sh

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -838,15 +838,16 @@ else
838838
echo; echo "-- B3 first bytes (od) --"; echo "$IM_ARR" | od -c | head -6; exit 1
839839
fi
840840

841-
# B4: STDIN — piped JSON resolves; this path must NOT emit a deprecation warning.
842-
IM_STDIN=$(echo "{\"project\":\"$PROJECT\"}" | "$BINARY" cli get_graph_schema 2>"$CLI_STDERR")
843-
if ! echo "$IM_STDIN" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); sys.exit(0 if 'node_labels' in d else 1)" 2>/dev/null; then
844-
echo "FAIL B4: stdin get_graph_schema did not resolve"; echo "$IM_STDIN" | head -c 300; cat "$CLI_STDERR"; exit 1
841+
# B4: STDIN + --json is the generated-client transport. It must return the
842+
# complete MCP result envelope and must NOT emit a deprecation warning.
843+
IM_STDIN=$(printf '%s' "{\"project\":\"$PROJECT\"}" | "$BINARY" cli --json get_graph_schema 2>"$CLI_STDERR")
844+
if ! printf '%s' "$IM_STDIN" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); c=d.get('content'); p=json.loads(c[0].get('text','')) if isinstance(c,list) and c and c[0].get('type') == 'text' else None; sys.exit(0 if d.get('isError') is not True and isinstance(p,dict) and isinstance(p.get('node_labels'),list) else 1)" 2>/dev/null; then
845+
echo "FAIL B4: compact stdin + --json did not return a successful get_graph_schema MCP payload"; echo "$IM_STDIN" | head -c 300; cat "$CLI_STDERR"; exit 1
845846
fi
846847
if grep -qi 'deprecated' "$CLI_STDERR"; then
847848
echo "FAIL B4: stdin path wrongly emitted a deprecation warning"; cat "$CLI_STDERR"; exit 1
848849
fi
849-
echo "OK B4: STDIN input resolves, no deprecation warning"
850+
echo "OK B4: compact STDIN + --json returns a successful schema MCP envelope, no deprecation warning"
850851

851852
# B5: --args-file — JSON read from a file resolves; must NOT warn deprecated.
852853
IM_ARGS_FILE=$(smoke_mktemp_file)
@@ -890,7 +891,8 @@ else
890891
echo "FAIL B6c: 'notatool --help' did not report 'unknown tool'"; cat "$CLI_STDERR"; exit 1
891892
fi
892893

893-
# B7: DEPRECATION guard — one raw-JSON call MUST warn on stderr; flag form must NOT.
894+
# B7: DEPRECATION control — positional raw JSON MUST warn on stderr; this is
895+
# retained only to prove B4 is exercising the non-deprecated stdin transport.
894896
cli search_graph "{\"project\":\"$PROJECT\",\"name_pattern\":\"compute\"}" >/dev/null || true
895897
if grep -qi 'deprecated' "$CLI_STDERR"; then
896898
echo "OK B7a: raw-JSON cli emits deprecation warning on stderr"
@@ -2303,16 +2305,96 @@ if ! path_match "$CMD" "$SELF_PATH" ||
23032305
fi
23042306
echo "OK 8ak: custom KIMI_CODE_HOME MCP + durable context + UserPromptSubmit hook"
23052307

2306-
# 8al: Pi has documented instructions and skill, but no invented MCP config.
2308+
# 8al: Pi has documented instructions and skill, no invented MCP config, and
2309+
# an installed generated extension that uses the non-deprecated stdin bridge.
23072310
PI_INSTRUCTIONS="$FAKE_HOME/.pi/agent/AGENTS.md"
23082311
PI_SKILL="$FAKE_HOME/.pi/agent/skills/codebase-memory/SKILL.md"
2312+
PI_EXTENSION="$FAKE_HOME/.pi/agent/extensions/cbmem.ts"
23092313
if ! grep -q 'search_graph' "$PI_INSTRUCTIONS" 2>/dev/null ||
23102314
! grep -q 'Sessions and Subagents' "$PI_SKILL" 2>/dev/null ||
2315+
! grep -Fq "spawn(BIN, ['cli', '--json', tool], {" "$PI_EXTENSION" 2>/dev/null ||
2316+
! grep -Fq "stdio: ['pipe', 'pipe', 'pipe']" "$PI_EXTENSION" 2>/dev/null ||
2317+
! grep -Fq "child.stdin.on('error'" "$PI_EXTENSION" 2>/dev/null ||
2318+
! grep -Fq 'child.stdin.end(JSON.stringify(args ?? {}));' "$PI_EXTENSION" 2>/dev/null ||
2319+
grep -Fq 'tool, JSON.stringify(args ?? {})]' "$PI_EXTENSION" 2>/dev/null ||
2320+
grep -Fq "stdio: ['ignore', 'pipe', 'pipe']" "$PI_EXTENSION" 2>/dev/null ||
23112321
[ -e "$FAKE_HOME/.pi/agent/mcp.json" ]; then
2312-
echo "FAIL 8al: Pi durable context missing or unsupported MCP config created"
2322+
echo "FAIL 8al: Pi durable context or stdin client bridge missing, or unsupported MCP config created"
23132323
exit 1
23142324
fi
2315-
echo "OK 8al: Pi durable context only (no MCP config)"
2325+
echo "OK 8al: Pi durable context + stdin client bridge (no MCP config)"
2326+
2327+
# 8al-node: execute the generated extension against a child that exits without
2328+
# consuming a deliberately over-pipe-capacity payload. This is the lifecycle
2329+
# Node implements: without an stdin error listener, the late EPIPE is an
2330+
# unhandled EventEmitter error and crashes the Pi host. A second call emits a
2331+
# valid MCP envelope before the same early exit, proving parsed JSON remains
2332+
# authoritative over a retained stdin transport error.
2333+
#
2334+
# SKIP_WHITELIST: the minimal C-only Linux image intentionally has no Node.
2335+
# What was tried: making Node a universal core-suite prerequisite would widen
2336+
# the product's C build dependencies. The exact generated-source assertions
2337+
# above still run there; this live probe gates every Node-equipped smoke venue.
2338+
if command -v node >/dev/null 2>&1; then
2339+
PI_NODE=$(command -v node)
2340+
PI_PROBE_DIR="$TMPDIR/pi-node-probe"
2341+
mkdir -p "$PI_PROBE_DIR"
2342+
python3 - "$PI_EXTENSION" "$PI_PROBE_DIR/cbmem.mjs" <<'PYPIADAPTER'
2343+
import pathlib
2344+
import sys
2345+
2346+
source, destination = map(pathlib.Path, sys.argv[1:])
2347+
text = source.read_text(encoding="utf-8")
2348+
lines = text.splitlines()
2349+
matches = [i for i, line in enumerate(lines) if line.startswith("const BIN = ")]
2350+
if len(matches) != 1:
2351+
raise SystemExit("generated Pi extension has no unique BIN declaration")
2352+
lines[matches[0]] = "const BIN = process.execPath;"
2353+
destination.write_text("\n".join(lines) + "\n", encoding="utf-8")
2354+
PYPIADAPTER
2355+
cat >"$PI_PROBE_DIR/cli" <<'PICHILD'
2356+
const fs = require('node:fs');
2357+
if (process.argv[3] === 'get_graph_schema') {
2358+
fs.writeSync(1, JSON.stringify({ content: [{ type: 'text', text: 'schema-ok' }] }) + '\n');
2359+
}
2360+
process.exit(0);
2361+
PICHILD
2362+
if ! grep -Fxq 'const BIN = process.execPath;' "$PI_PROBE_DIR/cbmem.mjs" ||
2363+
! grep -Fxq "const fs = require('node:fs');" "$PI_PROBE_DIR/cli"; then
2364+
echo "FAIL 8al-node: generated lifecycle probe is not portable across Node launch environments"
2365+
exit 1
2366+
fi
2367+
cat >"$PI_PROBE_DIR/probe.mjs" <<'PIPROBE'
2368+
import extension from './cbmem.mjs';
2369+
2370+
const tools = [];
2371+
extension({ registerTool: (definition) => tools.push(definition) });
2372+
const byName = (name) => tools.find((tool) => tool.name === name);
2373+
const args = { padding: 'x'.repeat(16 * 1024 * 1024) };
2374+
2375+
const successful = await byName('get_graph_schema').execute('probe-ok', args);
2376+
if (successful?.content?.[0]?.text !== 'schema-ok') {
2377+
throw new Error('valid child JSON was not authoritative over stdin EPIPE');
2378+
}
2379+
2380+
let transportError = '';
2381+
try {
2382+
await byName('search_graph').execute('probe-error', args);
2383+
} catch (error) {
2384+
transportError = String(error?.message ?? error);
2385+
}
2386+
if (!/(EPIPE|broken pipe|write)/i.test(transportError)) {
2387+
throw new Error(`missing surfaced stdin transport error: ${transportError || '<none>'}`);
2388+
}
2389+
PIPROBE
2390+
if ! (cd "$PI_PROBE_DIR" && "$PI_NODE" probe.mjs); then
2391+
echo "FAIL 8al-node: generated Pi extension did not contain early-exit stdin errors"
2392+
exit 1
2393+
fi
2394+
echo "OK 8al-node: generated Pi extension contains EPIPE and keeps valid JSON authoritative"
2395+
else
2396+
echo "SKIP 8al-node: Node unavailable in this C-only smoke venue (whitelisted above)"
2397+
fi
23162398

23172399
# 8am: Warp receives the documented shared skill; MCP remains user/UI-managed.
23182400
WARP_SKILL="$FAKE_HOME/.agents/skills/codebase-memory/SKILL.md"

src/cli/client_adapter.c

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,14 +168,16 @@ char *cbm_client_adapter_pi(const char *binary_path) {
168168
sb_append(
169169
&sb, "async function call(tool, args, signal) {\n"
170170
" return new Promise((resolve) => {\n"
171-
" const child = spawn(BIN, ['cli', '--json', tool, JSON.stringify(args ?? {})], {\n"
172-
" stdio: ['ignore', 'pipe', 'pipe'],\n"
171+
" const child = spawn(BIN, ['cli', '--json', tool], {\n"
172+
" stdio: ['pipe', 'pipe', 'pipe'],\n"
173173
" env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n"
174174
" });\n"
175175
" let out = '';\n"
176+
" let stdinError;\n"
176177
" const onAbort = () => { if (!child.killed) child.kill(); };\n"
177178
" signal?.addEventListener('abort', onAbort, { once: true });\n"
178179
" child.stdout.on('data', (d) => (out += d.toString()));\n"
180+
" child.stdin.on('error', (e) => { stdinError = e; });\n"
179181
" child.on('error', (e) => {\n"
180182
" signal?.removeEventListener('abort', onAbort);\n"
181183
" resolve({ error: String(e && e.message ? e.message : e) });\n"
@@ -186,8 +188,12 @@ char *cbm_client_adapter_pi(const char *binary_path) {
186188
" for (let i = lines.length - 1; i >= 0; i--) {\n"
187189
" try { return resolve(JSON.parse(lines[i])); } catch { /* keep scanning */ }\n"
188190
" }\n"
191+
" if (stdinError) {\n"
192+
" return resolve({ error: String(stdinError.message || stdinError) });\n"
193+
" }\n"
189194
" resolve({ error: 'no JSON response from codebase-memory-mcp' });\n"
190195
" });\n"
196+
" child.stdin.end(JSON.stringify(args ?? {}));\n"
191197
" });\n"
192198
"}\n\n");
193199

src/cli/client_adapter.h

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@
3030
/* Emit a pi extension registering every tool in the MCP registry.
3131
*
3232
* pi has no MCP client, so this bridge is the only route to the graph for it.
33-
* Each registered tool shells out to `<binary> cli <tool> '<json-args>'`, which
34-
* is an existing public entry point rather than a private one.
33+
* Each registered tool shells out to `<binary> cli --json <tool>` and writes
34+
* its JSON arguments to stdin, using an existing public entry point without
35+
* the deprecated positional-JSON transport.
3536
*
3637
* `binary_path` is embedded as a JS string literal and is escaped; a Windows
3738
* path such as C:\Users\x\bin\cbm.exe must not corrupt the module or, worse,

tests/test_agent_clients.c

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,6 +1183,44 @@ TEST(client_adapter_pi_emits_parameters_and_execute) {
11831183
PASS();
11841184
}
11851185

1186+
/* #1834: raw JSON in argv selects the deprecated compatibility path. The Pi
1187+
* bridge must keep arguments out of process listings and feed the existing
1188+
* stdin transport only after all child handlers are attached. */
1189+
TEST(client_adapter_pi_sends_arguments_over_stdin_issue1834) {
1190+
char *js = cbm_client_adapter_pi("/usr/local/bin/codebase-memory-mcp");
1191+
ASSERT_NOT_NULL(js);
1192+
1193+
const char *spawn = strstr(js, "spawn(BIN, ['cli', '--json', tool], {");
1194+
const char *stdio = strstr(js, "stdio: ['pipe', 'pipe', 'pipe']");
1195+
const char *stdout_handler = strstr(js, "child.stdout.on('data'");
1196+
const char *stdin_error_handler = strstr(js, "child.stdin.on('error'");
1197+
const char *error_handler = strstr(js, "child.on('error'");
1198+
const char *close_handler = strstr(js, "child.on('close'");
1199+
const char *stdin_end = strstr(js, "child.stdin.end(JSON.stringify(args ?? {}));");
1200+
1201+
ASSERT_NOT_NULL(spawn);
1202+
ASSERT_NOT_NULL(stdio);
1203+
ASSERT_NOT_NULL(stdout_handler);
1204+
ASSERT_NOT_NULL(stdin_error_handler);
1205+
ASSERT_NOT_NULL(error_handler);
1206+
ASSERT_NOT_NULL(close_handler);
1207+
ASSERT_NOT_NULL(stdin_end);
1208+
ASSERT(stdout_handler < stdin_end);
1209+
ASSERT(stdin_error_handler < stdin_end);
1210+
ASSERT(error_handler < stdin_end);
1211+
ASSERT(close_handler < stdin_end);
1212+
ASSERT_NOT_NULL(strstr(js, "let stdinError;"));
1213+
ASSERT_NOT_NULL(strstr(js, "stdinError = e;"));
1214+
ASSERT_NOT_NULL(strstr(js, "if (stdinError)"));
1215+
1216+
/* These are the exact deprecated transport shapes being retired. */
1217+
ASSERT_NULL(strstr(js, "tool, JSON.stringify(args ?? {})]"));
1218+
ASSERT_NULL(strstr(js, "stdio: ['ignore', 'pipe', 'pipe']"));
1219+
1220+
free(js);
1221+
PASS();
1222+
}
1223+
11861224
/* Result wrap + abort: Pi's TUI reads result.content; a spawn/parse failure
11871225
* must throw rather than return a result without content; AbortSignal must
11881226
* kill the cli child. String-shape only; a live Pi process is not gated. */
@@ -1294,6 +1332,7 @@ SUITE(agent_clients) {
12941332
RUN_TEST(client_adapter_pi_registers_every_registry_tool);
12951333
RUN_TEST(client_adapter_pi_default_exports_its_factory_issue1550);
12961334
RUN_TEST(client_adapter_pi_emits_parameters_and_execute);
1335+
RUN_TEST(client_adapter_pi_sends_arguments_over_stdin_issue1834);
12971336
RUN_TEST(client_adapter_pi_wraps_result_and_honors_abort);
12981337
RUN_TEST(client_adapter_escapes_windows_paths_and_quotes);
12991338
RUN_TEST(client_adapter_opencode_sends_the_required_hook_event);

0 commit comments

Comments
 (0)