shopping-cli 3.0.1__tar.gz → 3.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {shopping_cli-3.0.1/shopping_cli.egg-info → shopping_cli-3.0.2}/PKG-INFO +4 -2
  2. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/pyproject.toml +12 -2
  3. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/__init__.py +1 -1
  4. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/agents/buyer_cli.py +6 -1
  5. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/agents/merchant_daemon.py +58 -45
  6. shopping_cli-3.0.2/shopping_cli/agents/merchant_state_helpers.py +58 -0
  7. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/agents/tools.py +2 -0
  8. shopping_cli-3.0.2/shopping_cli/api/app.py +121 -0
  9. shopping_cli-3.0.2/shopping_cli/api/error_handlers.py +117 -0
  10. shopping_cli-3.0.2/shopping_cli/api/error_response.py +22 -0
  11. shopping_cli-3.0.2/shopping_cli/api/fastapi_routes.py +602 -0
  12. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/agents.py +1 -1
  13. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/buyer.py +34 -5
  14. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/catalog.py +56 -19
  15. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/idempotency.py +25 -0
  16. shopping_cli-3.0.2/shopping_cli/api/request_dispatch.py +62 -0
  17. shopping_cli-3.0.2/shopping_cli/api/request_limits.py +87 -0
  18. shopping_cli-3.0.2/shopping_cli/api/route_matching.py +19 -0
  19. shopping_cli-3.0.2/shopping_cli/api/route_table.py +610 -0
  20. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/cli.py +2 -0
  21. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/cli_buyer_commands.py +9 -1
  22. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/cli_catalog_commands.py +2 -0
  23. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/commerce/adapters.py +17 -3
  24. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/commerce/data_source.py +7 -4
  25. shopping_cli-3.0.2/shopping_cli/contracts/kiwi-contracts.lock.json +7 -0
  26. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/catalog.py +59 -137
  27. shopping_cli-3.0.2/shopping_cli/core/catalog_scoring.py +47 -0
  28. shopping_cli-3.0.2/shopping_cli/core/catalog_text.py +56 -0
  29. shopping_cli-3.0.2/shopping_cli/core/catalog_views.py +51 -0
  30. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/channels.py +0 -1
  31. shopping_cli-3.0.2/shopping_cli/core/negotiation.py +76 -0
  32. shopping_cli-3.0.1/shopping_cli/core/negotiation.py → shopping_cli-3.0.2/shopping_cli/core/negotiation_contracts.py +10 -48
  33. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/data_sources/erp_source.py +57 -11
  34. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/db/migrations.py +12 -1
  35. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/db/models.py +4 -0
  36. shopping_cli-3.0.2/shopping_cli/http_client.py +326 -0
  37. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/listings/projection.py +3 -0
  38. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/services/negotiation.py +39 -234
  39. shopping_cli-3.0.2/shopping_cli/services/negotiation_gates.py +106 -0
  40. shopping_cli-3.0.2/shopping_cli/services/negotiation_message.py +57 -0
  41. shopping_cli-3.0.2/shopping_cli/services/negotiation_policy_helpers.py +48 -0
  42. shopping_cli-3.0.2/shopping_cli/services/negotiation_policy_result.py +57 -0
  43. shopping_cli-3.0.2/shopping_cli/services/negotiation_snapshot.py +45 -0
  44. shopping_cli-3.0.2/shopping_cli/services/negotiation_snapshot_projection.py +110 -0
  45. {shopping_cli-3.0.1 → shopping_cli-3.0.2/shopping_cli.egg-info}/PKG-INFO +4 -2
  46. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli.egg-info/SOURCES.txt +19 -0
  47. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli.egg-info/requires.txt +3 -1
  48. shopping_cli-3.0.1/shopping_cli/api/app.py +0 -1353
  49. shopping_cli-3.0.1/shopping_cli/http_client.py +0 -149
  50. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/LICENSE +0 -0
  51. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/MANIFEST.in +0 -0
  52. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/README.md +0 -0
  53. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/setup.cfg +0 -0
  54. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/agents/__init__.py +0 -0
  55. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/agents/agent_cli.py +0 -0
  56. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/agents/merchant_agent.py +0 -0
  57. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/__init__.py +0 -0
  58. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/auth.py +0 -0
  59. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/fallback_asgi.py +0 -0
  60. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/__init__.py +0 -0
  61. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/audit.py +0 -0
  62. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/common.py +0 -0
  63. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/conversations.py +0 -0
  64. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/erp.py +0 -0
  65. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/human_review.py +0 -0
  66. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/listings_projection.py +0 -0
  67. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/negotiation.py +0 -0
  68. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/limits.py +0 -0
  69. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/route_registry.py +0 -0
  70. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/routes_agents.py +0 -0
  71. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/routes_conversations.py +0 -0
  72. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/routes_marketplace.py +0 -0
  73. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/routes_merchants.py +0 -0
  74. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/server.py +0 -0
  75. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/cli_agent_commands.py +0 -0
  76. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/cli_common.py +0 -0
  77. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/cli_conversation_commands.py +0 -0
  78. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/cli_erp_commands.py +0 -0
  79. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/cli_listing_commands.py +0 -0
  80. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/commerce/__init__.py +0 -0
  81. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/config.py +0 -0
  82. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/contracts/shopping.negotiation/0.1/capabilities.schema.json +0 -0
  83. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/contracts/shopping.negotiation/0.1/decision.schema.json +0 -0
  84. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/contracts/shopping.negotiation/0.1/policy-result.schema.json +0 -0
  85. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/contracts/shopping.negotiation/0.1/snapshot.schema.json +0 -0
  86. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/__init__.py +0 -0
  87. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/conversations.py +0 -0
  88. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/delivery.py +0 -0
  89. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/errors.py +0 -0
  90. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/harness.py +0 -0
  91. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/limits.py +0 -0
  92. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/policies.py +0 -0
  93. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/risk.py +0 -0
  94. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/tokens.py +0 -0
  95. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/data_sources/__init__.py +0 -0
  96. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/db/__init__.py +0 -0
  97. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/db/provenance.py +0 -0
  98. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/db/session.py +0 -0
  99. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/listings/__init__.py +0 -0
  100. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/services/__init__.py +0 -0
  101. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/services/agents.py +0 -0
  102. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/services/audit.py +0 -0
  103. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/services/buyer_bootstrap.py +0 -0
  104. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/services/conversations.py +0 -0
  105. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/services/human_review.py +0 -0
  106. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/services/tokens.py +0 -0
  107. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli.egg-info/dependency_links.txt +0 -0
  108. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli.egg-info/entry_points.txt +0 -0
  109. {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shopping-cli
3
- Version: 3.0.1
3
+ Version: 3.0.2
4
4
  Summary: Standalone local-commerce AI consultation marketplace runtime
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -209,13 +209,15 @@ Description-Content-Type: text/markdown
209
209
  License-File: LICENSE
210
210
  Requires-Dist: psutil>=5.9
211
211
  Provides-Extra: api
212
- Requires-Dist: fastapi>=0.110; extra == "api"
212
+ Requires-Dist: fastapi>=0.141.1; extra == "api"
213
213
  Requires-Dist: pydantic>=2; extra == "api"
214
214
  Requires-Dist: uvicorn>=0.27; extra == "api"
215
215
  Provides-Extra: dev
216
216
  Requires-Dist: build>=1.2; extra == "dev"
217
217
  Requires-Dist: coverage[toml]>=7.6; extra == "dev"
218
+ Requires-Dist: httpx>=0.28; extra == "dev"
218
219
  Requires-Dist: mypy>=1.11; extra == "dev"
220
+ Requires-Dist: pytest>=8.0; extra == "dev"
219
221
  Requires-Dist: ruff>=0.8; extra == "dev"
220
222
  Dynamic: license-file
221
223
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "shopping-cli"
7
- version = "3.0.1"
7
+ version = "3.0.2"
8
8
  description = "Standalone local-commerce AI consultation marketplace runtime"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -20,17 +20,24 @@ shopping-cli-agent = "shopping_cli.agents.agent_cli:main"
20
20
 
21
21
  [project.optional-dependencies]
22
22
  api = [
23
- "fastapi>=0.110",
23
+ "fastapi>=0.141.1",
24
24
  "pydantic>=2",
25
25
  "uvicorn>=0.27",
26
26
  ]
27
27
  dev = [
28
28
  "build>=1.2",
29
29
  "coverage[toml]>=7.6",
30
+ "httpx>=0.28",
30
31
  "mypy>=1.11",
32
+ "pytest>=8.0",
31
33
  "ruff>=0.8",
32
34
  ]
33
35
 
36
+ [tool.uv]
37
+ # Keep the optional API stack above the currently published Starlette security
38
+ # floor while retaining an upper bound for deliberate compatibility upgrades.
39
+ constraint-dependencies = ["fastapi>=0.141.1,<0.142", "starlette>=1.3.1,<1.7", "uvicorn<0.36"]
40
+
34
41
  [tool.ruff]
35
42
  target-version = "py311"
36
43
  line-length = 120
@@ -58,3 +65,6 @@ omit = ["shopping_cli/__main__.py"]
58
65
  [tool.setuptools.packages.find]
59
66
  where = ["."]
60
67
  include = ["shopping_cli*"]
68
+
69
+ [tool.setuptools.package-data]
70
+ shopping_cli = ["contracts/*.json"]
@@ -1,3 +1,3 @@
1
1
  """Standalone shopping-cli consultation runtime."""
2
2
 
3
- VERSION = "3.0.1"
3
+ VERSION = "3.0.2"
@@ -131,7 +131,12 @@ def summarize(conn: sqlite3.Connection, conversation_id: str) -> dict[str, Any]:
131
131
  missing_facts.append("merchant contact")
132
132
  if not option["delivery"].get("service_area"):
133
133
  missing_facts.append("delivery rule")
134
- if option["stock"] <= 0:
134
+ # 审查 P2-1:公开投影只携带 availability_hint(精确库存不下发);
135
+ # 历史快照可能仍是旧形状(stock),两者都按"缺货"语义处理。
136
+ stock = option.get("stock")
137
+ if stock is None:
138
+ stock = 0 if option.get("availability_hint") == "out_of_stock" else 1
139
+ if stock <= 0:
135
140
  warnings.append("Product is out of stock.")
136
141
  warnings.extend(status_warnings(conversation))
137
142
  for flag in conversation["flags"]:
@@ -1,9 +1,18 @@
1
- """Local lifecycle management for resident merchant-agent processes."""
1
+ """Local lifecycle management for resident merchant-agent processes.
2
+
3
+ Pure path-naming / id-sanitization / counter-coercion / error-classification
4
+ helpers moved move-only to :mod:`shopping_cli.agents.merchant_state_helpers`;
5
+ the public names are re-exported here (``as`` aliases) so
6
+ ``merchant_daemon.safe_merchant_id`` / ``agent_paths`` / ``safe_replied_count``
7
+ / ``permanent_agent_error`` / ``state_dir_from`` / ``DEFAULT_STATE_DIR`` keep
8
+ their exact surface, path naming, return types and call order. This module
9
+ still owns process start/stop, PID-file locking, signals, log rotation/tailing,
10
+ state file I/O and the process loop.
11
+ """
2
12
 
3
13
  from __future__ import annotations
4
14
 
5
15
  import json
6
- import hashlib
7
16
  import os
8
17
  import secrets
9
18
  import signal
@@ -30,10 +39,17 @@ except ImportError: # pragma: no cover - optional runtime dependency
30
39
  psutil = None
31
40
 
32
41
  from shopping_cli.agents import merchant_agent
42
+ from shopping_cli.agents.merchant_state_helpers import (
43
+ DEFAULT_STATE_DIR as DEFAULT_STATE_DIR,
44
+ agent_paths as agent_paths,
45
+ permanent_agent_error as permanent_agent_error,
46
+ safe_merchant_id as safe_merchant_id,
47
+ safe_replied_count as safe_replied_count,
48
+ state_dir_from as state_dir_from,
49
+ )
33
50
  from shopping_cli.db.session import db_session, decode_json, now_iso
34
51
  from shopping_cli.core.limits import safe_non_negative_int, safe_positive_float, safe_non_negative_float_with_max as safe_non_negative_float
35
52
 
36
- DEFAULT_STATE_DIR = Path.home() / ".local" / "state" / "shopping-cli"
37
53
  MAX_AGENT_INTERVAL_SECONDS = 3600.0
38
54
  MAX_AGENT_STOP_TIMEOUT_SECONDS = 300.0
39
55
  MAX_AGENT_LOG_TAIL = 1000
@@ -42,30 +58,6 @@ MAX_AGENT_LOG_BACKUPS = 3
42
58
  MAX_AGENT_ERROR_BACKOFF_SECONDS = 60.0
43
59
 
44
60
 
45
- def state_dir_from(value: str | Path | None = None) -> Path:
46
- return Path(value or os.environ.get("SHOPPING_CLI_STATE_DIR") or DEFAULT_STATE_DIR).expanduser()
47
-
48
-
49
- def safe_merchant_id(merchant_id: str) -> str:
50
- raw = str(merchant_id or "")
51
- slug = "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "_" for ch in raw).strip("._-")
52
- slug = (slug or "merchant")[:64]
53
- digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
54
- return f"{slug}-{digest}"
55
-
56
-
57
- def agent_paths(merchant_id: str, state_dir: str | Path | None = None) -> dict[str, Path]:
58
- root = state_dir_from(state_dir)
59
- safe_id = safe_merchant_id(merchant_id)
60
- return {
61
- "state_dir": root,
62
- "pid_file": root / "agents" / f"{safe_id}.pid",
63
- "state_file": root / "agents" / f"{safe_id}.state.json",
64
- "stop_file": root / "agents" / f"{safe_id}.stop",
65
- "log_file": root / "logs" / f"{safe_id}.log",
66
- }
67
-
68
-
69
61
  def ensure_agent_dirs(paths: dict[str, Path]) -> None:
70
62
  paths["pid_file"].parent.mkdir(parents=True, exist_ok=True)
71
63
  paths["state_file"].parent.mkdir(parents=True, exist_ok=True)
@@ -86,9 +78,25 @@ def rotate_agent_log(log_file: Path) -> bool:
86
78
  if source.exists():
87
79
  source.replace(log_file.with_suffix(log_file.suffix + f".{index + 1}"))
88
80
  log_file.replace(log_file.with_suffix(log_file.suffix + ".1"))
81
+ # 审查 P2-07:rename 保留源文件权限——旧日志若世界可读,备份必须 0600
82
+ for index in range(1, MAX_AGENT_LOG_BACKUPS + 1):
83
+ backup = log_file.with_suffix(log_file.suffix + f".{index}")
84
+ if backup.exists():
85
+ os.chmod(backup, 0o600)
89
86
  return True
90
87
 
91
88
 
89
+ def _open_log_append(log_path: Path):
90
+ """以 0600 创建/复用 daemon 日志文件。
91
+
92
+ 审查 P2-07:日志含会话内容,且 os.open 的 mode 只在新建时生效——对
93
+ 已存在文件也要显式 chmod 0600。
94
+ """
95
+ fd = os.open(str(log_path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
96
+ os.chmod(log_path, 0o600)
97
+ return os.fdopen(fd, "ab", buffering=0)
98
+
99
+
92
100
  def _redirect_stdout_to(log_path: Path) -> None:
93
101
  """Repoint stdout at a freshly rotated log file.
94
102
 
@@ -99,7 +107,10 @@ def _redirect_stdout_to(log_path: Path) -> None:
99
107
  target_fd = sys.stdout.fileno()
100
108
  except (OSError, ValueError):
101
109
  return
102
- fd = os.open(str(log_path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
110
+ # 审查 P3/P2-07:日志含会话内容——0600,避免多用户机器上世界可读;
111
+ # mode 只对新文件生效,已存在文件同样 chmod。
112
+ fd = os.open(str(log_path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
113
+ os.chmod(log_path, 0o600)
103
114
  try:
104
115
  os.dup2(fd, target_fd)
105
116
  finally:
@@ -137,24 +148,23 @@ def read_json(path: Path, default: Any) -> Any:
137
148
  return decoded
138
149
 
139
150
 
140
-
141
-
142
-
143
- def safe_replied_count(value: Any) -> int:
144
- if not isinstance(value, list):
145
- return 0
146
- return len(value)
147
-
148
-
149
- def permanent_agent_error(error: str) -> bool:
150
- lowered = str(error or "").lower()
151
- return any(marker in lowered for marker in ("invalid authorization", "revoked authorization", "expired authorization", "token required", "unknown merchant"))
152
-
153
-
154
151
  def write_json_atomic(path: Path, value: Any) -> None:
155
152
  path.parent.mkdir(parents=True, exist_ok=True)
156
153
  tmp = path.with_name(f"{path.name}.tmp")
157
154
  tmp.write_text(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
155
+ # 审查 P3:pid/state 文件含 launch_token —— 0600(此前继承 umask 0644
156
+ # 世界可读)。原子替换前 chmod,rename 后权限即为目标值。
157
+ os.chmod(tmp, 0o600)
158
+ tmp.replace(path)
159
+
160
+
161
+ def _write_private_text(path: Path, text: str) -> None:
162
+ """原子写凭据类文本文件并强制 0600(审查 P2-07:stop 文件含
163
+ launch_token,open() 默认 mode 继承 umask 可能世界可读)。"""
164
+ path.parent.mkdir(parents=True, exist_ok=True)
165
+ tmp = path.with_name(f"{path.name}.tmp")
166
+ tmp.write_text(text, encoding="utf-8")
167
+ os.chmod(tmp, 0o600)
158
168
  tmp.replace(path)
159
169
 
160
170
 
@@ -472,7 +482,9 @@ def start_agent(
472
482
  elif merchant_token:
473
483
  env["SHOPPING_MERCHANT_TOKEN"] = merchant_token
474
484
  env.pop("SHOPPING_AGENT_TOKEN", None)
475
- with paths["log_file"].open("ab", buffering=0) as log:
485
+ # 审查 P2-07:新建/已存在的日志文件都须 0600(open() 默认 mode 继承
486
+ # umask,多用户机器上可能 0644 世界可读)
487
+ with _open_log_append(paths["log_file"]) as log:
476
488
  process = subprocess.Popen(
477
489
  command,
478
490
  stdin=subprocess.DEVNULL,
@@ -568,8 +580,9 @@ def stop_agent(
568
580
  )
569
581
 
570
582
  if was_running:
571
- paths["stop_file"].parent.mkdir(parents=True, exist_ok=True)
572
- paths["stop_file"].write_text(launch_token, encoding="utf-8")
583
+ # 审查 P2-07:stop 文件含 launch_token——0600(open() 默认 mode 会
584
+ # 继承 umask 0644 世界可读)
585
+ _write_private_text(paths["stop_file"], launch_token)
573
586
  if psutil is not None:
574
587
  try:
575
588
  os.kill(pid, signal.SIGTERM)
@@ -0,0 +1,58 @@
1
+ """Pure merchant-daemon state helpers: path naming, id sanitization, counters.
2
+
3
+ Move-only extraction from :mod:`shopping_cli.agents.merchant_daemon`: this leaf
4
+ module owns the merchant-id sanitization (path isolation via slug + digest),
5
+ the state/log/pid/stop file path naming, the replied-count coercion and the
6
+ permanent-error classification. It never starts or stops processes, takes
7
+ locks, installs signal handlers, touches the database or network, writes files,
8
+ or drives state transitions — everything here is a pure function of its
9
+ inputs plus the ``SHOPPING_CLI_STATE_DIR`` environment override.
10
+
11
+ The parent module re-exports these names unchanged (``as`` aliases) so the
12
+ public ``merchant_daemon`` surface, path naming, return types and call order
13
+ are preserved.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import os
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ DEFAULT_STATE_DIR = Path.home() / ".local" / "state" / "shopping-cli"
24
+
25
+
26
+ def state_dir_from(value: str | Path | None = None) -> Path:
27
+ return Path(value or os.environ.get("SHOPPING_CLI_STATE_DIR") or DEFAULT_STATE_DIR).expanduser()
28
+
29
+
30
+ def safe_merchant_id(merchant_id: str) -> str:
31
+ raw = str(merchant_id or "")
32
+ slug = "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "_" for ch in raw).strip("._-")
33
+ slug = (slug or "merchant")[:64]
34
+ digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
35
+ return f"{slug}-{digest}"
36
+
37
+
38
+ def agent_paths(merchant_id: str, state_dir: str | Path | None = None) -> dict[str, Path]:
39
+ root = state_dir_from(state_dir)
40
+ safe_id = safe_merchant_id(merchant_id)
41
+ return {
42
+ "state_dir": root,
43
+ "pid_file": root / "agents" / f"{safe_id}.pid",
44
+ "state_file": root / "agents" / f"{safe_id}.state.json",
45
+ "stop_file": root / "agents" / f"{safe_id}.stop",
46
+ "log_file": root / "logs" / f"{safe_id}.log",
47
+ }
48
+
49
+
50
+ def safe_replied_count(value: Any) -> int:
51
+ if not isinstance(value, list):
52
+ return 0
53
+ return len(value)
54
+
55
+
56
+ def permanent_agent_error(error: str) -> bool:
57
+ lowered = str(error or "").lower()
58
+ return any(marker in lowered for marker in ("invalid authorization", "revoked authorization", "expired authorization", "token required", "unknown merchant"))
@@ -260,6 +260,7 @@ class HTTPMerchantAgentTools:
260
260
  merchant_token: str,
261
261
  timeout: float = 10.0,
262
262
  opener: Any | None = None,
263
+ transport: Any | None = None,
263
264
  host: str = "",
264
265
  session_id: str = "",
265
266
  allow_insecure_http: bool = False,
@@ -275,6 +276,7 @@ class HTTPMerchantAgentTools:
275
276
  self.merchant_token,
276
277
  timeout=timeout,
277
278
  opener=opener,
279
+ transport=transport,
278
280
  allow_insecure_http=allow_insecure_http,
279
281
  )
280
282
  self.base_url = self.http.base_url
@@ -0,0 +1,121 @@
1
+ """Marketplace API app factory.
2
+
3
+ FastAPI is used when installed. The lightweight fallback keeps route metadata
4
+ available for local tests in environments where optional API dependencies have
5
+ not been installed yet.
6
+
7
+ 装配拆分(move-only,行为不变):可执行路由表与 handler wrapper 在
8
+ ``api.route_table``(_ROUTE_TABLE / resolve_route),FastAPI 路由安装与
9
+ header 默认值在 ``api.fastapi_routes``(register_fastapi_routes)。本模块
10
+ 保留公共门面——create_app / handle_request / _ROUTE_TABLE / RouteEntry /
11
+ resolve_route / FastAPI / AUTHORIZATION_HEADER / IDEMPOTENCY_KEY_HEADER——
12
+ route_registry、fallback ASGI 与既有导入方不变。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from shopping_cli import VERSION
22
+ from shopping_cli.api.fallback_asgi import MarketplaceASGIApp
23
+ from shopping_cli.api.fastapi_routes import (
24
+ AUTHORIZATION_HEADER, # noqa: F401 - compat re-export for external importers
25
+ FastAPI,
26
+ Header, # noqa: F401 - compat re-export for external importers
27
+ IDEMPOTENCY_KEY_HEADER, # noqa: F401 - compat re-export for external importers
28
+ JSONResponse, # noqa: F401 - compat re-export for external importers
29
+ Request, # noqa: F401 - compat re-export for external importers
30
+ RequestValidationError, # noqa: F401 - compat re-export for external importers
31
+ Response, # noqa: F401 - compat re-export for external importers
32
+ StarletteHTTPException, # noqa: F401 - compat re-export for external importers
33
+ register_fastapi_routes,
34
+ )
35
+ from shopping_cli.api.limits import validate_payload
36
+ from shopping_cli.api.request_dispatch import (
37
+ RouteEntry, # noqa: F401 - compat re-export for external importers
38
+ dispatch_request,
39
+ )
40
+ from shopping_cli.api.request_limits import (
41
+ ASGIApp as _ASGIApp, # noqa: F401 - compat re-export for external importers
42
+ ASGIReceive as _ASGIReceive, # noqa: F401 - compat re-export for external importers
43
+ ASGISend as _ASGISend, # noqa: F401 - compat re-export for external importers
44
+ RequestBodyLimitMiddleware as _RequestBodyLimitMiddleware, # noqa: F401 - compat re-export for external importers
45
+ )
46
+ from shopping_cli.api.route_table import _ROUTE_TABLE, resolve_route
47
+ from shopping_cli.core.errors import (
48
+ AuthError,
49
+ ConflictError,
50
+ IdempotencyConflict,
51
+ MethodNotAllowedError,
52
+ NotFoundError,
53
+ PermissionDenied,
54
+ RateLimitError,
55
+ PayloadTooLargeError,
56
+ ShoppingCliError,
57
+ ValidationError,
58
+ )
59
+
60
+ logger = logging.getLogger("shopping-cli")
61
+
62
+
63
+ def handle_request(
64
+ db_path: str | Path,
65
+ method: str,
66
+ path: str,
67
+ payload: dict[str, Any] | None = None,
68
+ query: dict[str, Any] | None = None,
69
+ ) -> tuple[int, dict[str, Any]]:
70
+ payload = payload or {}
71
+ query = query or {}
72
+ try:
73
+ validate_payload(payload)
74
+ return dispatch_request(db_path, method, path, payload, query, _ROUTE_TABLE)
75
+ except AuthError as exc:
76
+ return 403, {"ok": False, "error": str(exc)}
77
+ except PermissionDenied as exc:
78
+ return 403, {"ok": False, "error": str(exc)}
79
+ except IdempotencyConflict as exc:
80
+ return 409, {"ok": False, "error": str(exc)}
81
+ except ConflictError as exc:
82
+ return 409, {"ok": False, "error": str(exc)}
83
+ except NotFoundError as exc:
84
+ return 404, {"ok": False, "error": str(exc)}
85
+ except RateLimitError as exc:
86
+ return 429, {"ok": False, "error": str(exc)}
87
+ except PayloadTooLargeError as exc:
88
+ return 413, {"ok": False, "error": str(exc)}
89
+ except MethodNotAllowedError as exc:
90
+ return 405, {"ok": False, "error": str(exc)}
91
+ except ValidationError as exc:
92
+ return 400, {"ok": False, "error": str(exc)}
93
+ except ShoppingCliError as exc:
94
+ return 400, {"ok": False, "error": str(exc)}
95
+ except Exception:
96
+ logger.exception("unhandled error handling %s %s", method, path)
97
+ return 500, {"ok": False, "error": "internal server error"}
98
+
99
+
100
+ def create_app(db_path: str | Path = "shopping-cli.sqlite") -> Any:
101
+ from shopping_cli.api.route_registry import route_info
102
+
103
+ routes = route_info()
104
+ if FastAPI is None:
105
+ return MarketplaceASGIApp(
106
+ db_path,
107
+ route_provider=lambda: routes,
108
+ route_resolver=lambda method, path: resolve_route(method, path, routes=routes),
109
+ )
110
+
111
+ app = FastAPI(
112
+ title="shopping-cli Marketplace API",
113
+ version=VERSION,
114
+ docs_url=None,
115
+ redoc_url=None,
116
+ openapi_url=None,
117
+ )
118
+ app.state.db_path = str(db_path)
119
+ app.state.fastapi_available = True
120
+ register_fastapi_routes(app, db_path)
121
+ return app
@@ -0,0 +1,117 @@
1
+ """FastAPI exception-handler registration for the marketplace API.
2
+
3
+ Centralizes the business exception → HTTP mapping (status code, JSON envelope,
4
+ error string) that :func:`shopping_cli.api.app.create_app` used to register
5
+ inline, so the transport layer only wires the registration in. This module has
6
+ no hard dependency on FastAPI: the response factory and the optional
7
+ Starlette/FastAPI exception types are injected by the caller, matching how the
8
+ dual API stack isolates optional imports (see ``app.py``'s try/except import
9
+ block).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from typing import Any
16
+
17
+ from shopping_cli.api.error_response import build_error_response
18
+ from shopping_cli.core.errors import (
19
+ AuthError,
20
+ ConflictError,
21
+ IdempotencyConflict,
22
+ MethodNotAllowedError,
23
+ NotFoundError,
24
+ PayloadTooLargeError,
25
+ PermissionDenied,
26
+ RateLimitError,
27
+ ShoppingCliError,
28
+ ValidationError,
29
+ )
30
+
31
+ logger = logging.getLogger("shopping-cli")
32
+
33
+
34
+ def _json_error_response(json_response: Any, status_code: int, error: str) -> Any:
35
+ return build_error_response(status_code, error, json_response)
36
+
37
+
38
+ def register_error_handlers(
39
+ app: Any,
40
+ *,
41
+ json_response: Any = None,
42
+ request_validation_error: Any = None,
43
+ starlette_http_exception: Any = None,
44
+ ) -> None:
45
+ """Register business and transport error handlers on *app*.
46
+
47
+ *app* must expose ``exception_handler`` (a FastAPI app or a test double).
48
+ ``json_response`` is the FastAPI ``JSONResponse`` class, or ``None`` when
49
+ FastAPI is not installed (handlers then return the fallback
50
+ ``SimpleNamespace`` envelope). ``request_validation_error`` and
51
+ ``starlette_http_exception`` are the optional FastAPI/Starlette exception
52
+ types; passing ``None`` skips those handlers, mirroring ``app.py``'s
53
+ conditional registration.
54
+
55
+ Registration order is load-bearing: specific business exceptions come
56
+ before their base classes and before the catch-all ``Exception`` handler,
57
+ so a first-match handler walk (as done by the FakeFastAPI test harness)
58
+ resolves a subclass to its own status rather than a base class's.
59
+ """
60
+ @app.exception_handler(AuthError)
61
+ def auth_error_handler(_request: Any, exc: AuthError) -> Any:
62
+ return _json_error_response(json_response, 403, str(exc))
63
+
64
+ @app.exception_handler(PermissionDenied)
65
+ def permission_denied_handler(_request: Any, exc: PermissionDenied) -> Any:
66
+ return _json_error_response(json_response, 403, str(exc))
67
+
68
+ @app.exception_handler(IdempotencyConflict)
69
+ def idempotency_conflict_handler(_request: Any, exc: IdempotencyConflict) -> Any:
70
+ return _json_error_response(json_response, 409, str(exc))
71
+
72
+ @app.exception_handler(ConflictError)
73
+ def conflict_error_handler(_request: Any, exc: ConflictError) -> Any:
74
+ return _json_error_response(json_response, 409, str(exc))
75
+
76
+ @app.exception_handler(NotFoundError)
77
+ def not_found_error_handler(_request: Any, exc: NotFoundError) -> Any:
78
+ return _json_error_response(json_response, 404, str(exc))
79
+
80
+ @app.exception_handler(RateLimitError)
81
+ def rate_limit_error_handler(_request: Any, exc: RateLimitError) -> Any:
82
+ return _json_error_response(json_response, 429, str(exc))
83
+
84
+ @app.exception_handler(PayloadTooLargeError)
85
+ def payload_too_large_handler(_request: Any, exc: PayloadTooLargeError) -> Any:
86
+ return _json_error_response(json_response, 413, str(exc))
87
+
88
+ @app.exception_handler(MethodNotAllowedError)
89
+ def method_not_allowed_handler(_request: Any, exc: MethodNotAllowedError) -> Any:
90
+ return _json_error_response(json_response, 405, str(exc))
91
+
92
+ @app.exception_handler(ValidationError)
93
+ def validation_error_handler(_request: Any, exc: ValidationError) -> Any:
94
+ return _json_error_response(json_response, 400, str(exc))
95
+
96
+ @app.exception_handler(ShoppingCliError)
97
+ def shopping_cli_error_handler(_request: Any, exc: ShoppingCliError) -> Any:
98
+ return _json_error_response(json_response, 400, str(exc))
99
+
100
+ if request_validation_error is not None: # pragma: no cover - exercised with fastapi installed
101
+ @app.exception_handler(request_validation_error)
102
+ def request_validation_error_handler(request: Any, exc: Exception) -> Any:
103
+ # 不回显 str(exc):包含 schema 内部结构并回显调用方输入。
104
+ logger.warning("request validation failed on %s: %s", getattr(request, "url", "?"), exc)
105
+ return _json_error_response(json_response, 400, "invalid request body")
106
+
107
+ if starlette_http_exception is not None: # pragma: no cover - exercised with fastapi installed
108
+ @app.exception_handler(starlette_http_exception)
109
+ def http_exception_handler(_request: Any, exc: Any) -> Any:
110
+ status = int(exc.status_code)
111
+ message = "not found" if status == 404 else "method not allowed" if status == 405 else str(exc.detail)
112
+ return _json_error_response(json_response, status, message)
113
+
114
+ @app.exception_handler(Exception)
115
+ def unexpected_error_handler(request: Any, exc: Exception) -> Any:
116
+ logger.exception("unhandled error on %s %s", getattr(request, "method", "?"), getattr(request, "url", "?"))
117
+ return _json_error_response(json_response, 500, "internal server error")
@@ -0,0 +1,22 @@
1
+ """Transport-only error response construction for the dual API stack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from types import SimpleNamespace
7
+ from typing import Any, Callable
8
+
9
+
10
+ def build_error_response(
11
+ status_code: int,
12
+ error: str,
13
+ response_factory: Callable[..., Any] | None = None,
14
+ ) -> Any:
15
+ """Build the existing JSON error envelope for FastAPI or fallback mode."""
16
+ payload = {"ok": False, "error": error}
17
+ if response_factory is not None:
18
+ return response_factory(status_code=status_code, content=payload)
19
+ return SimpleNamespace(
20
+ status_code=status_code,
21
+ body=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
22
+ )