diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48c1e14..cd9f550 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +23,11 @@ jobs: with: python-version: "3.11" - - name: Install cmcp-runtime - run: pip install cmcp-runtime + # requirements.txt is the single source of truth: it already pins both + # cmcp-runtime (demos 1-5) and weight-custody-manifest (demos 6-9). + # Installing cmcp-runtime alone silently left the WCM demos unrunnable. + - name: Install demo dependencies + run: pip install -r requirements.txt # Run in order: demo-01 produces workspace/trace-claim.json, which # demo-02 and demo-03 consume. @@ -36,3 +39,17 @@ jobs: - name: Demo 3 - offline TRACE verification run: python demo-03-offline-trace/run.py + + - name: Demo 4 - context-aware enforcement + run: python demo-04-context-enforcement/run.py + + - name: Demo 5 - attribute-based enforcement (BAA coverage) + run: python demo-05-compliance-domain/run.py + + # Demos 6-9 are offline: no gateway, no ports, no MCP server. + - name: Demos 6-9 - weight custody + run: | + for d in demo-06-weight-custody demo-07-closed-weight demo-08-derivative-lineage demo-09-sovereign-threshold; do + echo "== $d ==" + python "$d/run.py" + done diff --git a/demo-01-cmcp-in-action/run.py b/demo-01-cmcp-in-action/run.py index 9d38267..17d2223 100644 --- a/demo-01-cmcp-in-action/run.py +++ b/demo-01-cmcp-in-action/run.py @@ -54,7 +54,45 @@ def _wait_for_port(port: int, what: str, timeout: float = 60.0) -> None: "See the *.log files in this demo folder.") +def _assert_port_free(port: int, what: str) -> None: + """Refuse to start if something already owns the port. + + _wait_for_port() returns as soon as *anything* answers, so a gateway left + running by an earlier demo satisfies it instantly. Every call then goes to + that gateway and is decided by its policy bundle, not this demo's. The + verdicts still look plausible, which is what makes it dangerous: the demo + prints allow/deny lines that are simply wrong, with no error anywhere. + """ + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + pass + except OSError: + return + sys.exit( + f"Port {port} is already in use, so {what} cannot start and this demo " + f"would be scored against whatever is already listening. Stop it first " + f"(a cMCP gateway left over from another demo is the usual cause), then " + f"re-run." + ) + + +def _wait_for_port_release(port: int, timeout: float = 10.0) -> None: + """Block until the port is actually free, so the next demo starts clean.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + time.sleep(0.25) + except OSError: + return + print(f"warning: port {port} still held after teardown", file=sys.stderr) + + def main() -> None: + # Before anything starts: both ports must be ours. + _assert_port_free(9001, "the MCP filesystem server") + _assert_port_free(8443, "the cMCP Runtime") + token = os.environ.setdefault("CMCP_BEARER_TOKEN", "demo-token") # noqa: F841 log_dir = SCRIPT_DIR @@ -91,6 +129,8 @@ def main() -> None: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() + _wait_for_port_release(8443) + _wait_for_port_release(9001) server_log.close() cmcp_log.close() diff --git a/demo-01-cmcp-in-action/run.sh b/demo-01-cmcp-in-action/run.sh index 2cf2ed8..21ac7d2 100644 --- a/demo-01-cmcp-in-action/run.sh +++ b/demo-01-cmcp-in-action/run.sh @@ -1,44 +1,14 @@ #!/usr/bin/env bash # Demo 1: cMCP in action # -# Starts the local MCP filesystem server and the cMCP Runtime (CMCP_DEV_MODE=1), -# then calls three tools through the Runtime: -# file.write -> Cedar allows it, real file written to workspace/hello.txt -# file.read -> Cedar allows it, reads file back -# file.list -> Cedar DENIES it (HTTP 403) -# -# On real Intel TDX hardware, the policy bundle hash flows into RTMR[2] -# at startup. Here it appears in trace.policy.bundle_hash in the TRACE claim. +# Thin wrapper around run.py, which is the cross-platform launcher and the one +# CI exercises. This script used to start the servers itself with a fixed +# `sleep 2`, which raced cMCP startup and, worse, carried no check that the +# ports were free. Keeping the logic in one place means the shell path and the +# CI path cannot drift apart again. # # Usage: bash demo-01-cmcp-in-action/run.sh (from repo root) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -CMCP_BEARER_TOKEN="${CMCP_BEARER_TOKEN:-demo-token}" -export CMCP_BEARER_TOKEN - -cleanup() { - kill "${CMCP_PID:-}" "${SERVER_PID:-}" 2>/dev/null || true - wait "${CMCP_PID:-}" "${SERVER_PID:-}" 2>/dev/null || true -} -trap cleanup EXIT - -echo "" -echo "=== Demo 1: cMCP in action ===" -echo "" - -echo "-- Starting MCP filesystem server on :9001 --" -python "$REPO_ROOT/server/server.py" & -SERVER_PID=$! -sleep 1 - -echo "-- Starting cMCP Runtime (CMCP_DEV_MODE=1) on :8443 --" -cd "$SCRIPT_DIR" -CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml & -CMCP_PID=$! -sleep 2 - -echo "" -python "$SCRIPT_DIR/call.py" +exec python "$SCRIPT_DIR/run.py" "$@" diff --git a/demo-02-policy-swap/run.py b/demo-02-policy-swap/run.py index 9bc0993..ddeeca2 100644 --- a/demo-02-policy-swap/run.py +++ b/demo-02-policy-swap/run.py @@ -74,7 +74,45 @@ def _post(url: str, payload: dict, token: str) -> dict: return json.loads(exc.read()) +def _assert_port_free(port: int, what: str) -> None: + """Refuse to start if something already owns the port. + + _wait_for_port() returns as soon as *anything* answers, so a gateway left + running by an earlier demo satisfies it instantly. Every call then goes to + that gateway and is decided by its policy bundle, not this demo's. The + verdicts still look plausible, which is what makes it dangerous: the demo + prints allow/deny lines that are simply wrong, with no error anywhere. + """ + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + pass + except OSError: + return + sys.exit( + f"Port {port} is already in use, so {what} cannot start and this demo " + f"would be scored against whatever is already listening. Stop it first " + f"(a cMCP gateway left over from another demo is the usual cause), then " + f"re-run." + ) + + +def _wait_for_port_release(port: int, timeout: float = 10.0) -> None: + """Block until the port is actually free, so the next demo starts clean.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + time.sleep(0.25) + except OSError: + return + print(f"warning: port {port} still held after teardown", file=sys.stderr) + + def main() -> None: + # Before anything starts: both ports must be ours. + _assert_port_free(9001, "the MCP filesystem server") + _assert_port_free(8443, "the cMCP Runtime") + if not CLAIM_PATH.exists(): sys.exit("Run demo-01 first to produce a TRACE claim:\n python demo-01-cmcp-in-action/run.py") @@ -182,6 +220,8 @@ def main() -> None: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() + _wait_for_port_release(8443) + _wait_for_port_release(9001) server_log.close() cmcp_log.close() diff --git a/demo-02-policy-swap/run.sh b/demo-02-policy-swap/run.sh index 4750dd7..e0b25e1 100644 --- a/demo-02-policy-swap/run.sh +++ b/demo-02-policy-swap/run.sh @@ -1,169 +1,14 @@ #!/usr/bin/env bash -# Demo 2: Policy swap = attestation failure +# Demo 2: policy swap # -# Shows that swapping the Cedar policy bundle changes policy.bundle_hash -# in the TRACE claim. A verifier that pinned the v1 hash detects POLICY_HASH_MISMATCH. -# -# On real Intel TDX hardware, the policy hash flows into RTMR[2] at startup. -# Swapping the bundle changes the TEE measurement itself, not just the claim field. -# -# Requires demo-01 to have run first (needs workspace/trace-claim.json). +# Thin wrapper around run.py, which is the cross-platform launcher and the one +# CI exercises. This script used to start the servers itself with a fixed +# `sleep 2`, which raced cMCP startup and, worse, carried no check that the +# ports were free. Keeping the logic in one place means the shell path and the +# CI path cannot drift apart again. # # Usage: bash demo-02-policy-swap/run.sh (from repo root) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -CMCP_BEARER_TOKEN="${CMCP_BEARER_TOKEN:-demo-token}" -export CMCP_BEARER_TOKEN - -cleanup() { - kill "${CMCP_PID:-}" "${SERVER_PID:-}" 2>/dev/null || true - wait "${CMCP_PID:-}" "${SERVER_PID:-}" 2>/dev/null || true -} -trap cleanup EXIT - -# Bash can handle POSIX paths for file existence; Python needs Windows paths -CLAIM_PATH="$REPO_ROOT/workspace/trace-claim.json" -V2_CLAIM_PATH="$REPO_ROOT/workspace/trace-claim-v2.json" -# Convert to Windows paths for Python pathlib (cygpath available in Git Bash) -CLAIM_PATH_W=$(cygpath -w "$CLAIM_PATH") -V2_CLAIM_PATH_W=$(cygpath -w "$V2_CLAIM_PATH") - -if [[ ! -f "$CLAIM_PATH" ]]; then - echo "Run demo-01 first to produce a TRACE claim:" - echo " bash demo-01-cmcp-in-action/run.sh" - exit 1 -fi - -echo "" -echo "=== Demo 2: Policy swap = attestation failure ===" -echo "" - -# Step 0: Show that v1 and v2 hashes differ -echo "-- Step 0: Policy bundle hashes --" -python "$SCRIPT_DIR/check_hash.py" - -# Step 1: Show the v1 claim's policy hash -echo "" -echo "-- Step 1: TRACE claim from demo-01 --" -python3 -c " -import json, pathlib -claim = json.loads(pathlib.Path(r'$CLAIM_PATH_W').read_text()) -policy_hash = claim['trace']['policy']['bundle_hash'] -catalog_hash = claim['gateway']['catalog']['hash'] -print(f' v1 policy.bundle_hash: {policy_hash}') -print(f' catalog.hash: {catalog_hash}') -print() -print(' A verifier who approved v1 pins the policy hash above.') -" - -# Step 2: Start v2 Runtime (Cedar policy now forbids write_file) -echo "" -echo "-- Step 2: Start cMCP with v2 policy (write_file DENIED) --" -python "$REPO_ROOT/server/server.py" & -SERVER_PID=$! -sleep 1 - -cd "$SCRIPT_DIR" -CMCP_DEV_MODE=1 cmcp start --config cmcp-config-v2.yaml & -CMCP_PID=$! -sleep 2 - -# Step 3: Show write_file is denied under v2 -echo "" -echo "-- Step 3: write_file -> DENIED by v2 Cedar policy --" -python3 -c " -import json, os, urllib.request, urllib.error - -TOKEN = os.environ.get('CMCP_BEARER_TOKEN', 'demo-token') -payload = json.dumps({ - 'jsonrpc': '2.0', 'id': 1, 'method': 'tools/call', - 'params': { - 'name': 'write_file', - 'arguments': {'path': 'post-swap.txt', 'content': 'written after policy swap'}, - '_cmcp': {'workflow_id': 'demo-02'}, - }, -}).encode() -req = urllib.request.Request( - 'http://localhost:8443/mcp', - data=payload, - headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {TOKEN}'}, - method='POST', -) -try: - with urllib.request.urlopen(req, timeout=10) as resp: - body = json.loads(resp.read()) -except urllib.error.HTTPError as exc: - body = json.loads(exc.read()) - -error = body.get('error', {}) -code = error.get('data', {}).get('error_code', '?') -print(f' HTTP 403 -- {error.get(\"message\", \"?\")} [{code}]') -print(' Cedar forbid rule matched: Action::\"WriteFile\" denied by v2 policy') -" - -# Step 4: Get v2 TRACE claim (via allowed read_file call) -echo "" -echo "-- Step 4: Get v2 TRACE claim --" -python3 -c " -import json, os, pathlib, urllib.request - -TOKEN = os.environ.get('CMCP_BEARER_TOKEN', 'demo-token') -headers = {'Content-Type': 'application/json', 'Authorization': f'Bearer {TOKEN}'} - -payload = json.dumps({ - 'jsonrpc': '2.0', 'id': 1, 'method': 'tools/call', - 'params': {'name': 'read_file', 'arguments': {'path': 'hello.txt'}, '_cmcp': {'workflow_id': 'demo-02'}}, -}).encode() -req = urllib.request.Request('http://localhost:8443/mcp', data=payload, headers=headers, method='POST') -with urllib.request.urlopen(req, timeout=10) as resp: - r = json.loads(resp.read()) -session_id = r['result']['_cmcp']['session_id'] - -req2 = urllib.request.Request( - f'http://localhost:8443/sessions/{session_id}/close', - data=b'{}', headers=headers, method='POST', -) -with urllib.request.urlopen(req2, timeout=10) as resp2: - v2_claim = json.loads(resp2.read()) - -v1_claim = json.loads(pathlib.Path(r'$CLAIM_PATH_W').read_text()) -v1_hash = v1_claim['trace']['policy']['bundle_hash'] -v2_hash = v2_claim['trace']['policy']['bundle_hash'] -print(f' v1 policy.bundle_hash: {v1_hash}') -print(f' v2 policy.bundle_hash: {v2_hash}') -print() -if v1_hash != v2_hash: - print(' Hashes differ. A verifier with the v1 approved hash will reject v2 claims.') -else: - print(' ERROR: hashes should differ but are identical.') - -pathlib.Path(r'$V2_CLAIM_PATH_W').write_text(json.dumps(v2_claim, indent=2)) -print(f' v2 claim saved.') -" 2>&1 - -# Steps 5-6: Verify the v2 claim against pinned hashes -V1_HASH=$(python3 "$SCRIPT_DIR/check_hash.py" v1 | awk '{print $2}') -V2_HASH=$(python3 "$SCRIPT_DIR/check_hash.py" v2 | awk '{print $2}') -CATALOG_HASH=$(python3 -c " -import json, pathlib -c = json.loads(pathlib.Path(r'$V2_CLAIM_PATH_W').read_text()) -print(c['gateway']['catalog']['hash']) -") - -echo "" -echo "-- Step 5: Verify v2 claim with v1 (pinned) hash -> POLICY_HASH_MISMATCH --" -echo " A verifier that approved v1 now rejects any claim from the v2 Runtime." -cmcp verify "$V2_CLAIM_PATH_W" --policy-hash "$V1_HASH" --catalog-hash "$CATALOG_HASH" || true - -echo "" -echo "-- Step 6: Verify v2 claim with v2 hash -> passes --" -echo " (Confirms the v2 claim itself is well-formed; only the pinned hash differs.)" -cmcp verify "$V2_CLAIM_PATH_W" --policy-hash "$V2_HASH" --catalog-hash "$CATALOG_HASH" || true - -echo "" -echo " On real TDX hardware: RTMR[2] changes on policy swap." -echo " No claim from the v2 Runtime can pass v1 verification." -echo "" +exec python "$SCRIPT_DIR/run.py" "$@" diff --git a/demo-04-context-enforcement/run.py b/demo-04-context-enforcement/run.py index c19db3e..60aadd3 100644 --- a/demo-04-context-enforcement/run.py +++ b/demo-04-context-enforcement/run.py @@ -54,7 +54,45 @@ def _wait_for_port(port: int, what: str, timeout: float = 60.0) -> None: "See the *.log files in this demo folder.") +def _assert_port_free(port: int, what: str) -> None: + """Refuse to start if something already owns the port. + + _wait_for_port() returns as soon as *anything* answers, so a gateway left + running by an earlier demo satisfies it instantly. Every call then goes to + that gateway and is decided by its policy bundle, not this demo's. The + verdicts still look plausible, which is what makes it dangerous: the demo + prints allow/deny lines that are simply wrong, with no error anywhere. + """ + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + pass + except OSError: + return + sys.exit( + f"Port {port} is already in use, so {what} cannot start and this demo " + f"would be scored against whatever is already listening. Stop it first " + f"(a cMCP gateway left over from another demo is the usual cause), then " + f"re-run." + ) + + +def _wait_for_port_release(port: int, timeout: float = 10.0) -> None: + """Block until the port is actually free, so the next demo starts clean.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + time.sleep(0.25) + except OSError: + return + print(f"warning: port {port} still held after teardown", file=sys.stderr) + + def main() -> None: + # Before anything starts: both ports must be ours. + _assert_port_free(9001, "the MCP filesystem server") + _assert_port_free(8443, "the cMCP Runtime") + os.environ.setdefault("CMCP_BEARER_TOKEN", "demo-token") log_dir = SCRIPT_DIR @@ -91,6 +129,8 @@ def main() -> None: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() + _wait_for_port_release(8443) + _wait_for_port_release(9001) server_log.close() cmcp_log.close() diff --git a/demo-04-context-enforcement/run.sh b/demo-04-context-enforcement/run.sh index 9472713..fac9a2a 100644 --- a/demo-04-context-enforcement/run.sh +++ b/demo-04-context-enforcement/run.sh @@ -1,44 +1,14 @@ #!/usr/bin/env bash # Demo 4: context-aware enforcement # -# Starts the local MCP filesystem server and the cMCP Runtime (CMCP_DEV_MODE=1), -# then makes three calls through the Runtime: -# write_file workflow=invoice-run -> Cedar permits (approved workflow) -# write_file workflow=chat-freeform -> Cedar DENIES (same tool + args, HTTP 403) -# read_file workflow=chat-freeform -> Cedar permits (reads allowed anywhere) -# -# The point: the same capability is scoped by the call's declared workflow, not -# by the tool's identity. The agent cannot widen its authority by restating intent. +# Thin wrapper around run.py, which is the cross-platform launcher and the one +# CI exercises. This script used to start the servers itself with a fixed +# `sleep 2`, which raced cMCP startup and, worse, carried no check that the +# ports were free. Keeping the logic in one place means the shell path and the +# CI path cannot drift apart again. # # Usage: bash demo-04-context-enforcement/run.sh (from repo root) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -CMCP_BEARER_TOKEN="${CMCP_BEARER_TOKEN:-demo-token}" -export CMCP_BEARER_TOKEN - -cleanup() { - kill "${CMCP_PID:-}" "${SERVER_PID:-}" 2>/dev/null || true - wait "${CMCP_PID:-}" "${SERVER_PID:-}" 2>/dev/null || true -} -trap cleanup EXIT - -echo "" -echo "=== Demo 4: context-aware enforcement ===" -echo "" - -echo "-- Starting MCP filesystem server on :9001 --" -python "$REPO_ROOT/server/server.py" & -SERVER_PID=$! -sleep 1 - -echo "-- Starting cMCP Runtime (CMCP_DEV_MODE=1) on :8443 --" -cd "$SCRIPT_DIR" -CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml & -CMCP_PID=$! -sleep 2 - -echo "" -python "$SCRIPT_DIR/call.py" +exec python "$SCRIPT_DIR/run.py" "$@" diff --git a/demo-05-compliance-domain/run.py b/demo-05-compliance-domain/run.py index 7262bee..a9d2988 100644 --- a/demo-05-compliance-domain/run.py +++ b/demo-05-compliance-domain/run.py @@ -54,7 +54,45 @@ def _wait_for_port(port: int, what: str, timeout: float = 60.0) -> None: "See the *.log files in this demo folder.") +def _assert_port_free(port: int, what: str) -> None: + """Refuse to start if something already owns the port. + + _wait_for_port() returns as soon as *anything* answers, so a gateway left + running by an earlier demo satisfies it instantly. Every call then goes to + that gateway and is decided by its policy bundle, not this demo's. The + verdicts still look plausible, which is what makes it dangerous: the demo + prints allow/deny lines that are simply wrong, with no error anywhere. + """ + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + pass + except OSError: + return + sys.exit( + f"Port {port} is already in use, so {what} cannot start and this demo " + f"would be scored against whatever is already listening. Stop it first " + f"(a cMCP gateway left over from another demo is the usual cause), then " + f"re-run." + ) + + +def _wait_for_port_release(port: int, timeout: float = 10.0) -> None: + """Block until the port is actually free, so the next demo starts clean.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + time.sleep(0.25) + except OSError: + return + print(f"warning: port {port} still held after teardown", file=sys.stderr) + + def main() -> None: + # Before anything starts: both ports must be ours. + _assert_port_free(9001, "the MCP filesystem server") + _assert_port_free(8443, "the cMCP Runtime") + os.environ.setdefault("CMCP_BEARER_TOKEN", "demo-token") log_dir = SCRIPT_DIR @@ -91,6 +129,8 @@ def main() -> None: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() + _wait_for_port_release(8443) + _wait_for_port_release(9001) server_log.close() cmcp_log.close() diff --git a/demo-05-compliance-domain/run.sh b/demo-05-compliance-domain/run.sh index 89bdeb4..bb0ae2b 100644 --- a/demo-05-compliance-domain/run.sh +++ b/demo-05-compliance-domain/run.sh @@ -1,44 +1,14 @@ #!/usr/bin/env bash -# Demo 5: attribute-based enforcement (compliance domain / BAA coverage) +# Demo 5: attribute-based enforcement (BAA coverage) # -# Starts the local MCP filesystem server and the cMCP Runtime (CMCP_DEV_MODE=1), -# then makes three calls through the Runtime: -# write_file clinical, baa_covered=true -> Cedar permits -# read_file clinical, baa_covered=true -> Cedar permits -# list_dir external-analytics, baa_covered=false -> Cedar DENIES (HTTP 403) -# -# The deny is decided on the tool's compliance attribute (context.baa_covered), -# not on its name. One guardrail rule covers every non-BAA-covered tool. +# Thin wrapper around run.py, which is the cross-platform launcher and the one +# CI exercises. This script used to start the servers itself with a fixed +# `sleep 2`, which raced cMCP startup and, worse, carried no check that the +# ports were free. Keeping the logic in one place means the shell path and the +# CI path cannot drift apart again. # # Usage: bash demo-05-compliance-domain/run.sh (from repo root) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -CMCP_BEARER_TOKEN="${CMCP_BEARER_TOKEN:-demo-token}" -export CMCP_BEARER_TOKEN - -cleanup() { - kill "${CMCP_PID:-}" "${SERVER_PID:-}" 2>/dev/null || true - wait "${CMCP_PID:-}" "${SERVER_PID:-}" 2>/dev/null || true -} -trap cleanup EXIT - -echo "" -echo "=== Demo 5: attribute-based enforcement (BAA coverage) ===" -echo "" - -echo "-- Starting MCP filesystem server on :9001 --" -python "$REPO_ROOT/server/server.py" & -SERVER_PID=$! -sleep 1 - -echo "-- Starting cMCP Runtime (CMCP_DEV_MODE=1) on :8443 --" -cd "$SCRIPT_DIR" -CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml & -CMCP_PID=$! -sleep 2 - -echo "" -python "$SCRIPT_DIR/call.py" +exec python "$SCRIPT_DIR/run.py" "$@"