diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d79d09b9..c460f5ebb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,13 +116,13 @@ jobs: strategy: matrix: python-version: - - "3.11" + - "3.13" steps: - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: astral-sh/setup-uv@v7 with: - python-version: "3.11" + python-version: "3.13" enable-cache: true cache-suffix: "schema-check-cli" - name: Install CLI dependencies diff --git a/libs/cli/generate_schema.py b/libs/cli/generate_schema.py index 99e521d28..32543c037 100644 --- a/libs/cli/generate_schema.py +++ b/libs/cli/generate_schema.py @@ -27,6 +27,8 @@ from langgraph_cli.schemas import ( StoreConfig, ThreadTTLConfig, TTLConfig, + WebhooksConfig, + WebhookUrlPolicy, ) @@ -118,6 +120,8 @@ def add_descriptions_to_schema(schema, cls): SerdeConfig, TTLConfig, ConfigurableHeaderConfig, + WebhooksConfig, + WebhookUrlPolicy, ]: if potential_cls.__name__ == def_name: add_descriptions_to_schema(def_schema, potential_cls) diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 30a835692..9053f7fc1 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -156,6 +156,8 @@ def validate_config(config: Config) -> Config: "auth": config.get("auth"), "encryption": config.get("encryption"), "http": config.get("http"), + # Pass through webhooks config so it can be injected into the image + "webhooks": config.get("webhooks"), "checkpointer": config.get("checkpointer"), "ui": config.get("ui"), "ui_config": config.get("ui_config"), @@ -959,6 +961,10 @@ ADD {relpath} /deps/{name} if (http_config := config.get("http")) is not None: env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'") + # Inject webhooks configuration if provided + if (webhooks_config := config.get("webhooks")) is not None: + env_vars.append(f"ENV LANGGRAPH_WEBHOOKS='{json.dumps(webhooks_config)}'") + if (checkpointer_config := config.get("checkpointer")) is not None: env_vars.append( f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'" @@ -1085,6 +1091,10 @@ def node_config_to_docker( if (http_config := config.get("http")) is not None: env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'") + # Inject webhooks configuration if provided + if (webhooks_config := config.get("webhooks")) is not None: + env_vars.append(f"ENV LANGGRAPH_WEBHOOKS='{json.dumps(webhooks_config)}'") + if (checkpointer_config := config.get("checkpointer")) is not None: env_vars.append( f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'" diff --git a/libs/cli/langgraph_cli/schemas.py b/libs/cli/langgraph_cli/schemas.py index 8e01bfa58..b559e6a0d 100644 --- a/libs/cli/langgraph_cli/schemas.py +++ b/libs/cli/langgraph_cli/schemas.py @@ -362,7 +362,7 @@ class CorsConfig(TypedDict, total=False): """ -class ConfigurableHeaderConfig(TypedDict): +class ConfigurableHeaderConfig(TypedDict, total=False): """Customize which headers to include as configurable values in your runs. By default, omits x-api-key, x-tenant-id, and x-service-key. @@ -373,7 +373,7 @@ class ConfigurableHeaderConfig(TypedDict): """ includes: list[str] | None - """Headers to include (if not also matches against an 'exludes' pattern. + """Headers to include (if not also matched against an 'excludes' pattern). Examples: - 'user-agent' @@ -485,6 +485,46 @@ class HttpConfig(TypedDict, total=False): """ +class WebhookUrlPolicy(TypedDict, total=False): + require_https: bool + """Enforce HTTPS scheme for absolute URLs; reject `http://` when true.""" + allowed_domains: list[str] + """Hostname allowlist. Supports exact hosts and wildcard subdomains. + + Use entries like "hooks.example.com" or "*.mycorp.com". The wildcard only + matches subdomains ("foo.mycorp.com"), not the apex ("mycorp.com"). When + empty or omitted, any public host is allowed (subject to SSRF IP checks). + """ + allowed_ports: list[int] + """Explicit port allowlist for absolute URLs. + + If set, requests must use one of these ports. Defaults are respected when + a port is not present in the URL (443 for https, 80 for http). + """ + max_url_length: int + """Maximum permitted URL length in characters; longer inputs are rejected early.""" + disable_loopback: bool + """Disallow relative URLs (internal loopback calls) when true.""" + + +class WebhooksConfig(TypedDict, total=False): + env_prefix: str + """Required prefix for environment variables referenced in header templates. + + Acts as an allowlist boundary to prevent leaking arbitrary environment + variables. Defaults to "LG_WEBHOOK_" when omitted. + """ + url: WebhookUrlPolicy + """URL validation policy for user-supplied webhook endpoints.""" + headers: dict[str, str] + """Static headers to include with webhook requests. + + Values may contain templates of the form "${{ env.VAR }}". On startup, these + are resolved via the process environment after verifying `VAR` starts with + `env_prefix`. Mixed literals and multiple templates are allowed. + """ + + class Config(TypedDict, total=False): """Top-level config for langgraph-cli or similar deployment tooling.""" @@ -613,6 +653,13 @@ class Config(TypedDict, total=False): and how cross-origin requests are handled. """ + webhooks: WebhooksConfig | None + """Optional. Webhooks configuration for outbound event delivery. + + Forwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig` + for URL policy and header templating details. + """ + ui: dict[str, str] | None """Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file. """ diff --git a/libs/cli/schemas/schema.json b/libs/cli/schemas/schema.json index 86e5937c1..d79e47ec8 100644 --- a/libs/cli/schemas/schema.json +++ b/libs/cli/schemas/schema.json @@ -210,6 +210,17 @@ } ], "description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n" + }, + "webhooks": { + "anyOf": [ + { + "$ref": "#/$defs/WebhooksConfig" + }, + { + "type": "null" + } + ], + "description": "Optional. Webhooks configuration for outbound event delivery.\n\nForwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig`\nfor URL policy and header templating details.\n" } }, "required": [ @@ -413,6 +424,17 @@ } ], "description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n" + }, + "webhooks": { + "anyOf": [ + { + "$ref": "#/$defs/WebhooksConfig" + }, + { + "type": "null" + } + ], + "description": "Optional. Webhooks configuration for outbound event delivery.\n\nForwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig`\nfor URL policy and header templating details.\n" } }, "required": [ @@ -616,7 +638,7 @@ }, "EncryptionConfig": { "title": "EncryptionConfig", - "description": "Configuration for custom at-rest encryption logic.\n\n Allows you to implement custom encryption for sensitive data stored in the database,\n including metadata fields and checkpoint blobs.", + "description": "Configuration for custom at-rest encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database,\nincluding metadata fields and checkpoint blobs.", "type": "object", "properties": { "path": { @@ -759,13 +781,10 @@ "type": "null" } ], - "description": "Headers to include (if not also matches against an 'exludes' pattern.\n" + "description": "Headers to include (if not also matched against an 'excludes' pattern).\n" } }, - "required": [ - "excludes", - "includes" - ] + "required": [] }, "CorsConfig": { "title": "CorsConfig", @@ -908,6 +927,63 @@ } }, "required": [] + }, + "WebhooksConfig": { + "title": "WebhooksConfig", + "type": "object", + "properties": { + "env_prefix": { + "type": "string", + "description": "Required prefix for environment variables referenced in header templates.\n\nActs as an allowlist boundary to prevent leaking arbitrary environment\nvariables. Defaults to \"LG_WEBHOOK_\" when omitted.\n" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Static headers to include with webhook requests.\n\nValues may contain templates of the form \"${{ env.VAR }}\". On startup, these\nare resolved via the process environment after verifying `VAR` starts with\n`env_prefix`. Mixed literals and multiple templates are allowed.\n" + }, + "url": { + "$ref": "#/$defs/WebhookUrlPolicy", + "description": "URL validation policy for user-supplied webhook endpoints." + } + }, + "required": [], + "description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n d = {}\n for k, v in iterable:\n d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n in the keyword argument list. For example: dict(one=1, two=2)" + }, + "WebhookUrlPolicy": { + "title": "WebhookUrlPolicy", + "type": "object", + "properties": { + "allowed_domains": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Hostname allowlist. Supports exact hosts and wildcard subdomains.\n\nUse entries like \"hooks.example.com\" or \"*.mycorp.com\". The wildcard only\nmatches subdomains (\"foo.mycorp.com\"), not the apex (\"mycorp.com\"). When\nempty or omitted, any public host is allowed (subject to SSRF IP checks).\n" + }, + "allowed_ports": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Explicit port allowlist for absolute URLs.\n\nIf set, requests must use one of these ports. Defaults are respected when\na port is not present in the URL (443 for https, 80 for http).\n" + }, + "disable_loopback": { + "type": "boolean", + "description": "Disallow relative URLs (internal loopback calls) when true." + }, + "max_url_length": { + "type": "integer", + "description": "Maximum permitted URL length in characters; longer inputs are rejected early." + }, + "require_https": { + "type": "boolean", + "description": "Enforce HTTPS scheme for absolute URLs; reject `http://` when true." + } + }, + "required": [], + "description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n d = {}\n for k, v in iterable:\n d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n in the keyword argument list. For example: dict(one=1, two=2)" } }, "title": "LangGraph CLI Configuration", diff --git a/libs/cli/schemas/schema.v0.json b/libs/cli/schemas/schema.v0.json index 86e5937c1..d79e47ec8 100644 --- a/libs/cli/schemas/schema.v0.json +++ b/libs/cli/schemas/schema.v0.json @@ -210,6 +210,17 @@ } ], "description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n" + }, + "webhooks": { + "anyOf": [ + { + "$ref": "#/$defs/WebhooksConfig" + }, + { + "type": "null" + } + ], + "description": "Optional. Webhooks configuration for outbound event delivery.\n\nForwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig`\nfor URL policy and header templating details.\n" } }, "required": [ @@ -413,6 +424,17 @@ } ], "description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n" + }, + "webhooks": { + "anyOf": [ + { + "$ref": "#/$defs/WebhooksConfig" + }, + { + "type": "null" + } + ], + "description": "Optional. Webhooks configuration for outbound event delivery.\n\nForwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig`\nfor URL policy and header templating details.\n" } }, "required": [ @@ -616,7 +638,7 @@ }, "EncryptionConfig": { "title": "EncryptionConfig", - "description": "Configuration for custom at-rest encryption logic.\n\n Allows you to implement custom encryption for sensitive data stored in the database,\n including metadata fields and checkpoint blobs.", + "description": "Configuration for custom at-rest encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database,\nincluding metadata fields and checkpoint blobs.", "type": "object", "properties": { "path": { @@ -759,13 +781,10 @@ "type": "null" } ], - "description": "Headers to include (if not also matches against an 'exludes' pattern.\n" + "description": "Headers to include (if not also matched against an 'excludes' pattern).\n" } }, - "required": [ - "excludes", - "includes" - ] + "required": [] }, "CorsConfig": { "title": "CorsConfig", @@ -908,6 +927,63 @@ } }, "required": [] + }, + "WebhooksConfig": { + "title": "WebhooksConfig", + "type": "object", + "properties": { + "env_prefix": { + "type": "string", + "description": "Required prefix for environment variables referenced in header templates.\n\nActs as an allowlist boundary to prevent leaking arbitrary environment\nvariables. Defaults to \"LG_WEBHOOK_\" when omitted.\n" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Static headers to include with webhook requests.\n\nValues may contain templates of the form \"${{ env.VAR }}\". On startup, these\nare resolved via the process environment after verifying `VAR` starts with\n`env_prefix`. Mixed literals and multiple templates are allowed.\n" + }, + "url": { + "$ref": "#/$defs/WebhookUrlPolicy", + "description": "URL validation policy for user-supplied webhook endpoints." + } + }, + "required": [], + "description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n d = {}\n for k, v in iterable:\n d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n in the keyword argument list. For example: dict(one=1, two=2)" + }, + "WebhookUrlPolicy": { + "title": "WebhookUrlPolicy", + "type": "object", + "properties": { + "allowed_domains": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Hostname allowlist. Supports exact hosts and wildcard subdomains.\n\nUse entries like \"hooks.example.com\" or \"*.mycorp.com\". The wildcard only\nmatches subdomains (\"foo.mycorp.com\"), not the apex (\"mycorp.com\"). When\nempty or omitted, any public host is allowed (subject to SSRF IP checks).\n" + }, + "allowed_ports": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Explicit port allowlist for absolute URLs.\n\nIf set, requests must use one of these ports. Defaults are respected when\na port is not present in the URL (443 for https, 80 for http).\n" + }, + "disable_loopback": { + "type": "boolean", + "description": "Disallow relative URLs (internal loopback calls) when true." + }, + "max_url_length": { + "type": "integer", + "description": "Maximum permitted URL length in characters; longer inputs are rejected early." + }, + "require_https": { + "type": "boolean", + "description": "Enforce HTTPS scheme for absolute URLs; reject `http://` when true." + } + }, + "required": [], + "description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n d = {}\n for k, v in iterable:\n d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n in the keyword argument list. For example: dict(one=1, two=2)" } }, "title": "LangGraph CLI Configuration", diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index def0212ce..621ac3335 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -49,6 +49,7 @@ def test_validate_config(): "store": None, "auth": None, "encryption": None, + "webhooks": None, "checkpointer": None, "http": None, "ui": None, @@ -76,6 +77,7 @@ def test_validate_config(): "store": None, "auth": None, "encryption": None, + "webhooks": None, "checkpointer": None, "http": None, "ui": None, @@ -798,7 +800,10 @@ def test_config_to_docker_python_encryption_formatted(): ) # Verify that LANGGRAPH_ENCRYPTION is in the docker output with the correct path assert "LANGGRAPH_ENCRYPTION=" in actual_docker_stdin - assert "/deps/outer-unit_tests/unit_tests/agent.py:my_encryption" in actual_docker_stdin + assert ( + "/deps/outer-unit_tests/unit_tests/agent.py:my_encryption" + in actual_docker_stdin + ) def test_config_to_docker_nodejs_internal_docker_tag(): @@ -834,6 +839,85 @@ RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not foun assert additional_contexts == {} +def _extract_env_json(dockerfile: str, var_name: str) -> dict: + """Helper to extract and parse a JSON value from an ENV line in a Dockerfile.""" + line_prefix = f"ENV {var_name}='" + for line in dockerfile.splitlines(): + if line.startswith(line_prefix) and line.endswith("'"): + json_str = line[len(line_prefix) : -1] + return json.loads(json_str) + raise AssertionError(f"{var_name} not found in Dockerfile env lines") + + +def test_config_to_docker_webhooks_python(): + graphs = {"agent": "./agent.py:graph"} + webhooks = { + "env_prefix": "LG_WEBHOOK_", + "url": { + "require_https": True, + "allowed_domains": ["hooks.example.com", "*.example.org"], + "allowed_ports": [443], + "max_url_length": 1024, + "disable_loopback": False, + }, + "headers": { + "x-auth": "${{ env.LG_WEBHOOK_TOKEN }}", + "x-mixed": "Bearer ${{ env.LG_WEBHOOK_TOKEN }}-suffix", + }, + } + + dockerfile, _ = config_to_docker( + PATH_TO_CONFIG, + validate_config( + { + "dependencies": ["."], + "graphs": graphs, + "webhooks": webhooks, + } + ), + "langchain/langgraph-api", + ) + + # Ensure the ENV line is present and the payload round-trips via JSON + parsed = _extract_env_json(dockerfile, "LANGGRAPH_WEBHOOKS") + assert parsed == webhooks + + +def test_config_to_docker_webhooks_node(): + graphs = {"agent": "./graphs/agent.js:graph"} + webhooks = { + "env_prefix": "LG_WEBHOOK_", + "url": {"require_https": True}, + "headers": {"x-auth": "${{ env.LG_WEBHOOK_TOKEN }}"}, + } + + dockerfile, _ = config_to_docker( + PATH_TO_CONFIG, + validate_config( + { + "node_version": "20", + "graphs": graphs, + "webhooks": webhooks, + } + ), + "langchain/langgraphjs-api", + ) + + parsed = _extract_env_json(dockerfile, "LANGGRAPH_WEBHOOKS") + assert parsed == webhooks + + +def test_config_to_docker_no_webhooks(): + graphs = {"agent": "./agent.py:graph"} + dockerfile, _ = config_to_docker( + PATH_TO_CONFIG, + validate_config({"dependencies": ["."], "graphs": graphs}), + "langchain/langgraph-api", + ) + + assert "ENV LANGGRAPH_WEBHOOKS=" not in dockerfile + + def test_config_to_docker_gen_ui_python(): graphs = {"agent": "./agent.py:graph"} actual_docker_stdin, additional_contexts = config_to_docker(