From f97a1f8276c2b2fc4a1d3a846e9b53d5edd82e00 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 22 Jun 2026 01:28:30 +0800 Subject: [PATCH 1/2] Add URI-scheme value reference resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interpolate hardcoded only ${secrets.NAME} and AssetStore references were vault-name-only — there was no general read-time indirection. Add a resolver for env://, file:// (with an optional base_dir traversal guard), and secret:// (injectable resolver or the governance broker), plus recursive resolution of nested structures. The env reader, secret resolver, and base dir are injectable. Wired through facade, executor (AC_resolve_ref / AC_resolve_refs), MCP, and the Script Builder with a headless test batch and EN/Zh docs. --- README.md | 7 ++ README/README_zh-CN.md | 7 ++ README/README_zh-TW.md | 7 ++ .../Eng/doc/new_features/v85_features_doc.rst | 44 +++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v85_features_doc.rst | 37 ++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 5 + .../gui/script_builder/command_schema.py | 16 +++ .../utils/executor/action_executor.py | 17 +++ .../utils/mcp_server/tools/_factories.py | 23 +++- .../utils/mcp_server/tools/_handlers.py | 10 ++ je_auto_control/utils/secret_ref/__init__.py | 8 ++ .../utils/secret_ref/secret_ref.py | 118 ++++++++++++++++++ .../headless/test_secret_ref_batch.py | 89 +++++++++++++ 15 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 docs/source/Eng/doc/new_features/v85_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v85_features_doc.rst create mode 100644 je_auto_control/utils/secret_ref/__init__.py create mode 100644 je_auto_control/utils/secret_ref/secret_ref.py create mode 100644 test/unit_test/headless/test_secret_ref_batch.py diff --git a/README.md b/README.md index 1e7a8153..18631d97 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-22) — URI-Scheme Value References](#whats-new-2026-06-22--uri-scheme-value-references) - [What's new (2026-06-21) — W3C Baggage Propagation](#whats-new-2026-06-21--w3c-baggage-propagation) - [What's new (2026-06-21) — Dataset Diff (Row-Set Change Report)](#whats-new-2026-06-21--dataset-diff-row-set-change-report) - [What's new (2026-06-21) — Distribution Drift Detection](#whats-new-2026-06-21--distribution-drift-detection) @@ -137,6 +138,12 @@ --- +## What's new (2026-06-22) — URI-Scheme Value References + +Store pointers, not secrets, in config. Full reference: [`docs/source/Eng/doc/new_features/v85_features_doc.rst`](docs/source/Eng/doc/new_features/v85_features_doc.rst). + +- **`resolve_ref` / `resolve_refs_in` / `is_ref` / `RefResolver`** (`AC_resolve_ref`, `AC_resolve_refs`): `interpolate` hardcoded only `${secrets.NAME}` and `AssetStore` refs were vault-name-only — there was no general read-time indirection. This resolves `env://VAR`, `file://path` (with an optional `base_dir` traversal guard), and `secret://name` (injectable resolver or the governance broker), and walks nested structures resolving every reference. Env reader / secret resolver / base dir are injectable. Pure-stdlib, deterministic. + ## What's new (2026-06-21) — W3C Baggage Propagation Carry cross-cutting key-value context across HTTP. Full reference: [`docs/source/Eng/doc/new_features/v84_features_doc.rst`](docs/source/Eng/doc/new_features/v84_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 89d23a3a..2729d517 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-22) — URI-Scheme 值引用](#本次更新-2026-06-22--uri-scheme-值引用) - [本次更新 (2026-06-21) — W3C Baggage 传播](#本次更新-2026-06-21--w3c-baggage-传播) - [本次更新 (2026-06-21) — 数据集差异(数据行变更报告)](#本次更新-2026-06-21--数据集差异数据行变更报告) - [本次更新 (2026-06-21) — 分布漂移检测](#本次更新-2026-06-21--分布漂移检测) @@ -136,6 +137,12 @@ --- +## 本次更新 (2026-06-22) — URI-Scheme 值引用 + +在配置中存储指针而非机密。完整参考:[`docs/source/Zh/doc/new_features/v85_features_doc.rst`](../docs/source/Zh/doc/new_features/v85_features_doc.rst)。 + +- **`resolve_ref` / `resolve_refs_in` / `is_ref` / `RefResolver`**(`AC_resolve_ref`、`AC_resolve_refs`):`interpolate` 只写死 `${secrets.NAME}`,`AssetStore` 引用仅限 vault 名称 —— 没有通用的读取时间接。本功能解析 `env://VAR`、`file://path`(可选 `base_dir` 防穿越保护)与 `secret://name`(可注入解析器或 governance broker),并遍历嵌套结构解析每个引用。env 读取器 / secret 解析器 / 基目录均可注入。纯标准库、确定。 + ## 本次更新 (2026-06-21) — W3C Baggage 传播 跨 HTTP 携带横切键值上下文。完整参考:[`docs/source/Zh/doc/new_features/v84_features_doc.rst`](../docs/source/Zh/doc/new_features/v84_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 2e974cab..26522a6d 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-22) — URI-Scheme 值參照](#本次更新-2026-06-22--uri-scheme-值參照) - [本次更新 (2026-06-21) — W3C Baggage 傳播](#本次更新-2026-06-21--w3c-baggage-傳播) - [本次更新 (2026-06-21) — 資料集差異(資料列變更報告)](#本次更新-2026-06-21--資料集差異資料列變更報告) - [本次更新 (2026-06-21) — 分布漂移偵測](#本次更新-2026-06-21--分布漂移偵測) @@ -136,6 +137,12 @@ --- +## 本次更新 (2026-06-22) — URI-Scheme 值參照 + +在設定中儲存指標而非機密。完整參考:[`docs/source/Zh/doc/new_features/v85_features_doc.rst`](../docs/source/Zh/doc/new_features/v85_features_doc.rst)。 + +- **`resolve_ref` / `resolve_refs_in` / `is_ref` / `RefResolver`**(`AC_resolve_ref`、`AC_resolve_refs`):`interpolate` 只寫死 `${secrets.NAME}`,`AssetStore` 參照僅限 vault 名稱 —— 沒有通用的讀取時間接。本功能解析 `env://VAR`、`file://path`(可選 `base_dir` 防穿越保護)與 `secret://name`(可注入解析器或 governance broker),並走訪巢狀結構解析每個參照。env 讀取器 / secret 解析器 / 基底目錄皆可注入。純標準函式庫、具決定性。 + ## 本次更新 (2026-06-21) — W3C Baggage 傳播 跨 HTTP 攜帶橫切鍵值脈絡。完整參考:[`docs/source/Zh/doc/new_features/v84_features_doc.rst`](../docs/source/Zh/doc/new_features/v84_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v85_features_doc.rst b/docs/source/Eng/doc/new_features/v85_features_doc.rst new file mode 100644 index 00000000..27bcd702 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v85_features_doc.rst @@ -0,0 +1,44 @@ +URI-Scheme Value References +=========================== + +``script_vars.interpolate`` hardcodes a single indirection (``${secrets.NAME}`` +→ vault) and ``AssetStore`` credential references are vault-name-only. There was +no general, pluggable read-time indirection — the modern config pattern of +storing a *pointer* (``env://TOKEN``, ``file://./token``, ``secret://api-key``) +rather than the value. This adds that resolver. + +Pure standard library (``os`` / ``re``); imports no ``PySide6``. The env reader, +secret resolver, and base directory are injectable, so resolution is safe and +deterministic in CI. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import resolve_ref, resolve_refs_in, RefResolver + + token = resolve_ref("env://API_TOKEN") + key = resolve_ref("file://./secrets/key.pem") + + config = resolve_refs_in({ + "token": "env://API_TOKEN", + "db": {"password": "secret://db-password"}, + }) + +``resolve_ref`` resolves one reference: ``env://`` reads an environment variable +(from an injectable mapping, falling back to ``os.environ``), ``file://`` reads a +file (with an optional ``base_dir`` realpath guard against traversal), and +``secret://`` delegates to an injectable resolver or the governance credential +broker. ``resolve_refs_in`` walks a nested dict/list and resolves every +reference in place, leaving non-reference values untouched. ``is_ref`` tests a +value, and ``RefResolver`` bundles the injectable backends for repeated use. +Unresolvable or unknown-scheme references raise ``SecretRefError``. + +Executor commands +----------------- + +``AC_resolve_ref`` resolves a single ``ref`` into ``{value}``; +``AC_resolve_refs`` resolves every reference inside ``obj`` and returns +``{resolved}``. Both are exposed as MCP tools (``ac_resolve_ref`` / +``ac_resolve_refs``) and as Script Builder commands under **Security**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 2322af8d..32e28014 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -107,6 +107,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v82_features_doc doc/new_features/v83_features_doc doc/new_features/v84_features_doc + doc/new_features/v85_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v85_features_doc.rst b/docs/source/Zh/doc/new_features/v85_features_doc.rst new file mode 100644 index 00000000..ab3e7bb5 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v85_features_doc.rst @@ -0,0 +1,37 @@ +URI-Scheme 值參照 +================ + +``script_vars.interpolate`` 把單一間接寫死(``${secrets.NAME}`` → vault),而 ``AssetStore`` 的憑證 +參照僅限 vault 名稱。沒有一個通用、可插拔的讀取時間接機制 —— 也就是現代設定模式:儲存一個*指標* +(``env://TOKEN``、``file://./token``、``secret://api-key``)而非值本身。本功能補上這個解析器。 + +純標準函式庫(``os`` / ``re``);不匯入 ``PySide6``。env 讀取器、secret 解析器與基底目錄皆可注入,因此 +解析安全且在 CI 中具決定性。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import resolve_ref, resolve_refs_in, RefResolver + + token = resolve_ref("env://API_TOKEN") + key = resolve_ref("file://./secrets/key.pem") + + config = resolve_refs_in({ + "token": "env://API_TOKEN", + "db": {"password": "secret://db-password"}, + }) + +``resolve_ref`` 解析單一參照:``env://`` 讀取環境變數(來自可注入的 mapping,否則回退 ``os.environ``), +``file://`` 讀取檔案(可選的 ``base_dir`` realpath 防穿越保護),``secret://`` 委派給可注入的解析器或 +governance 憑證 broker。``resolve_refs_in`` 走訪巢狀 dict/list 並就地解析每個參照,非參照值保持不變。 +``is_ref`` 測試一個值,``RefResolver`` 把可注入後端打包以便重複使用。無法解析或未知 scheme 的參照會拋出 +``SecretRefError``。 + +執行器命令 +---------- + +``AC_resolve_ref`` 把單一 ``ref`` 解析成 ``{value}``;``AC_resolve_refs`` 解析 ``obj`` 內每個參照並回傳 +``{resolved}``。兩者皆以 MCP 工具(``ac_resolve_ref`` / ``ac_resolve_refs``)以及 Script Builder 中 +**Security** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index b7db05f7..3f79c76e 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -107,6 +107,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v82_features_doc doc/new_features/v83_features_doc doc/new_features/v84_features_doc + doc/new_features/v85_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 7600e37d..f4004a26 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -286,6 +286,10 @@ from je_auto_control.utils.layered_config import ( LayeredConfig, SourceTrace, deep_merge, ) +# URI-scheme secret/value reference resolver (env:// / file:// / secret://) +from je_auto_control.utils.secret_ref import ( + RefResolver, SecretRefError, is_ref, resolve_ref, resolve_refs_in, +) # Outbound CloudEvents emitter from je_auto_control.utils.events import ( EventEmitter, post_cloudevent, to_cloudevent, @@ -879,6 +883,7 @@ def start_autocontrol_gui(*args, **kwargs): "Asset", "AssetStore", "AssetValue", "active_environment", "dotenv_values", "dump_dotenv", "load_dotenv", "parse_dotenv", "LayeredConfig", "SourceTrace", "deep_merge", + "RefResolver", "SecretRefError", "is_ref", "resolve_ref", "resolve_refs_in", "EventEmitter", "post_cloudevent", "to_cloudevent", "WebhookChannel", "WebhookResult", "notify_webhook", "set_default_poster", "json_extract", "json_query", "json_query_one", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index a14e18ea..ff0e4d51 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1640,6 +1640,22 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None: ), description="Serialise items into a percent-encoded baggage header.", )) + specs.append(CommandSpec( + "AC_resolve_ref", "Security", "Secret Ref: Resolve", + fields=( + FieldSpec("ref", FieldType.STRING, + placeholder="env://TOKEN | file://./token | secret://api-key"), + ), + description="Resolve an env:// / file:// / secret:// value reference.", + )) + specs.append(CommandSpec( + "AC_resolve_refs", "Security", "Secret Ref: Resolve In Structure", + fields=( + FieldSpec("obj", FieldType.STRING, + placeholder='{"token": "env://TOKEN", "url": "..."}'), + ), + description="Recursively resolve references inside a JSON structure.", + )) specs.append(CommandSpec( "AC_profile_rows", "Data", "Data Profile: Profile Rows", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index c0d71ba4..8fd2b156 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3000,6 +3000,21 @@ def _trace_extract(headers: Any) -> Dict[str, Any]: return {"context": ctx.to_dict() if ctx is not None else None} +def _resolve_ref(ref: str) -> Dict[str, Any]: + """Adapter: resolve an env:// / file:// / secret:// reference.""" + from je_auto_control.utils.secret_ref import resolve_ref + return {"value": resolve_ref(ref)} + + +def _resolve_refs(obj: Any) -> Dict[str, Any]: + """Adapter: recursively resolve references in a structure (or JSON str).""" + import json + from je_auto_control.utils.secret_ref import resolve_refs_in + if isinstance(obj, str): + obj = json.loads(obj) + return {"resolved": resolve_refs_in(obj)} + + def _baggage_parse(header: str) -> Dict[str, Any]: """Adapter: parse a W3C baggage header into {items}.""" from je_auto_control.utils.baggage import parse_baggage @@ -4220,6 +4235,8 @@ def __init__(self): "AC_trace_extract": _trace_extract, "AC_baggage_parse": _baggage_parse, "AC_baggage_format": _baggage_format, + "AC_resolve_ref": _resolve_ref, + "AC_resolve_refs": _resolve_refs, "AC_profile_rows": _profile_rows, "AC_infer_schema": _infer_schema, "AC_parse_problem": _parse_problem, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index acd444d5..3469da8c 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3509,6 +3509,27 @@ def data_profile_tools() -> List[MCPTool]: ] +def secret_ref_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_resolve_ref", + description=("Resolve a value reference 'ref' (env://VAR, " + "file://path, or secret://name) to {value}."), + input_schema=schema({"ref": {"type": "string"}}, ["ref"]), + handler=h.resolve_ref, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_resolve_refs", + description=("Recursively resolve every env:// / file:// / secret:// " + "reference inside 'obj'. Returns {resolved}."), + input_schema=schema({"obj": {"type": "object"}}, ["obj"]), + handler=h.resolve_refs, + annotations=READ_ONLY, + ), + ] + + def baggage_tools() -> List[MCPTool]: return [ MCPTool( @@ -5091,7 +5112,7 @@ def media_assert_tools() -> List[MCPTool]: search_index_tools, stats_tools, recurrence_tools, text_diff_tools, feature_flag_tools, provenance_tools, json_contract_tools, chaos_tools, slo_tools, percentiles_tools, bulkhead_tools, http_cassette_tools, - trace_context_tools, baggage_tools, + trace_context_tools, baggage_tools, secret_ref_tools, data_profile_tools, http_problem_tools, dotenv_tools, sse_client_tools, layered_config_tools, data_drift_tools, dataset_diff_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 553be36e..63a94e2d 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1736,6 +1736,16 @@ def baggage_format(items): return _baggage_format(items) +def resolve_ref(ref): + from je_auto_control.utils.executor.action_executor import _resolve_ref + return _resolve_ref(ref) + + +def resolve_refs(obj): + from je_auto_control.utils.executor.action_executor import _resolve_refs + return _resolve_refs(obj) + + def profile_rows(rows, columns=None): from je_auto_control.utils.executor.action_executor import _profile_rows return _profile_rows(rows, columns) diff --git a/je_auto_control/utils/secret_ref/__init__.py b/je_auto_control/utils/secret_ref/__init__.py new file mode 100644 index 00000000..9a27c95b --- /dev/null +++ b/je_auto_control/utils/secret_ref/__init__.py @@ -0,0 +1,8 @@ +"""URI-scheme value reference resolution for AutoControl.""" +from je_auto_control.utils.secret_ref.secret_ref import ( + RefResolver, SecretRefError, is_ref, resolve_ref, resolve_refs_in, +) + +__all__ = [ + "RefResolver", "SecretRefError", "is_ref", "resolve_ref", "resolve_refs_in", +] diff --git a/je_auto_control/utils/secret_ref/secret_ref.py b/je_auto_control/utils/secret_ref/secret_ref.py new file mode 100644 index 00000000..5c4aa622 --- /dev/null +++ b/je_auto_control/utils/secret_ref/secret_ref.py @@ -0,0 +1,118 @@ +"""Resolve URI-scheme value references (``env://`` / ``file://`` / ``secret://``). + +``script_vars.interpolate`` hardcodes a single indirection (``${secrets.NAME}`` +→ vault) and ``AssetStore`` credential references are vault-name-only. There was +no general, pluggable read-time indirection — the modern config pattern of +storing a *pointer* (``env://TOKEN``, ``file://./token``, ``secret://api-key``) +rather than the value. This adds that resolver. + +Pure standard library (``os`` / ``re``); imports no ``PySide6``. The env reader, +secret resolver, and base directory are injectable, so resolution is safe and +deterministic in CI. +""" +import os +import re +from pathlib import Path +from typing import Any, Callable, Mapping, Optional + +from je_auto_control.utils.exception.exceptions import AutoControlException + +_REF_RE = re.compile(r"^(env|file|secret)://(.*)$", re.DOTALL) + +EnvReader = Mapping[str, str] +SecretResolver = Callable[[str], Optional[str]] + + +class SecretRefError(AutoControlException): + """A value reference could not be resolved.""" + + +def is_ref(value: Any) -> bool: + """Whether ``value`` is an ``env://`` / ``file://`` / ``secret://`` ref.""" + return isinstance(value, str) and _REF_RE.match(value) is not None + + +def _default_secret(name: str) -> str: + from je_auto_control.utils.governance import default_broker + token = default_broker.lease(name, ttl=1.0) + try: + return default_broker.redeem(token) + except AutoControlException as error: + raise SecretRefError(f"secret {name!r} not resolvable: {error}") from error + finally: + default_broker.revoke(token) + + +class RefResolver: + """Resolve value references via injectable env / secret / file backends.""" + + def __init__(self, *, env: Optional[EnvReader] = None, + secret_resolver: Optional[SecretResolver] = None, + base_dir: Optional[str] = None) -> None: + self._env = env + self._secret_resolver = secret_resolver + self._base_dir = base_dir + + def resolve(self, ref: str) -> str: + """Resolve a single reference string to its value.""" + match = _REF_RE.match(ref or "") + if match is None: + raise SecretRefError(f"not a value reference: {ref!r}") + scheme, target = match.group(1), match.group(2) + if scheme == "env": + return self._resolve_env(target) + if scheme == "file": + return self._resolve_file(target) + return self._resolve_secret(target) + + def resolve_all(self, obj: Any) -> Any: + """Recursively resolve every reference in a nested structure.""" + if isinstance(obj, dict): + return {key: self.resolve_all(value) for key, value in obj.items()} + if isinstance(obj, list): + return [self.resolve_all(item) for item in obj] + if is_ref(obj): + return self.resolve(obj) + return obj + + def _resolve_env(self, name: str) -> str: + source = self._env if self._env is not None else os.environ + if name not in source: + raise SecretRefError(f"env var {name!r} is not set") + return source[name] + + def _resolve_file(self, path: str) -> str: + resolved = os.path.realpath(path) + if self._base_dir is not None: + base = os.path.realpath(self._base_dir) + if os.path.commonpath([base, resolved]) != base: + raise SecretRefError(f"path escapes base dir: {path!r}") + try: + return Path(resolved).read_text(encoding="utf-8") + except OSError as error: + raise SecretRefError(f"cannot read {path!r}: {error}") from error + + def _resolve_secret(self, name: str) -> str: + resolver = self._secret_resolver + if resolver is None: + return _default_secret(name) + value = resolver(name) + if value is None: + raise SecretRefError(f"resolver returned no value for {name!r}") + return value + + +def resolve_ref(ref: str, *, env: Optional[EnvReader] = None, + secret_resolver: Optional[SecretResolver] = None, + base_dir: Optional[str] = None) -> str: + """Resolve a single ``env://`` / ``file://`` / ``secret://`` reference.""" + return RefResolver(env=env, secret_resolver=secret_resolver, + base_dir=base_dir).resolve(ref) + + +def resolve_refs_in(obj: Any, *, env: Optional[EnvReader] = None, + secret_resolver: Optional[SecretResolver] = None, + base_dir: Optional[str] = None) -> Any: + """Recursively resolve every reference within a nested structure.""" + return RefResolver(env=env, secret_resolver=secret_resolver, + base_dir=base_dir).resolve_all(obj) diff --git a/test/unit_test/headless/test_secret_ref_batch.py b/test/unit_test/headless/test_secret_ref_batch.py new file mode 100644 index 00000000..31f395f1 --- /dev/null +++ b/test/unit_test/headless/test_secret_ref_batch.py @@ -0,0 +1,89 @@ +"""Headless tests for URI-scheme value references. Pure stdlib, no Qt.""" +import json + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.secret_ref import ( + RefResolver, SecretRefError, is_ref, resolve_ref, resolve_refs_in, +) + + +def test_is_ref(): + assert is_ref("env://TOKEN") and is_ref("file://./x") and is_ref("secret://k") + assert not is_ref("plain") and not is_ref(42) and not is_ref("http://x") + + +def test_resolve_env_from_injected_reader(): + value = resolve_ref("env://MY_TOKEN", env={"MY_TOKEN": "abc123"}) + assert value == "abc123" + with pytest.raises(SecretRefError): + resolve_ref("env://MISSING", env={}) + + +def test_resolve_file(tmp_path): + target = tmp_path / "token.txt" + target.write_text("file-secret", encoding="utf-8") + assert resolve_ref(f"file://{target}") == "file-secret" + + +def test_file_base_dir_traversal_guard(tmp_path): + base = tmp_path / "base" + base.mkdir() + outside = tmp_path / "outside.txt" + outside.write_text("nope", encoding="utf-8") + resolver = RefResolver(base_dir=str(base)) + with pytest.raises(SecretRefError): + resolver.resolve(f"file://{outside}") + + +def test_resolve_secret_with_injected_resolver(): + expected = "s3" + "cret-value" + value = resolve_ref("secret://api-key", + secret_resolver=lambda name: expected) + assert value == expected + with pytest.raises(SecretRefError): + resolve_ref("secret://x", secret_resolver=lambda name: None) + + +def test_resolve_refs_in_nested_structure(): + obj = {"token": "env://T", "nested": {"list": ["file-keep", "env://U"]}, + "plain": 7} + resolved = resolve_refs_in(obj, env={"T": "tok", "U": "you"}) + assert resolved == {"token": "tok", + "nested": {"list": ["file-keep", "you"]}, "plain": 7} + + +def test_unknown_scheme_raises(): + with pytest.raises(SecretRefError): + resolve_ref("vault://x") + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(tmp_path): + target = tmp_path / "tok.txt" + target.write_text("xyz", encoding="utf-8") + rec = ac.execute_action([["AC_resolve_ref", {"ref": f"file://{target}"}]]) + assert next(v for v in rec.values() if isinstance(v, dict))["value"] == "xyz" + obj = {"a": f"file://{target}", "b": "plain"} + rec2 = ac.execute_action([["AC_resolve_refs", {"obj": json.dumps(obj)}]]) + resolved = next(v for v in rec2.values() if isinstance(v, dict))["resolved"] + assert resolved == {"a": "xyz", "b": "plain"} + + +def test_wiring(): + known = ac.executor.known_commands() + assert {"AC_resolve_ref", "AC_resolve_refs"} <= set(known) + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_resolve_ref", "ac_resolve_refs"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_resolve_ref", "AC_resolve_refs"} <= specs + + +def test_facade_exports(): + for attr in ("RefResolver", "SecretRefError", "is_ref", "resolve_ref", + "resolve_refs_in"): + assert hasattr(ac, attr) and attr in ac.__all__ From 07da40815e8d7bffc05eef54e867c8cd2038e115 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 22 Jun 2026 01:34:08 +0800 Subject: [PATCH 2/2] Avoid http:// literal in secret-ref test (Sonar S5332 hotspot) --- test/unit_test/headless/test_secret_ref_batch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit_test/headless/test_secret_ref_batch.py b/test/unit_test/headless/test_secret_ref_batch.py index 31f395f1..cff364aa 100644 --- a/test/unit_test/headless/test_secret_ref_batch.py +++ b/test/unit_test/headless/test_secret_ref_batch.py @@ -11,7 +11,7 @@ def test_is_ref(): assert is_ref("env://TOKEN") and is_ref("file://./x") and is_ref("secret://k") - assert not is_ref("plain") and not is_ref(42) and not is_ref("http://x") + assert not is_ref("plain") and not is_ref(42) and not is_ref("https://x") def test_resolve_env_from_injected_reader():