forked from 1b-agent/opencadstudio-upstream
fix(macos): forward launcher CLI arguments and preserve MCP stdio
This commit is contained in:
1 parent
0b98021bb4
commit
44ffb1ca5a
3 files changed
+118
No files matched your search
@@ -511,6 +511,12 @@ jobs:
|
|||||||
codesign -d --entitlements - --xml OpenCADStudio.app/Contents/PlugIns/DWGThumbnail.appex \
|
codesign -d --entitlements - --xml OpenCADStudio.app/Contents/PlugIns/DWGThumbnail.appex \
|
||||||
| python3 -c 'import io, plistlib, sys; ent = plistlib.load(io.BytesIO(sys.stdin.buffer.read())); print("entitlements:", ent); assert ent.get("com.apple.security.app-sandbox") is True'
|
| python3 -c 'import io, plistlib, sys; ent = plistlib.load(io.BytesIO(sys.stdin.buffer.read())); print("entitlements:", ent); assert ent.get("com.apple.security.app-sandbox") is True'
|
||||||
|
|
||||||
|
# Exercise CFBundleExecutable, not just the inner application: the
|
||||||
|
# Finder launcher must preserve --mcp and the client's stdio pipes.
|
||||||
|
- name: Verify packaged MCP handshake
|
||||||
|
timeout-minutes: 2
|
||||||
|
run: python3 docs/automation/mcp_handshake.py OpenCADStudio.app/Contents/MacOS/OpenCADStudio
|
||||||
|
|
||||||
- name: Create .dmg
|
- name: Create .dmg
|
||||||
run: |
|
run: |
|
||||||
set +e
|
set +e
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Bounded stdio regression probe; point this at the packaged launcher on macOS."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import queue
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
|
||||||
|
|
||||||
|
def probe(command: list[str], timeout: float = 10) -> None:
|
||||||
|
# A separate process group lets a failed probe clean up a broken launcher
|
||||||
|
# and any GUI child it incorrectly spawned. Never touch existing editors.
|
||||||
|
with tempfile.TemporaryFile() as stderr:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
[*command, "--mcp"],
|
||||||
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr,
|
||||||
|
text=True, encoding="utf-8", start_new_session=os.name == "posix",
|
||||||
|
env={**os.environ, "OCS_SKIP_SCHEMA_SYNC": "1"},
|
||||||
|
)
|
||||||
|
lines = queue.Queue()
|
||||||
|
|
||||||
|
def read_stdout() -> None:
|
||||||
|
try:
|
||||||
|
for line in process.stdout:
|
||||||
|
lines.put(line)
|
||||||
|
finally:
|
||||||
|
lines.put(None)
|
||||||
|
|
||||||
|
threading.Thread(target=read_stdout, daemon=True).start()
|
||||||
|
|
||||||
|
def request(request_id: int, method: str, params: dict) -> dict:
|
||||||
|
process.stdin.write(json.dumps({
|
||||||
|
"jsonrpc": "2.0", "id": request_id,
|
||||||
|
"method": method, "params": params,
|
||||||
|
}) + "\n")
|
||||||
|
process.stdin.flush()
|
||||||
|
# stdin stays open while waiting: a server buffering until EOF
|
||||||
|
# must fail this test just like the launcher that ignores --mcp.
|
||||||
|
try:
|
||||||
|
line = lines.get(timeout=timeout)
|
||||||
|
except queue.Empty as error:
|
||||||
|
raise AssertionError(f"Timed out waiting for {method}") from error
|
||||||
|
assert line is not None, f"stdout closed before {method} responded"
|
||||||
|
response = json.loads(line)
|
||||||
|
assert response["jsonrpc"] == "2.0", response
|
||||||
|
assert response["id"] == request_id, response
|
||||||
|
assert "error" not in response, response
|
||||||
|
return response["result"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
initialized = request(1, "initialize", {
|
||||||
|
"protocolVersion": "2025-11-25", "capabilities": {},
|
||||||
|
"clientInfo": {"name": "packaged-handshake", "version": "1"},
|
||||||
|
})
|
||||||
|
assert initialized["protocolVersion"] == "2025-11-25", initialized
|
||||||
|
assert initialized["serverInfo"]["name"] == "OpenCADStudio", initialized
|
||||||
|
process.stdin.write(
|
||||||
|
'{"jsonrpc":"2.0","method":"notifications/initialized"}\n'
|
||||||
|
)
|
||||||
|
process.stdin.flush()
|
||||||
|
tools = request(2, "tools/list", {})
|
||||||
|
assert {tool["name"] for tool in tools["tools"]} == {
|
||||||
|
"ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture",
|
||||||
|
}, tools
|
||||||
|
request(3, "ping", {})
|
||||||
|
process.stdin.close()
|
||||||
|
assert process.wait(timeout=timeout) == 0, process.returncode
|
||||||
|
finally:
|
||||||
|
if os.name == "posix":
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
elif process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
process.stdin.close()
|
||||||
|
process.stdout.close()
|
||||||
|
stderr.seek(0)
|
||||||
|
diagnostics = stderr.read().decode("utf-8", errors="replace")
|
||||||
|
if diagnostics:
|
||||||
|
print(diagnostics, file=sys.stderr, end="")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
probe([str(Path(sys.argv[1]).resolve())])
|
||||||
|
print("MCP initialize, tools/list, ping and EOF shutdown passed")
|
||||||
@@ -151,6 +151,28 @@ impl Delegate {
|
|||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// Explicit CLI invocations must reach the real argument parser before
|
||||||
|
// AppKit starts. In particular, --mcp needs this process's stdin/stdout,
|
||||||
|
// and --serve, --export, --help and --version must not launch a bare GUI.
|
||||||
|
// Keep arguments as OsString so file names need not be valid UTF-8.
|
||||||
|
// Finder may supply only its legacy process serial number; that still
|
||||||
|
// belongs to the persistent document-open relay below.
|
||||||
|
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||||
|
let finder_launch =
|
||||||
|
args.is_empty() || (args.len() == 1 && args[0].as_encoded_bytes().starts_with(b"-psn_"));
|
||||||
|
if !finder_launch {
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
|
||||||
|
// Replacing the process preserves stdio, PID, signals and exit status.
|
||||||
|
// Do not exec on a normal Finder launch: its AppKit delegate must stay
|
||||||
|
// alive to receive subsequent document-open events.
|
||||||
|
let error = std::process::Command::new(real_binary_path())
|
||||||
|
.args(args)
|
||||||
|
.exec();
|
||||||
|
eprintln!("OpenCADStudio launcher: failed to execute the application: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
let mtm = MainThreadMarker::new().expect("the launcher must run on the main thread");
|
let mtm = MainThreadMarker::new().expect("the launcher must run on the main thread");
|
||||||
let app = NSApplication::sharedApplication(mtm);
|
let app = NSApplication::sharedApplication(mtm);
|
||||||
// No window, no Dock icon, no menu bar — this process only relays.
|
// No window, no Dock icon, no menu bar — this process only relays.
|
||||||
|
|||||||
Reference in new issue
Block a user