diff --git a/.github/scripts/check-packaged-agent-proof.py b/.github/scripts/check-packaged-agent-proof.py index f941180d..a120508f 100644 --- a/.github/scripts/check-packaged-agent-proof.py +++ b/.github/scripts/check-packaged-agent-proof.py @@ -678,48 +678,125 @@ def stdio_status_command( stderr_thread.start() responses: list[dict] = [] process_terminated = False - try: - for request in requests: - entry = {"request": request} - transcript.append(entry) - process.stdin.write(json.dumps(request) + "\n") - process.stdin.flush() + failure_pending = False + failure_extra: dict | None = None + + def stdio_fail(message: str, extra: dict | None = None) -> None: + nonlocal failure_pending, failure_extra + failure_pending = True + failure_extra = extra + fail(layer, artifact, message) + + def read_correlated_response(expected_id: str | int, entry: dict, phase: str) -> dict: + deadline = time.monotonic() + timeout_secs + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + entry["timed_out"] = True + stdio_fail(f"stdio MCP {phase} timed out after {timeout_secs}s", {"timed_out": True}) try: - line = read_stdio_line(stdout_queue, timeout_secs) + line = read_stdio_line(stdout_queue, remaining) except subprocess.TimeoutExpired: entry["timed_out"] = True - terminate_process_tree(process) - process_terminated = True - stderr_path.write_text("".join(stderr_lines), encoding="utf-8") - write_stdio_artifact( - artifact, - transcript, - "".join(stdout_lines), - stderr_path, - {"timed_out": True, "timeout_secs": timeout_secs}, - ) - fail(layer, artifact, f"stdio MCP request timed out after {timeout_secs}s") + stdio_fail(f"stdio MCP {phase} timed out after {timeout_secs}s", {"timed_out": True}) if line is None: - terminate_process_tree(process) - process_terminated = True - stderr_path.write_text("".join(stderr_lines), encoding="utf-8") - write_stdio_artifact(artifact, transcript, "".join(stdout_lines), stderr_path) - fail(layer, artifact, "stdio MCP server closed before responding") + stdio_fail(f"stdio MCP server closed during {phase}") try: response = json.loads(line) except json.JSONDecodeError as exc: entry["invalid_line"] = line - terminate_process_tree(process) - process_terminated = True - stderr_path.write_text("".join(stderr_lines), encoding="utf-8") - write_stdio_artifact( - artifact, - transcript, - "".join(stdout_lines), - stderr_path, - {"invalid_line": line}, + stdio_fail(f"stdio MCP {phase} emitted invalid JSON: {exc}", {"invalid_line": line}) + if not isinstance(response, dict): + entry["invalid_response"] = response + stdio_fail(f"stdio MCP {phase} emitted a non-object response", {"invalid_response": response}) + if response.get("jsonrpc") != "2.0": + entry["invalid_response"] = response + stdio_fail(f"stdio MCP {phase} response has invalid jsonrpc envelope", {"invalid_response": response}) + if "method" in response: + method = response.get("method") + if isinstance(method, str) and method.startswith("notifications/") and "id" not in response: + transcript.append({"notification": response}) + continue + entry["unexpected_server_message"] = response + stdio_fail(f"stdio MCP {phase} emitted an unexpected server request", {"unexpected_server_message": response}) + if response.get("id") != expected_id: + entry["unexpected_response"] = response + stdio_fail( + f"stdio MCP {phase} returned id {response.get('id')!r}, expected {expected_id!r}", + {"unexpected_response": response}, ) - fail(layer, artifact, f"stdio MCP server emitted invalid JSON: {exc}") + if ("result" in response) == ("error" in response): + entry["invalid_response"] = response + stdio_fail(f"stdio MCP {phase} response must contain exactly one of result or error", {"invalid_response": response}) + return response + + try: + initialize = { + "jsonrpc": "2.0", + "id": "initialize", + "method": "initialize", + "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "packaged-proof", "version": "1"}}, + } + initialize_entry = {"request": initialize} + transcript.append(initialize_entry) + process.stdin.write(json.dumps(initialize) + "\n") + process.stdin.flush() + initialize_response = read_correlated_response("initialize", initialize_entry, "initialize") + initialize_entry["response"] = initialize_response + responses.append(initialize_response) + initialize_result = initialize_response.get("result") + if "error" in initialize_response: + stdio_fail(f"stdio MCP initialize failed: {initialize_response.get('error')}") + if not isinstance(initialize_result, dict): + stdio_fail("stdio MCP initialize missing result") + if initialize_result.get("protocolVersion") != initialize["params"]["protocolVersion"]: + stdio_fail(f"stdio MCP initialize protocol is {initialize_result.get('protocolVersion')!r}") + if not isinstance(initialize_result.get("capabilities"), dict): + stdio_fail("stdio MCP initialize missing capabilities") + if not isinstance(initialize_result["capabilities"].get("tools"), dict): + stdio_fail("stdio MCP initialize capabilities.tools must be an object") + initialized = {"jsonrpc": "2.0", "method": "notifications/initialized"} + transcript.append({"request": initialized}) + process.stdin.write(json.dumps(initialized) + "\n") + process.stdin.flush() + + list_changed = initialize_result.get("capabilities", {}).get("tools", {}).get("listChanged") + if list_changed is True: + publication_deadline = time.monotonic() + timeout_secs + while True: + remaining = publication_deadline - time.monotonic() + if remaining <= 0: + stdio_fail(f"stdio MCP runtime publication timed out after {timeout_secs}s", {"timed_out": True}) + try: + notification_line = read_stdio_line(stdout_queue, remaining) + except subprocess.TimeoutExpired: + stdio_fail(f"stdio MCP runtime publication timed out after {timeout_secs}s", {"timed_out": True}) + if notification_line is None: + stdio_fail("stdio MCP server closed before runtime publication") + try: + notification = json.loads(notification_line) + except json.JSONDecodeError as exc: + stdio_fail(f"stdio MCP runtime publication emitted invalid JSON: {exc}", {"invalid_line": notification_line}) + if not isinstance(notification, dict): + stdio_fail("stdio MCP runtime publication emitted a non-object message", {"invalid_response": notification}) + method = notification.get("method") + if ( + notification.get("jsonrpc") != "2.0" + or "id" in notification + or not isinstance(method, str) + or not method.startswith("notifications/") + ): + stdio_fail("stdio MCP runtime publication emitted an unexpected message", {"unexpected_response": notification}) + transcript.append({"notification": notification}) + if method == "notifications/tools/list_changed": + break + + for request in requests: + entry = {"request": request} + transcript.append(entry) + process.stdin.write(json.dumps(request) + "\n") + process.stdin.flush() + response = read_correlated_response(request["id"], entry, f"request {request['id']}") entry["response"] = response responses.append(response) finally: @@ -750,8 +827,11 @@ def stdio_status_command( if process.poll() is None: process.kill() process.wait(timeout=2) - stdout_thread.join(timeout=0.2) - stderr_thread.join(timeout=0.2) + stdout_thread.join(timeout=2) + stderr_thread.join(timeout=2) + if failure_pending: + stderr_path.write_text("".join(stderr_lines), encoding="utf-8") + write_stdio_artifact(artifact, transcript, "".join(stdout_lines), stderr_path, failure_extra) stderr_path.write_text("".join(stderr_lines), encoding="utf-8") responses_by_id = {response.get("id"): response for response in responses if isinstance(response, dict)} @@ -1503,7 +1583,25 @@ def emit(value): if fail == "serve_timeout": time.sleep(60) continue - if request.get("method") == "tools/list": + if request.get("method") == "notifications/initialized": + if os.environ.get("CODESTORY_FAKE_LIST_CHANGED") == "1": + for method in ( + "notifications/tools/list_changed", + "notifications/resources/list_changed", + "notifications/prompts/list_changed", + ): + print(json.dumps({"jsonrpc": "2.0", "method": method}), flush=True) + continue + if request.get("method") == "initialize": + result = { + "protocolVersion": request.get("params", {}).get("protocolVersion", "2024-11-05"), + "capabilities": { + "tools": {"listChanged": os.environ.get("CODESTORY_FAKE_LIST_CHANGED") == "1"}, + "resources": {"listChanged": False}, + }, + "serverInfo": {"name": "codestory", "version": "9.9.9"}, + } + elif request.get("method") == "tools/list": result = {"tools": [{"name": "packet"}, {"name": "search"}, {"name": "context"}, {"name": "sidecar_setup"}]} elif request.get("method") == "resources/list": resources = [{"uri": "codestory://status", "name": "CodeStory runtime status"}] @@ -1578,7 +1676,30 @@ def emit(value): }, } result = {"contents": [{"uri": "codestory://status", "mimeType": "application/json", "text": json.dumps(status)}]} - print(json.dumps({"jsonrpc": "2.0", "id": request.get("id"), "result": result}), flush=True) + response_id = request.get("id") + initialize_mode = os.environ.get("CODESTORY_FAKE_INITIALIZE_MODE") + if request.get("method") == "initialize" and initialize_mode == "non_object": + print(json.dumps([]), flush=True) + continue + if request.get("method") == "initialize" and initialize_mode == "wrong_id": + response_id = "wrong-initialize" + if request.get("method") == "initialize" and initialize_mode == "error": + print(json.dumps({"jsonrpc": "2.0", "id": response_id, "error": {"code": -32000, "message": "synthetic initialize error"}}), flush=True) + continue + if request.get("method") == "initialize" and initialize_mode == "malformed_tools": + result["capabilities"]["tools"] = [] + if request.get("method") == "initialize" and initialize_mode == "malformed_jsonrpc": + print("synthetic protocol stderr", file=sys.stderr, flush=True) + if request.get("method") == "tools/list" and initialize_mode == "out_of_order": + response_id = "resources" + if request.get("method") == "tools/list" and initialize_mode == "server_request_collision": + print(json.dumps({"jsonrpc": "2.0", "id": response_id, "method": "sampling/createMessage", "params": {}}), flush=True) + continue + if request.get("method") == "tools/list" and initialize_mode == "malformed_method": + print(json.dumps({"jsonrpc": "2.0", "method": 42}), flush=True) + continue + jsonrpc = "1.0" if request.get("method") == "initialize" and initialize_mode == "malformed_jsonrpc" else "2.0" + print(json.dumps({"jsonrpc": jsonrpc, "id": response_id, "result": result}), flush=True) else: raise SystemExit(f"unknown fake layer: {layer}") ''' @@ -1966,6 +2087,7 @@ def unclassified_cleanup(_path): stage / ("codestory-cli.cmd" if os.name == "nt" else "codestory-cli") ) os.environ["CODESTORY_FAKE_PLUGIN_MANAGED"] = "1" + os.environ["CODESTORY_FAKE_LIST_CHANGED"] = "1" try: run_gate(managed_args) managed_summary = read_json_file(Path(managed_args.out_dir) / "summary.json") @@ -1985,6 +2107,7 @@ def unclassified_cleanup(_path): assert managed_direct_status["cache_root"] != managed_cache_root finally: os.environ.pop("CODESTORY_FAKE_PLUGIN_MANAGED", None) + os.environ.pop("CODESTORY_FAKE_LIST_CHANGED", None) os.environ.pop("CODESTORY_FAKE_PLUGIN_CLI", None) policy_timeout_artifact = root / "policy-timeout" / "managed-plugin-handoff.json" @@ -2065,6 +2188,52 @@ def unclassified_cleanup(_path): finally: os.environ.pop("CODESTORY_FAKE_FAIL_LAYER", None) + fake_cli = stage / ("codestory-cli.cmd" if os.name == "nt" else "codestory-cli") + numeric_id_artifact = root / "stdio-numeric-id.json" + stdio_status_command( + [str(fake_cli), "serve", "--stdio", "--refresh", "none", "--project", str(project)], + numeric_id_artifact, + 2, + project, + extra_requests=[{"jsonrpc": "2.0", "id": 1, "method": "resources/list"}], + ) + numeric_id_payload = read_json_file(numeric_id_artifact) + assert any( + entry.get("request", {}).get("id") == 1 + and entry.get("response", {}).get("id") == 1 + for entry in numeric_id_payload["transcript"] + ) + + for mode in ( + "wrong_id", + "error", + "out_of_order", + "non_object", + "server_request_collision", + "malformed_jsonrpc", + "malformed_method", + "malformed_tools", + ): + protocol_artifact = root / f"stdio-{mode}.json" + try: + stdio_status( + fake_cli, + project, + protocol_artifact, + 2, + {**os.environ, "CODESTORY_FAKE_INITIALIZE_MODE": mode}, + ) + except GateFailure as exc: + assert exc.layer == "serve_stdio" + assert exc.artifact == protocol_artifact + assert protocol_artifact.is_file() + protocol_stderr = protocol_artifact.with_suffix(protocol_artifact.suffix + ".stderr.txt") + assert protocol_stderr.is_file() + if mode == "malformed_jsonrpc": + assert "synthetic protocol stderr" in protocol_stderr.read_text(encoding="utf-8") + else: + raise AssertionError(f"{mode} stdio response must fail correlation") + os.environ["CODESTORY_FAKE_FAIL_LAYER"] = "serve_timeout" timeout_args = argparse.Namespace(**vars(args)) timeout_args.timeout_secs = 5 diff --git a/CHANGELOG.md b/CHANGELOG.md index b072aced..b058a3c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### Fixed +- Opened the diagnostic MCP immediately on fresh managed-plugin startup while + the existing single-flight installer provisions the exact CLI version in the + background. Status now reports the in-process provisioning state, and the + launcher hands the next request to the verified stdio runtime without a host + restart. - Made sidecar generation GC root every current and rollback manifest across the shared cache scope instead of collapsing rollback evidence to one latest generation. Inventory now reports active, rollback, building, and reclaimable diff --git a/docs/users/codex.md b/docs/users/codex.md index 47a76aac..9a4ef80b 100644 --- a/docs/users/codex.md +++ b/docs/users/codex.md @@ -49,8 +49,11 @@ There are three separate steps: 2. `/plugins` refresh or, on Windows, `codex.cmd plugin add codestory@TheGreenCedar` updates the installed plugin package. -3. A fresh Codex host session starts the new MCP adapter and lets it provision - the matching managed CLI. The adapter itself is projectless; tool requests +3. A fresh Codex host session starts the new MCP adapter. When the matching + managed CLI is missing, diagnostic status is available immediately while + the existing single-flight installer provisions it in the background; the + next request after verification is handed to the real runtime without a + host restart. The adapter itself is projectless; tool requests are routed by their required `project` argument, so other Codex tasks can use other repositories at the same time. diff --git a/plugins/codestory/README.md b/plugins/codestory/README.md index e04f0d82..99af93f7 100644 --- a/plugins/codestory/README.md +++ b/plugins/codestory/README.md @@ -27,7 +27,10 @@ The adapter prefers a checksummed plugin-managed CLI and starts one projectless MCP runtime. Every tool call carries its repository root, so concurrent Codex tasks can use different projects without rebinding or restarting the server. It can provision from GitHub release `SHA256SUMS.txt`, honor `CODESTORY_CLI` as a local-dev override, -and stay up with diagnostic `codestory://status` when managed setup fails. +and open diagnostic `codestory://status` immediately while a missing exact +version is provisioned in the background. The next request after verified +publication is handed to the real stdio runtime; terminal setup failures remain +available through the same diagnostic MCP. Ambient `PATH` binaries are reported as diagnostics only; installed plugin runtime does not launch them. diff --git a/plugins/codestory/scripts/codestory-mcp.cjs b/plugins/codestory/scripts/codestory-mcp.cjs index 04d7c9cb..338d6eb7 100644 --- a/plugins/codestory/scripts/codestory-mcp.cjs +++ b/plugins/codestory/scripts/codestory-mcp.cjs @@ -1225,6 +1225,19 @@ function acquireManagedCliLock(root, purpose, waitMs = 0, options = {}) { } } +async function acquireManagedCliLockAsync(root, purpose, waitMs) { + const deadline = Date.now() + waitMs; + let waited = false; + while (true) { + const lock = acquireManagedCliLock(root, purpose, 0); + if (lock) return { ...lock, waited: waited || lock.waited }; + waited = true; + const remaining = deadline - Date.now(); + if (remaining <= 0) return null; + await sleep(Math.min(50, remaining)); + } +} + function managedCliFailureCode(error) { return String(error?.message || error || 'unknown_failure').match(/^[a-z0-9_]+/iu)?.[0] || 'unknown_failure'; } @@ -1455,7 +1468,7 @@ async function provisionManagedCli(dataDir, version, warnings = []) { const root = managedCliRoot(dataDir, true); const versionDir = path.join(root, version); - const lock = acquireManagedCliLock(root, `provision:${version}`, managedCliLockWaitMs); + const lock = await acquireManagedCliLockAsync(root, `provision:${version}`, managedCliLockWaitMs); if (!lock) throw new Error('managed_cli_publish_locked'); if (lock.waited) warnings.push('managed_cli_publication:waiter'); if (lock.reclaimed) warnings.push('managed_cli_publication:reclaimed_lock'); @@ -1533,7 +1546,7 @@ async function provisionManagedCli(dataDir, version, warnings = []) { } } -async function resolveManagedCli(dataDir, version, warnings) { +async function resolveManagedCli(dataDir, version, warnings, options = {}) { if (!dataDir || !version) return null; try { managedCliRoot(dataDir); @@ -1546,6 +1559,7 @@ async function resolveManagedCli(dataDir, version, warnings) { const existing = verifyPublishedManagedCli(versionDir, version); if (existing.verified) return existing.resolved; } + if (options.provision === false) return null; try { return await provisionManagedCli(dataDir, version, warnings); } catch (error) { @@ -1556,7 +1570,7 @@ async function resolveManagedCli(dataDir, version, warnings) { return null; } -async function resolveCli() { +async function resolveCli(options = {}) { const version = pluginVersion(); const warnings = []; if (process.env.CODESTORY_CLI) { @@ -1578,7 +1592,7 @@ async function resolveCli() { }; } - const managed = await resolveManagedCli(pluginDataDir(), version, warnings); + const managed = await resolveManagedCli(pluginDataDir(), version, warnings, options); if (managed && managed.warning) warnings.push(managed.warning); if (managed && managed.path) { return { source: 'managed', version, warnings, ...managed }; @@ -2315,6 +2329,7 @@ function pluginRuntimeForResolved(resolved) { function fallbackDiagnostic(resolved, probe, reason, options = {}) { const projectRoot = Object.hasOwn(options, 'projectRoot') ? options.projectRoot : launchCwd; + const managedProvisioning = reason === 'managed_cli_provisioning'; const managedProvisionFailed = String(reason || '').startsWith('managed_cli_provision_failed:'); const managedProvisionNext = [ 'Restart/reload the Codex host/app and read codestory://status; managed CLI provisioning will retry release asset downloads.', @@ -2462,8 +2477,10 @@ function fallbackDiagnostic(resolved, probe, reason, options = {}) { hasReadinessBroker: true, }), runtime_boundary: { - restart_required_for_runtime_change: true, - message: 'A running MCP server keeps using the CLI process it was launched with; plugin refresh, managed runtime provisioning, or CODESTORY_CLI changes require a host reload/restart and fresh codestory://status readback.', + restart_required_for_runtime_change: !managedProvisioning, + message: managedProvisioning + ? 'Managed CLI provisioning is running in this launcher; the next request after verified publication hands off to the real stdio runtime.' + : 'A running MCP server keeps using the CLI process it was launched with; plugin refresh or CODESTORY_CLI changes require a host reload/restart and fresh codestory://status readback.', }, warnings: plugin.warnings, project_root: projectRoot, @@ -2769,24 +2786,94 @@ function failOpenSidecarSetupResult(request, status = null) { function runFailOpenMcp(status, options = {}) { const currentStatus = () => (typeof status === 'function' ? status() : status); let handoff = null; + let initializeRequest = null; + let initializedNotification = null; + let runtimeReadyNotified = false; + let stdinEnded = false; + const delegatedRequestIds = new Set(); + let handoffFailureHandled = false; + const notifyRuntimeReady = () => { + if (!initializedNotification || runtimeReadyNotified) return; + if ( + typeof options.shouldHandoff === 'function' && + !options.shouldHandoff(currentStatus()) + ) return; + runtimeReadyNotified = true; + process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' })}\n`); + process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/resources/list_changed' })}\n`); + process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/prompts/list_changed' })}\n`); + }; const maybeHandoff = () => { if (handoff || typeof options.startRuntime !== 'function') { return handoff; } const liveStatus = currentStatus(); - if (!liveStatus.project_root || liveStatus.degraded_reason !== 'project_root_recovered_after_launch') { + const shouldHandoff = typeof options.shouldHandoff === 'function' + ? options.shouldHandoff(liveStatus) + : liveStatus.project_root && liveStatus.degraded_reason === 'project_root_recovered_after_launch'; + if (!shouldHandoff) { return null; } handoff = options.startRuntime(liveStatus); - handoff.stdout?.pipe(process.stdout); + handoffFailureHandled = false; + const failHandoff = (reason, details = {}) => { + if (handoffFailureHandled) return; + handoffFailureHandled = true; + handoff = null; + if (typeof options.onRuntimeFailure !== 'function') { + process.exit(details.code || 1); + return; + } + for (const id of delegatedRequestIds) { + process.stdout.write(`${JSON.stringify(jsonrpcError(JSON.parse(id), -32000, reason))}\n`); + } + delegatedRequestIds.clear(); + options.onRuntimeFailure({ reason, ...details }); + }; + if (handoff.stdout) { + let stdout = ''; + let suppressInitialize = Boolean(initializeRequest); + handoff.stdout.setEncoding('utf8'); + handoff.stdout.on('data', (chunk) => { + stdout += chunk; + const lines = stdout.split(/\r?\n/u); + stdout = lines.pop() || ''; + for (const output of lines) { + if (!output) continue; + let parsed = null; + try { + parsed = JSON.parse(output); + } catch { + // Non-JSON output remains visible instead of hiding a runtime failure. + } + if (suppressInitialize && parsed?.id === initializeRequest.id) { + suppressInitialize = false; + continue; + } + if (parsed?.id !== undefined) delegatedRequestIds.delete(JSON.stringify(parsed.id)); + process.stdout.write(`${output}\n`); + } + }); + } handoff.stderr?.pipe(process.stderr); - handoff.on('exit', (code, signal) => { - if (signal) process.kill(process.pid, signal); - process.exit(code || 0); + handoff.on('close', (code, signal) => { + if (signal || code) { + failHandoff('CodeStory stdio handoff exited before completing the request.', { code, signal }); + return; + } + process.exit(0); }); handoff.on('error', (error) => { - process.stdout.write(`${JSON.stringify(jsonrpcError(null, -32000, `CodeStory stdio handoff failed: ${error.message}`))}\n`); + failHandoff(`CodeStory stdio handoff failed: ${error.message}`, { error }); }); + if (initializeRequest) { + handoff.stdin.write(`${JSON.stringify(initializeRequest)}\n`); + handoff.stdin.write(`${JSON.stringify(initializedNotification || { + jsonrpc: '2.0', + method: 'notifications/initialized', + })}\n`); + } + if (stdinEnded) handoff.stdin.end(); return handoff; }; const tools = [{ @@ -2841,9 +2928,22 @@ function runFailOpenMcp(status, options = {}) { process.stdout.write(`${JSON.stringify(jsonrpcError(null, -32700, 'Parse error'))}\n`); continue; } - const delegated = maybeHandoff(); + if (request.method === 'notifications/initialized') { + initializedNotification = request; + notifyRuntimeReady(); + continue; + } + if (request.method === 'initialize' && request.id !== undefined) { + initializeRequest = request; + } + const delegated = request.method === 'initialize' ? null : maybeHandoff(); if (delegated) { - delegated.stdin.write(`${line}\n`); + if (request.id !== undefined) delegatedRequestIds.add(JSON.stringify(request.id)); + try { + delegated.stdin.write(`${line}\n`); + } catch (error) { + process.stdout.write(`${JSON.stringify(jsonrpcError(request.id ?? null, -32000, `CodeStory stdio handoff failed: ${error.message}`))}\n`); + } continue; } if (request.id === undefined) continue; @@ -2852,13 +2952,19 @@ function runFailOpenMcp(status, options = {}) { const liveStatus = currentStatus(); response = jsonrpcResult(request.id, { protocolVersion: request.params?.protocolVersion || '2024-11-05', - capabilities: { tools: {}, resources: {} }, + capabilities: { + tools: { listChanged: true }, + resources: { subscribe: false, listChanged: true }, + prompts: { listChanged: true }, + }, serverInfo: { name: 'codestory', version: resolvedVersionForStatus(liveStatus) }, }); } else if (request.method === 'tools/list') { response = jsonrpcResult(request.id, { tools }); } else if (request.method === 'resources/list') { response = jsonrpcResult(request.id, { resources }); + } else if (request.method === 'prompts/list') { + response = jsonrpcResult(request.id, { prompts: [] }); } else if (request.method === 'resources/read') { const uri = request.params?.uri; if (uri === 'codestory://status') { @@ -2880,6 +2986,11 @@ function runFailOpenMcp(status, options = {}) { process.stdout.write(`${JSON.stringify(response)}\n`); } }); + process.stdin.on('end', () => { + stdinEnded = true; + handoff?.stdin.end(); + }); + return { notifyRuntimeReady }; } function resolvedVersionForStatus(status) { @@ -2964,7 +3075,65 @@ async function main() { if (await handleBootstrapStatusCommand(process.argv)) return; handleSidecarPolicyCommand(process.argv); const runtimeCwd = releasePluginCacheCwd(); - const resolved = await resolveCli(); + const installed = await resolveCli({ provision: false }); + if ( + installed.source === 'managed_unavailable' && + process.env.CODESTORY_PLUGIN_DISABLE_PROVISION !== '1' + ) { + let ready = null; + let diagnostic = null; + let status = fallbackDiagnostic(installed, probeResolvedCli(installed), 'managed_cli_provisioning', { + projectRoot: null, + projectRootSource: 'request_argument', + summary: 'CodeStory managed CLI provisioning is running in the background.', + minimumNext: ['Read codestory://status again while managed CLI provisioning continues.'], + fullRepair: ['Read codestory://status again while managed CLI provisioning continues.'], + }); + setImmediate(() => { + resolveCli().then((resolved) => { + const probe = probeResolvedCli(resolved); + const reason = failOpenReasonForProbe(resolved, probe); + resolved.managedCliRetention = managedCliRetentionReport(resolved, probe, { dryRun: Boolean(reason) }); + rememberLaunch(resolved, runtimeCwd); + if (reason) { + status = fallbackDiagnostic(resolved, probe, reason, { + projectRoot: null, + projectRootSource: 'request_argument', + }); + return; + } + ready = resolved; + diagnostic?.notifyRuntimeReady(); + }).catch((error) => { + status = fallbackDiagnostic(installed, probeResolvedCli(installed), `launcher_error:${error.message}`, { + projectRoot: null, + projectRootSource: 'request_argument', + }); + }); + }); + diagnostic = runFailOpenMcp(() => status, { + shouldHandoff: () => Boolean(ready), + startRuntime: () => spawnStdioRuntime(ready, runtimeCwd, ['pipe', 'pipe', 'pipe']), + onRuntimeFailure: (failure) => { + const failed = ready; + ready = null; + const reason = failure.error ? 'managed_cli_handoff_unspawnable' : 'runtime_stdio_child_exit'; + status = fallbackDiagnostic(failed, { + status: failure.code ?? null, + error: failure.error?.message || failure.reason, + version: failed.cliVersion || failed.version, + stdout: '', + stderr: '', + }, reason, { + projectRoot: null, + projectRootSource: 'request_argument', + summary: 'CodeStory managed CLI provisioning completed, but the stdio runtime failed during handoff.', + }); + }, + }); + return; + } + const resolved = installed; const probe = probeResolvedCli(resolved); const failOpenReason = failOpenReasonForProbe(resolved, probe); if (failOpenReason) { @@ -3059,6 +3228,7 @@ if (require.main === module) { quarantineManagedCliVersion, releaseManagedCliLock, resolveManagedCli, + runFailOpenMcp, managedCliRetentionReport, managedCliVersionEntries, removeManagedCliVersion, diff --git a/plugins/codestory/tests/plugin-static.test.mjs b/plugins/codestory/tests/plugin-static.test.mjs index a354ae18..4be9c64f 100644 --- a/plugins/codestory/tests/plugin-static.test.mjs +++ b/plugins/codestory/tests/plugin-static.test.mjs @@ -211,14 +211,14 @@ function fakeProbeChild(response, options = {}) { return child; } -async function writeReleaseFixture(releaseDir, version) { +async function writeReleaseFixture(releaseDir, version, writeCli = writeFakeCli) { const { archiveBase, archiveName } = releaseAssetForPlatform(version); const stageDir = join(releaseDir, archiveBase); const cliName = process.platform === "win32" ? "codestory-cli.cmd" : "codestory-cli"; const cliPath = join(stageDir, cliName); const archivePath = join(releaseDir, archiveName); await mkdir(stageDir, { recursive: true }); - await writeFakeCli(cliPath); + await writeCli(cliPath); await writeArchiveFixture(archivePath, `${archiveBase}/${cliName}`, await readFile(cliPath)); const archiveSha256 = createHash("sha256").update(await readFile(archivePath)).digest("hex"); const sumsPath = join(releaseDir, "SHA256SUMS.txt"); @@ -235,7 +235,18 @@ function spawnLauncher(launcher, env) { let stderr = ""; child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); - child.stdin.end(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "resources/read", params: { uri: "codestory://status" } })}\n`); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "resources/read", params: { uri: "codestory://status" } })}\n`); + const runtimeMetadata = env.PLUGIN_DATA && join(env.PLUGIN_DATA, ".codestory-mcp-runtime.json"); + const handoffPoll = runtimeMetadata && setInterval(() => { + try { + if (JSON.parse(fs.readFileSync(runtimeMetadata, "utf8")).source !== "managed") return; + clearInterval(handoffPoll); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" })}\n`); + } catch { + // Provisioning has not published runtime metadata yet. + } + }, 10); + child.once("close", () => clearInterval(handoffPoll)); const completed = once(child, "close").then(([status, signal]) => ({ status, signal, stdout, stderr })); return { child, completed }; } @@ -271,6 +282,24 @@ async function writeFakeCli(cliPath) { await chmod(cliPath, 0o755); } +async function writeLifecycleCli(cliPath) { + const script = [ + "const fs=require('fs');", + "const args=process.argv.slice(1);", + "if(args[0]==='--version'){console.log('codestory-cli '+process.env.TEST_CODESTORY_VERSION);process.exit(0)}", + "if(args[0]!=='serve')process.exit(2);", + "let initialized=false;let notified=false;let input='';", + "process.stdin.setEncoding('utf8');", + "process.stdin.on('data',chunk=>{input+=chunk;const lines=input.split(/\\r?\\n/u);input=lines.pop()||'';for(const line of lines){if(!line)continue;const request=JSON.parse(line);if(request.method==='initialize'){initialized=true;process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{protocolVersion:request.params.protocolVersion,capabilities:{tools:{listChanged:false},resources:{listChanged:false},prompts:{listChanged:false}},serverInfo:{name:'fixture',version:'1'}}})+'\\n')}else if(request.method==='notifications/initialized'){notified=true}else if(request.method==='tools/list'){if(!initialized||!notified)process.exit(42);fs.writeFileSync(process.env.TEST_OUT,JSON.stringify({initialized,notified,args}));process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{tools:[]}})+'\\n')}else if(request.method==='resources/list'){process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{resources:[]}})+'\\n',()=>process.exit(17))}}});", + ].join(""); + if (process.platform === "win32") { + await writeFile(cliPath, `@echo off\r\n"${process.execPath}" -e "${script}" -- %*\r\n`, "utf8"); + return; + } + await writeFile(cliPath, `#!/bin/sh\n${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)} -- "$@"\n`, "utf8"); + await chmod(cliPath, 0o755); +} + async function writeRecordingCli(cliPath) { const script = "const fs=require('fs');const args=process.argv.slice(1);if(args[0]==='--version'){console.log('codestory-cli '+(process.env.CODESTORY_PLUGIN_CLI_VERSION||process.env.TEST_CODESTORY_VERSION||'0.0.0'));process.exit(0)}if(args[0]==='ready'&&process.env.CODESTORY_PLUGIN_SIDECAR_REPAIR!=='1'&&args.includes('--wait-fresh')&&!args.includes('--repair')&&!args.includes('agent')){console.log(JSON.stringify({verdicts:[{goal:'local_navigation',status:'ready',summary:'ready',minimum_next:[],full_repair:[]}],local_refresh:{state:'fresh',reason:'already_fresh',blocks_local_surfaces:false,readiness_status:'ready',changed_file_count:0,new_file_count:0,removed_file_count:0,fatal_error_count:0}}));process.exit(0)}fs.appendFileSync(process.env.TEST_LOG,JSON.stringify({repair:process.env.CODESTORY_PLUGIN_SIDECAR_REPAIR==='1',policy:process.env.CODESTORY_PLUGIN_SIDECAR_POLICY_STATE,args})+'\\n')"; if (process.platform === "win32") { @@ -2681,7 +2710,6 @@ test("mcp launcher fails open when CODESTORY_CLI override cannot spawn", async ( }); test("mcp launcher fails open when managed cli probe fails", async () => { - const { spawnSync } = await import("node:child_process"); const version = await readPluginVersion(); const dataDir = await mkdtemp(join(tmpdir(), "codestory-failopen-managed-")); const cliDir = join(dataDir, "codestory-cli", version); @@ -2718,20 +2746,45 @@ test("mcp launcher fails open when managed cli probe fails", async () => { "utf8", ); - const result = spawnSync(process.execPath, [launcher], { + const child = spawn(process.execPath, [launcher], { env: { + ...process.env, PLUGIN_DATA: dataDir, CODESTORY_PLUGIN_RELEASE_DIR: join(dataDir, "missing-release"), PATH: "", ComSpec: process.env.ComSpec || process.env.COMSPEC || "", }, - input, - encoding: "utf8", - timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], }); - - assert.equal(result.status, 0, result.stderr); - const response = JSON.parse(result.stdout.trim()); + const completed = once(child, "close"); + let buffer = ""; + const responses = []; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + buffer += chunk; + const lines = buffer.split(/\r?\n/u); + buffer = lines.pop() || ""; + responses.push(...lines.filter(Boolean).map((line) => JSON.parse(line))); + }); + child.stdin.write(input); + const firstDeadline = Date.now() + 2000; + while (Date.now() < firstDeadline && responses.length === 0) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + const firstReason = JSON.parse(responses[0].result.contents[0].text).degraded_reason; + assert.equal([ + "managed_cli_provisioning", + "managed_cli_provision_failed:managed_cli_asset_fetch_failed", + ].includes(firstReason), true); + if (firstReason === "managed_cli_provisioning") { + await waitForPath(join(dataDir, ".codestory-mcp-runtime.json")); + child.stdin.end(input.replace('"status"', '"terminal"')); + } else { + child.stdin.end(); + } + const [exitCode] = await completed; + assert.equal(exitCode, 0); + const response = responses.find((entry) => entry.id === "terminal") || responses[0]; const status = JSON.parse(response.result.contents[0].text); assert.equal( status.readiness[0].repair_reason, @@ -2876,17 +2929,13 @@ test("mcp launcher provisions a checksummed release asset into plugin data", asy "utf8", ); - const result = spawnSync(process.execPath, [launcher], { - env: { - ...process.env, - CODESTORY_CLI: "", - CODESTORY_PLUGIN_RELEASE_DIR: releaseDir, - PLUGIN_DATA: dataDir, - TEST_OUT: outFile, - TEST_CODESTORY_VERSION: version, - }, - encoding: "utf8", + const launched = spawnLauncher(launcher, { + CODESTORY_PLUGIN_RELEASE_DIR: releaseDir, + PLUGIN_DATA: dataDir, + TEST_OUT: outFile, + TEST_CODESTORY_VERSION: version, }); + const result = await launched.completed; assert.equal(result.status, 0, result.stderr); const observed = JSON.parse(await readFile(outFile, "utf8")); @@ -2917,6 +2966,258 @@ test("mcp launcher provisions a checksummed release asset into plugin data", asy } }); +test("mcp launcher serves diagnostics while managed provisioning runs, then hands off", { timeout: 30000 }, async () => { + const { createServer } = await import("node:http"); + const version = await readPluginVersion(); + const dataDir = await mkdtemp(join(tmpdir(), "codestory-background-provision-")); + const releaseDir = await mkdtemp(join(tmpdir(), "codestory-background-release-")); + const launcher = join(pluginRoot, "scripts", "codestory-mcp.cjs"); + const outFile = join(dataDir, "runtime.json"); + let child; + let server; + let releaseAssets = () => {}; + try { + const fixture = await writeReleaseFixture(releaseDir, version, writeLifecycleCli); + const assets = new Map([ + ["/SHA256SUMS.txt", await readFile(fixture.sumsPath)], + [`/${fixture.archiveName}`, await readFile(fixture.archivePath)], + ]); + const assetGate = new Promise((resolve) => { releaseAssets = resolve; }); + server = createServer(async (request, response) => { + await assetGate; + const body = assets.get(request.url); + if (!body) return response.writeHead(404).end(); + response.writeHead(200).end(body); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + child = spawn(process.execPath, [launcher], { + env: { + ...process.env, + CODESTORY_CLI: "", + CODESTORY_PLUGIN_RELEASE_BASE_URL: `http://127.0.0.1:${server.address().port}`, + PLUGIN_DATA: dataDir, + TEST_CODESTORY_VERSION: version, + TEST_OUT: outFile, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + const completed = once(child, "close"); + let buffered = ""; + const responses = []; + const waiters = []; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + buffered += chunk; + const lines = buffered.split(/\r?\n/u); + buffered = lines.pop() || ""; + for (const line of lines.filter(Boolean)) { + const response = JSON.parse(line); + if (waiters.length) waiters.shift()(response); else responses.push(response); + } + }); + const nextResponse = () => responses.shift() || Promise.race([ + new Promise((resolve) => waiters.push(resolve)), + new Promise((_, reject) => setTimeout(() => reject(new Error("timed out waiting for diagnostic MCP")), 2000)), + ]); + const request = async (message) => { + child.stdin.write(`${JSON.stringify(message)}\n`); + return nextResponse(); + }; + + const initialized = await request({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2024-11-05" }, + }); + assert.equal(initialized.result.serverInfo.name, "codestory"); + assert.equal(initialized.result.capabilities.tools.listChanged, true); + assert.equal(initialized.result.capabilities.prompts.listChanged, true); + const statusResponse = await request({ + jsonrpc: "2.0", + id: 2, + method: "resources/read", + params: { uri: "codestory://status" }, + }); + const status = JSON.parse(statusResponse.result.contents[0].text); + assert.equal(status.degraded_reason, "managed_cli_provisioning"); + assert.equal(status.runtime_boundary.restart_required_for_runtime_change, false); + + releaseAssets(); + const runtimeMetadata = join(dataDir, ".codestory-mcp-runtime.json"); + await waitForPath(runtimeMetadata); + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + const metadata = JSON.parse(await readFile(runtimeMetadata, "utf8")); + if (metadata.source === "managed") break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(JSON.parse(await readFile(runtimeMetadata, "utf8")).source, "managed"); + assert.equal( + responses.some((response) => response.method === "notifications/tools/list_changed"), + false, + ); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`); + const notificationDeadline = Date.now() + 2000; + while ( + Date.now() < notificationDeadline && + !responses.some((response) => response.method === "notifications/tools/list_changed") + ) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal( + responses.some((response) => response.method === "notifications/tools/list_changed"), + true, + ); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/list" })}\n`); + let handedOff = null; + const handoffDeadline = Date.now() + 2000; + while (Date.now() < handoffDeadline && !handedOff) { + try { + handedOff = JSON.parse(await readFile(outFile, "utf8")); + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + assert.ok(handedOff); + assert.equal(handedOff.initialized, true); + assert.equal(handedOff.notified, true); + assert.deepEqual(handedOff.args, ["serve", "--stdio", "--multi-project", "--refresh", "none"]); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 4, method: "resources/list" })}\n`); + const failureDeadline = Date.now() + 2000; + while (Date.now() < failureDeadline && !responses.some((response) => response.id === 4)) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(responses.filter((response) => response.id === 4).length, 1); + assert.deepEqual(responses.find((response) => response.id === 4)?.result.resources, []); + await new Promise((resolve) => setTimeout(resolve, 100)); + child.stdin.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: 5, + method: "resources/read", + params: { uri: "codestory://status" }, + })}\n`); + const recoveryDeadline = Date.now() + 2000; + while (Date.now() < recoveryDeadline && !responses.some((response) => response.id === 5)) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + const recoveredStatus = responses.find((response) => response.id === 5); + assert.equal( + JSON.parse(recoveredStatus.result.contents[0].text).degraded_reason, + "runtime_stdio_child_exit", + ); + child.stdin.end(); + assert.equal((await completed)[0], 0); + child = null; + } finally { + releaseAssets(); + if (child) child.kill("SIGKILL"); + if (server) await new Promise((resolve) => server.close(resolve)); + await rm(dataDir, { recursive: true, force: true }); + await rm(releaseDir, { recursive: true, force: true }); + } +}); + +test("managed publication waiter keeps diagnostic MCP responsive", { timeout: 15000 }, async () => { + const { createServer } = await import("node:http"); + const version = await readPluginVersion(); + const dataDir = await mkdtemp(join(tmpdir(), "codestory-responsive-waiter-")); + const releaseDir = await mkdtemp(join(tmpdir(), "codestory-responsive-release-")); + const launcher = join(pluginRoot, "scripts", "codestory-mcp.cjs"); + let publisher; + let waiter; + let server; + let releaseAssets = () => {}; + try { + const fixture = await writeReleaseFixture(releaseDir, version); + const assets = new Map([ + ["/SHA256SUMS.txt", await readFile(fixture.sumsPath)], + [`/${fixture.archiveName}`, await readFile(fixture.archivePath)], + ]); + const gate = new Promise((resolve) => { releaseAssets = resolve; }); + server = createServer(async (request, response) => { + await gate; + response.writeHead(200).end(assets.get(request.url)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const env = { + ...process.env, + CODESTORY_CLI: "", + CODESTORY_PLUGIN_RELEASE_BASE_URL: `http://127.0.0.1:${server.address().port}`, + PLUGIN_DATA: dataDir, + TEST_CODESTORY_VERSION: version, + }; + publisher = spawn(process.execPath, [launcher], { env, stdio: ["pipe", "pipe", "pipe"] }); + publisher.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05" } })}\n`); + await waitForPath(join(dataDir, "codestory-cli", ".retention-lock", "owner.json")); + + waiter = spawn(process.execPath, [launcher], { env, stdio: ["pipe", "pipe", "pipe"] }); + let output = ""; + waiter.stdout.setEncoding("utf8"); + waiter.stdout.on("data", (chunk) => { output += chunk; }); + waiter.stdin.write([ + JSON.stringify({ jsonrpc: "2.0", id: 2, method: "initialize", params: { protocolVersion: "2024-11-05" } }), + JSON.stringify({ jsonrpc: "2.0", id: 3, method: "resources/read", params: { uri: "codestory://status" } }), + "", + ].join("\n")); + const deadline = Date.now() + 2000; + while (Date.now() < deadline && !output.split(/\r?\n/u).some((line) => { + if (!line) return false; + return JSON.parse(line).id === 3; + })) await new Promise((resolve) => setTimeout(resolve, 10)); + const statusResponse = output.split(/\r?\n/u).filter(Boolean) + .map((line) => JSON.parse(line)).find((response) => response.id === 3); + assert.ok(statusResponse, output); + assert.equal(JSON.parse(statusResponse.result.contents[0].text).degraded_reason, "managed_cli_provisioning"); + } finally { + releaseAssets(); + for (const child of [publisher, waiter]) { + if (child && child.exitCode === null) child.kill("SIGKILL"); + } + if (server) await new Promise((resolve) => server.close(resolve)); + await rm(dataDir, { recursive: true, force: true }); + await rm(releaseDir, { recursive: true, force: true }); + } +}); + +test("diagnostic handoff recovers a child spawn error", { timeout: 5000 }, async () => { + const launcher = join(pluginRoot, "scripts", "codestory-mcp.cjs"); + const fixture = [ + `const run=require(${JSON.stringify(launcher)})._test.runFailOpenMcp;`, + "const {EventEmitter}=require('node:events');", + "const {PassThrough}=require('node:stream');", + "let failed=false;", + "const status=()=>({plugin_runtime:{plugin_version:'test'},degraded_reason:failed?'managed_cli_handoff_unspawnable':'managed_cli_provisioning',recommended_next_calls:[]});", + "run(status,{shouldHandoff:()=>!failed,startRuntime:()=>{const child=new EventEmitter();child.stdin=new PassThrough();child.stdout=new PassThrough();child.stderr=new PassThrough();process.nextTick(()=>child.emit('error',new Error('synthetic spawn error')));return child},onRuntimeFailure:()=>{failed=true}});", + ].join(""); + const child = spawn(process.execPath, ["-e", fixture], { stdio: ["pipe", "pipe", "pipe"] }); + const completed = once(child, "close"); + let output = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { output += chunk; }); + child.stdin.write([ + JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05" } }), + JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), + JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }), + "", + ].join("\n")); + const errorDeadline = Date.now() + 2000; + while (Date.now() < errorDeadline && !output.split(/\r?\n/u).filter(Boolean) + .map((line) => JSON.parse(line)).some((response) => response.id === 2)) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + child.stdin.end(`${JSON.stringify({ + jsonrpc: "2.0", + id: 3, + method: "resources/read", + params: { uri: "codestory://status" }, + })}\n`); + assert.equal((await completed)[0], 0); + const responses = output.split(/\r?\n/u).filter(Boolean).map((line) => JSON.parse(line)); + assert.equal(responses.find((response) => response.id === 2)?.error.code, -32000); + const status = JSON.parse(responses.find((response) => response.id === 3).result.contents[0].text); + assert.equal(status.degraded_reason, "managed_cli_handoff_unspawnable"); +}); + test("managed cli publication is single-flight and atomically visible across two processes", { timeout: 30000 }, async () => { const { createServer } = await import("node:http"); const version = await readPluginVersion(); @@ -3315,7 +3616,6 @@ test("release asset downloader enforces a total body deadline", async () => { }); test("mcp launcher keeps managed provision failures primary", async () => { - const { spawnSync } = await import("node:child_process"); const version = await readPluginVersion(); const dataDir = await mkdtemp(join(tmpdir(), "codestory-managed-provision-fail-")); const releaseDir = await mkdtemp(join(tmpdir(), "codestory-empty-release-")); @@ -3328,7 +3628,7 @@ test("mcp launcher keeps managed provision failures primary", async () => { }) + "\n"; try { - const result = spawnSync(process.execPath, [launcher], { + const child = spawn(process.execPath, [launcher], { env: { ...process.env, CODESTORY_CLI: "", @@ -3337,13 +3637,32 @@ test("mcp launcher keeps managed provision failures primary", async () => { PATH: "", ComSpec: process.env.ComSpec || process.env.COMSPEC || "", }, - input, - encoding: "utf8", - timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], }); - - assert.equal(result.status, 0, result.stderr); - const response = JSON.parse(result.stdout.trim()); + const completed = once(child, "close"); + let buffer = ""; + const responses = []; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + buffer += chunk; + const lines = buffer.split(/\r?\n/u); + buffer = lines.pop() || ""; + responses.push(...lines.filter(Boolean).map((line) => JSON.parse(line))); + }); + child.stdin.write(input); + const firstDeadline = Date.now() + 2000; + while (Date.now() < firstDeadline && responses.length === 0) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + const first = JSON.parse(responses[0].result.contents[0].text); + if (first.degraded_reason === "managed_cli_provisioning") { + await waitForPath(join(dataDir, ".codestory-mcp-runtime.json")); + child.stdin.end(input.replace('"id":1', '"id":2')); + } else { + child.stdin.end(); + } + assert.equal((await completed)[0], 0); + const response = responses.find((entry) => entry.id === 2) || responses[0]; const status = JSON.parse(response.result.contents[0].text); assert.equal(status.degraded_reason, "managed_cli_provision_failed:managed_cli_asset_fetch_failed"); assert.doesNotMatch(JSON.stringify(status), new RegExp(releaseDir.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "u"));