city-cli 0.9.1__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 (67) hide show
  1. city_cli-0.9.1/.gitignore +73 -0
  2. city_cli-0.9.1/PKG-INFO +111 -0
  3. city_cli-0.9.1/README.md +82 -0
  4. city_cli-0.9.1/VERSION +1 -0
  5. city_cli-0.9.1/pyproject.toml +44 -0
  6. city_cli-0.9.1/src/city/__init__.py +1 -0
  7. city_cli-0.9.1/src/city/testkit/__init__.py +5 -0
  8. city_cli-0.9.1/src/city/testkit/keys.py +9 -0
  9. city_cli-0.9.1/src/city/testkit/py.typed +0 -0
  10. city_cli-0.9.1/src/city_conform/__init__.py +3 -0
  11. city_cli-0.9.1/src/city_conform/__main__.py +21 -0
  12. city_cli-0.9.1/src/city_conform/agent.py +283 -0
  13. city_cli-0.9.1/src/city_conform/minimarket.py +66 -0
  14. city_cli-0.9.1/src/city_conform/remote.py +559 -0
  15. city_cli-0.9.1/src/city_conform/report.py +50 -0
  16. city_cli-0.9.1/src/city_conform/runner.py +122 -0
  17. city_cli-0.9.1/src/city_conform/stages.py +457 -0
  18. city_cli-0.9.1/src/city_conform/world.py +117 -0
  19. city_cli-0.9.1/src/city_deploy/__init__.py +3 -0
  20. city_cli-0.9.1/src/city_deploy/__main__.py +6 -0
  21. city_cli-0.9.1/src/city_deploy/api.py +282 -0
  22. city_cli-0.9.1/src/city_deploy/cli.py +554 -0
  23. city_cli-0.9.1/src/city_deploy/clock.py +28 -0
  24. city_cli-0.9.1/src/city_deploy/conform.py +105 -0
  25. city_cli-0.9.1/src/city_deploy/errors.py +29 -0
  26. city_cli-0.9.1/src/city_deploy/local.py +89 -0
  27. city_cli-0.9.1/src/city_deploy/quota.py +48 -0
  28. city_cli-0.9.1/src/city_deploy/receipt.py +34 -0
  29. city_cli-0.9.1/src/city_deploy/runtime.py +724 -0
  30. city_cli-0.9.1/src/city_deploy/secrets.py +125 -0
  31. city_cli-0.9.1/src/city_deploy/serve.py +45 -0
  32. city_cli-0.9.1/src/city_deploy/service.py +943 -0
  33. city_cli-0.9.1/src/city_deploy/skeleton.py +298 -0
  34. city_cli-0.9.1/src/city_deploy/state.py +10 -0
  35. city_cli-0.9.1/src/city_deploy/templates/b2b/Dockerfile +9 -0
  36. city_cli-0.9.1/src/city_deploy/templates/b2b/SKILL.md +3 -0
  37. city_cli-0.9.1/src/city_deploy/templates/b2b/brain/brain.py +8 -0
  38. city_cli-0.9.1/src/city_deploy/templates/b2b/card.yaml +10 -0
  39. city_cli-0.9.1/src/city_deploy/templates/b2b/city.yaml +7 -0
  40. city_cli-0.9.1/src/city_deploy/templates/cup/Dockerfile +9 -0
  41. city_cli-0.9.1/src/city_deploy/templates/cup/SKILL.md +5 -0
  42. city_cli-0.9.1/src/city_deploy/templates/cup/brain/brain.py +11 -0
  43. city_cli-0.9.1/src/city_deploy/templates/cup/card.yaml +14 -0
  44. city_cli-0.9.1/src/city_deploy/templates/cup/city.yaml +7 -0
  45. city_cli-0.9.1/src/city_deploy/templates/facility/Dockerfile +9 -0
  46. city_cli-0.9.1/src/city_deploy/templates/facility/SKILL.md +3 -0
  47. city_cli-0.9.1/src/city_deploy/templates/facility/brain/brain.py +7 -0
  48. city_cli-0.9.1/src/city_deploy/templates/facility/card.yaml +16 -0
  49. city_cli-0.9.1/src/city_deploy/templates/facility/city.yaml +7 -0
  50. city_cli-0.9.1/src/city_deploy/templates/mmg/Dockerfile +9 -0
  51. city_cli-0.9.1/src/city_deploy/templates/mmg/SKILL.md +3 -0
  52. city_cli-0.9.1/src/city_deploy/templates/mmg/brain/brain.py +6 -0
  53. city_cli-0.9.1/src/city_deploy/templates/mmg/card.yaml +10 -0
  54. city_cli-0.9.1/src/city_deploy/templates/mmg/city.yaml +7 -0
  55. city_cli-0.9.1/src/city_index/__init__.py +3 -0
  56. city_cli-0.9.1/src/city_index/__main__.py +24 -0
  57. city_cli-0.9.1/src/city_index/app.py +109 -0
  58. city_cli-0.9.1/src/city_index/auth.py +15 -0
  59. city_cli-0.9.1/src/city_index/cache.py +43 -0
  60. city_cli-0.9.1/src/city_index/catalog.py +233 -0
  61. city_cli-0.9.1/src/city_index/events.py +38 -0
  62. city_cli-0.9.1/src/city_index/model.py +43 -0
  63. city_cli-0.9.1/src/city_index/ports.py +15 -0
  64. city_cli-0.9.1/src/city_index/query.py +91 -0
  65. city_cli-0.9.1/src/city_index/service.py +201 -0
  66. city_cli-0.9.1/src/city_index/visibility.py +52 -0
  67. city_cli-0.9.1/src/city_index/watch.py +35 -0
@@ -0,0 +1,73 @@
1
+ # Terraform
2
+ **/.terraform/
3
+ *.tfstate
4
+ *.tfstate.backup
5
+ *.tfstate.lock.info
6
+ crash.log
7
+ crash.*.log
8
+ override.tf
9
+ override.tf.json
10
+ *_override.tf
11
+ *_override.tf.json
12
+ *.tfplan
13
+ *.tfplan.json
14
+ *.pyc
15
+ __pycache__/
16
+
17
+ # Local city CLI keystore and team token
18
+ .city/
19
+
20
+ # Terraform variable files with secrets — keep *.tfvars.example, ignore actual values
21
+ *.tfvars
22
+ *.tfvars.json
23
+
24
+ # Terragrunt cache
25
+ **/.terragrunt-cache/
26
+
27
+ # Provider lock files (optional: remove if you want to pin providers in VCS)
28
+ # **/.terraform.lock.hcl
29
+
30
+ # GCP service account key files
31
+ *.json
32
+ !**/cloudbuild*.json
33
+ # Firebase web app configs are public client identifiers (not secrets)
34
+ !**/firebase-config*.json
35
+ # Contract schemas and examples are source, not credentials
36
+ !specs/**/*.json
37
+
38
+ # macOS
39
+ .DS_Store
40
+ .AppleDouble
41
+ .LSOverride
42
+
43
+ # Editor
44
+ .idea/
45
+ .vscode/
46
+ *.swp
47
+ *.swo
48
+ *~
49
+
50
+ # Node.js / Next.js config files
51
+ !package-lock.json
52
+ !package.json
53
+ !tsconfig.json
54
+ !next.config.mjs
55
+ !next.config.js
56
+ node_modules/
57
+ services/ui/dist/
58
+ services/ui/.env
59
+ services/ui/.env.local
60
+ services/ui/.env.*.local
61
+ .venv/
62
+ .mypy_cache/
63
+ .ruff_cache/
64
+ .pytest_cache/
65
+
66
+ # Elementor kit workdir / local backups (keep the published zip + README tracked)
67
+ landing/elementor-export/.kit-work/
68
+ landing/elementor-export/*.bak.zip
69
+ landing/elementor-export/migrate_to_native.py
70
+ landing/elementor-export/pni-elementor-kit.zip
71
+ landing/elementor-export/README.md
72
+ .publish/
73
+ dist-pypi/
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.5
2
+ Name: city-cli
3
+ Version: 0.9.1
4
+ Summary: Milano Agentic City CLI (city): init, dev, conform, and deploy.
5
+ Project-URL: Homepage, https://github.com/projectnandaitaly/agentic-city
6
+ Project-URL: Repository, https://github.com/projectnandaitaly/agentic-city
7
+ Project-URL: Documentation, https://github.com/projectnandaitaly/agentic-city/blob/main/services/deploy/README.md
8
+ Project-URL: Issues, https://github.com/projectnandaitaly/agentic-city/issues
9
+ Author: Project NANDA Italy
10
+ License: Proprietary
11
+ Keywords: agentic-city,cli,deploy,nanda
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: city-sdk>=0.9.1
21
+ Requires-Dist: cryptography>=43
22
+ Requires-Dist: fastapi>=0.115
23
+ Requires-Dist: httpx>=0.27
24
+ Requires-Dist: pydantic>=2
25
+ Requires-Dist: rfc8785>=0.1.4
26
+ Requires-Dist: typer>=0.12
27
+ Requires-Dist: uvicorn>=0.32
28
+ Description-Content-Type: text/markdown
29
+
30
+ # city-cli
31
+
32
+ Command-line tool for **Milano Agentic City** teams.
33
+
34
+ Install once, then scaffold an agent, run it locally, and publish it through the City Deploy API — **without GCP credentials** (ADR-010).
35
+
36
+ ## Requirements
37
+
38
+ - Python **3.12+**
39
+ - For live deploy: Deploy API URL (see below)
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pipx install city-cli
45
+ # or
46
+ uv tool install city-cli
47
+ # or
48
+ pip install city-cli
49
+ ```
50
+
51
+ This provides the `city` command. It depends on [`city-sdk`](https://pypi.org/project/city-sdk/) for the Market facade and harness types.
52
+
53
+ ## Live city (remote Deploy API)
54
+
55
+ ```bash
56
+ export CITY_DEPLOY_URL=https://deploy.projectnandaitaly.ai
57
+
58
+ city login --team t07
59
+ city keys init
60
+ city init --role cup --name demo
61
+ city card validate
62
+ city deploy --promote
63
+ city status agent:t07/demo
64
+ ```
65
+
66
+ Per-command override:
67
+
68
+ ```bash
69
+ city --api-url https://deploy.projectnandaitaly.ai status agent:t07/demo
70
+ ```
71
+
72
+ JSON for scripting:
73
+
74
+ ```bash
75
+ city --json --api-url "$CITY_DEPLOY_URL" status agent:t07/demo
76
+ ```
77
+
78
+ ## Local kit (no API URL)
79
+
80
+ If `CITY_DEPLOY_URL` / `--api-url` is unset, `city` uses an **in-process** Deploy service (useful for `init`, `dev`, and offline checks).
81
+
82
+ ```bash
83
+ city init --role cup --name demo
84
+ city dev
85
+ city conform --profile fast
86
+ ```
87
+
88
+ ## Commands (overview)
89
+
90
+ | Command | Purpose |
91
+ |---|---|
92
+ | `city login` | Device-flow team session; stores token under `.city/` |
93
+ | `city keys init` | Local controller key + register public key |
94
+ | `city init` | Scaffold `cup` / `facility` / `mmg` / `b2b` template |
95
+ | `city card validate` | Basic card.yaml checks |
96
+ | `city dev` | One local need through the harness |
97
+ | `city conform` | Conformance suite (`--profile full\|fast`, or `--url`) |
98
+ | `city deploy` | Upload + pipeline (`--promote`, `--runtime`) |
99
+ | `city promote` / `rollback` / `status` / `retire` | Lifecycle |
100
+ | `city secrets set` / `city scale` / `city llm key rotate` | Ops |
101
+ | `city compliance` / `city verify` | Receipts and offline verify |
102
+
103
+ ## Links
104
+
105
+ - Source: [projectnandaitaly/agentic-city](https://github.com/projectnandaitaly/agentic-city)
106
+ - SDK: [city-sdk on PyPI](https://pypi.org/project/city-sdk/)
107
+ - Spec: City Deploy CLI §10.2 in the repo docs
108
+
109
+ ## License
110
+
111
+ Proprietary — Project NANDA Italy / Milano Agentic City event kit.
@@ -0,0 +1,82 @@
1
+ # city-cli
2
+
3
+ Command-line tool for **Milano Agentic City** teams.
4
+
5
+ Install once, then scaffold an agent, run it locally, and publish it through the City Deploy API — **without GCP credentials** (ADR-010).
6
+
7
+ ## Requirements
8
+
9
+ - Python **3.12+**
10
+ - For live deploy: Deploy API URL (see below)
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pipx install city-cli
16
+ # or
17
+ uv tool install city-cli
18
+ # or
19
+ pip install city-cli
20
+ ```
21
+
22
+ This provides the `city` command. It depends on [`city-sdk`](https://pypi.org/project/city-sdk/) for the Market facade and harness types.
23
+
24
+ ## Live city (remote Deploy API)
25
+
26
+ ```bash
27
+ export CITY_DEPLOY_URL=https://deploy.projectnandaitaly.ai
28
+
29
+ city login --team t07
30
+ city keys init
31
+ city init --role cup --name demo
32
+ city card validate
33
+ city deploy --promote
34
+ city status agent:t07/demo
35
+ ```
36
+
37
+ Per-command override:
38
+
39
+ ```bash
40
+ city --api-url https://deploy.projectnandaitaly.ai status agent:t07/demo
41
+ ```
42
+
43
+ JSON for scripting:
44
+
45
+ ```bash
46
+ city --json --api-url "$CITY_DEPLOY_URL" status agent:t07/demo
47
+ ```
48
+
49
+ ## Local kit (no API URL)
50
+
51
+ If `CITY_DEPLOY_URL` / `--api-url` is unset, `city` uses an **in-process** Deploy service (useful for `init`, `dev`, and offline checks).
52
+
53
+ ```bash
54
+ city init --role cup --name demo
55
+ city dev
56
+ city conform --profile fast
57
+ ```
58
+
59
+ ## Commands (overview)
60
+
61
+ | Command | Purpose |
62
+ |---|---|
63
+ | `city login` | Device-flow team session; stores token under `.city/` |
64
+ | `city keys init` | Local controller key + register public key |
65
+ | `city init` | Scaffold `cup` / `facility` / `mmg` / `b2b` template |
66
+ | `city card validate` | Basic card.yaml checks |
67
+ | `city dev` | One local need through the harness |
68
+ | `city conform` | Conformance suite (`--profile full\|fast`, or `--url`) |
69
+ | `city deploy` | Upload + pipeline (`--promote`, `--runtime`) |
70
+ | `city promote` / `rollback` / `status` / `retire` | Lifecycle |
71
+ | `city secrets set` / `city scale` / `city llm key rotate` | Ops |
72
+ | `city compliance` / `city verify` | Receipts and offline verify |
73
+
74
+ ## Links
75
+
76
+ - Source: [projectnandaitaly/agentic-city](https://github.com/projectnandaitaly/agentic-city)
77
+ - SDK: [city-sdk on PyPI](https://pypi.org/project/city-sdk/)
78
+ - Spec: City Deploy CLI §10.2 in the repo docs
79
+
80
+ ## License
81
+
82
+ Proprietary — Project NANDA Italy / Milano Agentic City event kit.
city_cli-0.9.1/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.9.1
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "city-cli"
3
+ version = "0.9.1"
4
+ description = "Milano Agentic City CLI (city): init, dev, conform, and deploy."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "Proprietary" }
8
+ authors = [{ name = "Project NANDA Italy" }]
9
+ keywords = ["agentic-city", "cli", "deploy", "nanda"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Topic :: Software Development :: Libraries",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [
20
+ "city-sdk>=0.9.1",
21
+ "cryptography>=43",
22
+ "fastapi>=0.115",
23
+ "httpx>=0.27",
24
+ "pydantic>=2",
25
+ "rfc8785>=0.1.4",
26
+ "typer>=0.12",
27
+ "uvicorn>=0.32",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/projectnandaitaly/agentic-city"
32
+ Repository = "https://github.com/projectnandaitaly/agentic-city"
33
+ Documentation = "https://github.com/projectnandaitaly/agentic-city/blob/main/services/deploy/README.md"
34
+ Issues = "https://github.com/projectnandaitaly/agentic-city/issues"
35
+
36
+ [project.scripts]
37
+ city = "city_deploy.cli:main"
38
+
39
+ [build-system]
40
+ requires = ["hatchling"]
41
+ build-backend = "hatchling.build"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/city_deploy", "src/city_conform", "src/city_index", "src/city"]
@@ -0,0 +1 @@
1
+ """City libraries (``city.testkit``, later reputation and others)."""
@@ -0,0 +1,5 @@
1
+ """Test helpers. Ephemeral keys only; nothing here is a credential store."""
2
+
3
+ from city.testkit.keys import ephemeral
4
+
5
+ __all__ = ["ephemeral"]
@@ -0,0 +1,9 @@
1
+ """Generate an Ed25519 key pair in memory. The private key is not serialized."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cap.core.keys import KeyPair, generate
6
+
7
+
8
+ def ephemeral() -> KeyPair:
9
+ return generate()
File without changes
@@ -0,0 +1,3 @@
1
+ """Conformance runner. Outcomes follow specification 09."""
2
+
3
+ __version__ = "0.9.0"
@@ -0,0 +1,21 @@
1
+ """`city-conform` runs the in-process full profile and prints the report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from city_conform.report import render
8
+ from city_conform.runner import run_profile
9
+ from city_conform.stages import CATALOG
10
+ from city_conform.world import good_agent
11
+
12
+
13
+ def main() -> None:
14
+ async def _run() -> bool:
15
+ agent = await good_agent()
16
+ report = await run_profile(agent, CATALOG)
17
+ print(render(report), end="")
18
+ return report.passed
19
+
20
+ if not asyncio.run(_run()):
21
+ raise SystemExit(1)
@@ -0,0 +1,283 @@
1
+ """Agent under test. The live agent is the harness behind ASGI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timedelta
8
+ from typing import Any, Protocol, cast
9
+
10
+ from cap.core.envelope import build, sign
11
+ from cap.core.keys import PrivateKey, PublicKey
12
+ from cap.core.timeutil import format_time
13
+ from city_sdk.app import create_app
14
+ from city_sdk.harness import Harness
15
+ from httpx import ASGITransport, AsyncClient
16
+
17
+ Sender = tuple[str, PrivateKey]
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class SuiteSigners:
22
+ """City and peer keys the agent already trusts. The runner does not invent them."""
23
+
24
+ sender: Sender
25
+ prober: Sender
26
+ city: Sender
27
+ stranger: Sender
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class HttpResult:
32
+ status: int
33
+ body: dict[str, Any]
34
+
35
+
36
+ class Agent(Protocol):
37
+ def now(self) -> datetime: ...
38
+
39
+ def public_key(self) -> PublicKey: ...
40
+
41
+ def effect_count(self) -> int: ...
42
+
43
+ def ack_log(self) -> list[tuple[str, int, str]]: ...
44
+
45
+ def renew_log(self) -> list[tuple[str, int]]: ...
46
+
47
+ def sent_log(self) -> list[tuple[str, dict[str, object], str | None]]: ...
48
+
49
+ def hold_log(self) -> list[tuple[str, str]]: ...
50
+
51
+ def memory_view(self) -> dict[str, str]: ...
52
+
53
+ def escalation(self) -> str | None: ...
54
+
55
+ async def stop(self) -> None: ...
56
+
57
+ async def request(
58
+ self, method: str, path: str, payload: dict[str, Any] | None = None
59
+ ) -> HttpResult: ...
60
+
61
+ def need(
62
+ self,
63
+ *,
64
+ body: dict[str, Any],
65
+ msg_id: str | None = None,
66
+ fence: int = 1,
67
+ expires_at: datetime | None = None,
68
+ ) -> dict[str, Any]: ...
69
+
70
+ def unsigned_need(self) -> dict[str, Any]: ...
71
+
72
+ def bad_signature_need(self) -> dict[str, Any]: ...
73
+
74
+ def unknown_key_need(self) -> dict[str, Any]: ...
75
+
76
+ def expired_need(self) -> dict[str, Any]: ...
77
+
78
+ def probe(self, nonce: str) -> dict[str, Any]: ...
79
+
80
+ def order(self, *, epoch: int, kind: str, sender: Sender | None = None) -> dict[str, Any]: ...
81
+
82
+ def foreign_order(self) -> dict[str, Any]: ...
83
+
84
+
85
+ def _tamper(token: str) -> str:
86
+ first = token[0]
87
+ replacement = "A" if first != "A" else "B"
88
+ return replacement + token[1:]
89
+
90
+
91
+ class LiveAgent:
92
+ def __init__(
93
+ self,
94
+ harness: Harness,
95
+ *,
96
+ sender: Sender,
97
+ prober: Sender,
98
+ city: Sender,
99
+ stranger: Sender,
100
+ ) -> None:
101
+ self.harness = harness
102
+ self._sender = sender
103
+ self._prober = prober
104
+ self._city = city
105
+ self._stranger = stranger
106
+ self._app = create_app(harness)
107
+
108
+ def now(self) -> datetime:
109
+ return self.harness.clock.now()
110
+
111
+ def public_key(self) -> PublicKey:
112
+ return self.harness.public
113
+
114
+ def effect_count(self) -> int:
115
+ return self.harness.brain_calls
116
+
117
+ def ack_log(self) -> list[tuple[str, int, str]]:
118
+ return list(self.harness.relay.acks)
119
+
120
+ def renew_log(self) -> list[tuple[str, int]]:
121
+ return list(self.harness.relay.renewed)
122
+
123
+ def sent_log(self) -> list[tuple[str, dict[str, object], str | None]]:
124
+ return list(self.harness.relay.sent)
125
+
126
+ def hold_log(self) -> list[tuple[str, str]]:
127
+ return list(self.harness.ledger.calls)
128
+
129
+ def memory_view(self) -> dict[str, str]:
130
+ return dict(self.harness.memory)
131
+
132
+ def escalation(self) -> str | None:
133
+ market = self.harness.last_market
134
+ if market is None:
135
+ return None
136
+ return market.escalated
137
+
138
+ async def stop(self) -> None:
139
+ await self.harness.shutdown()
140
+
141
+ async def request(
142
+ self, method: str, path: str, payload: dict[str, Any] | None = None
143
+ ) -> HttpResult:
144
+ async with AsyncClient(
145
+ transport=ASGITransport(app=self._app),
146
+ base_url="http://agent",
147
+ ) as client:
148
+ if method == "GET":
149
+ response = await client.get(path)
150
+ else:
151
+ response = await client.post(path, json=payload)
152
+ body: dict[str, Any] = {}
153
+ try:
154
+ parsed = response.json()
155
+ except ValueError:
156
+ parsed = None
157
+ if isinstance(parsed, dict):
158
+ body = parsed
159
+ return HttpResult(response.status_code, body)
160
+
161
+ def need(
162
+ self,
163
+ *,
164
+ body: dict[str, Any],
165
+ msg_id: str | None = None,
166
+ fence: int = 1,
167
+ expires_at: datetime | None = None,
168
+ ) -> dict[str, Any]:
169
+ return self._delivery(
170
+ self._sender,
171
+ "need.request",
172
+ body,
173
+ msg_id=msg_id,
174
+ fence=fence,
175
+ expires_at=expires_at,
176
+ )
177
+
178
+ def unsigned_need(self) -> dict[str, Any]:
179
+ msg_id = str(uuid.uuid4())
180
+ envelope = build(
181
+ msg_id=msg_id,
182
+ message_type="need.request",
183
+ sender=self._sender[0],
184
+ recipient=self.harness.agent_id,
185
+ sent_at=self.now(),
186
+ body={"service": "lab", "summary": "visit"},
187
+ )
188
+ return self._wrap(msg_id, envelope, fence=1)
189
+
190
+ def bad_signature_need(self) -> dict[str, Any]:
191
+ payload = self.need(body={"service": "lab", "summary": "visit"})
192
+ envelope = payload["envelope"]
193
+ sig = dict(envelope["sig"])
194
+ sig["jws"] = _tamper(str(sig["jws"]))
195
+ envelope["sig"] = sig
196
+ return payload
197
+
198
+ def unknown_key_need(self) -> dict[str, Any]:
199
+ return self._delivery(
200
+ self._stranger,
201
+ "need.request",
202
+ {"service": "lab", "summary": "visit"},
203
+ )
204
+
205
+ def expired_need(self) -> dict[str, Any]:
206
+ return self.need(
207
+ body={"service": "lab", "summary": "visit"},
208
+ expires_at=self.now() - timedelta(seconds=5),
209
+ )
210
+
211
+ def probe(self, nonce: str) -> dict[str, Any]:
212
+ return self._delivery(self._prober, "cap.probe", {"nonce": nonce})
213
+
214
+ def order(self, *, epoch: int, kind: str, sender: Sender | None = None) -> dict[str, Any]:
215
+ who = self._city if sender is None else sender
216
+ msg_id = str(uuid.uuid4())
217
+ envelope = sign(
218
+ build(
219
+ msg_id=msg_id,
220
+ message_type="city.order",
221
+ sender=who[0],
222
+ recipient=self.harness.agent_id,
223
+ sent_at=self.now(),
224
+ body={
225
+ "order_id": f"ord:{epoch}:001",
226
+ "epoch": epoch,
227
+ "kind": kind,
228
+ "requires_ack": True,
229
+ },
230
+ ),
231
+ who[1],
232
+ kid=who[0] + "#k1",
233
+ )
234
+ return cast(dict[str, Any], envelope)
235
+
236
+ def foreign_order(self) -> dict[str, Any]:
237
+ return self.order(epoch=1, kind="ping", sender=self._sender)
238
+
239
+ def suite_signers(self) -> SuiteSigners:
240
+ return SuiteSigners(
241
+ sender=self._sender,
242
+ prober=self._prober,
243
+ city=self._city,
244
+ stranger=self._stranger,
245
+ )
246
+
247
+ def _delivery(
248
+ self,
249
+ sender: Sender,
250
+ message_type: str,
251
+ body: dict[str, Any],
252
+ *,
253
+ msg_id: str | None = None,
254
+ fence: int = 1,
255
+ expires_at: datetime | None = None,
256
+ ) -> dict[str, Any]:
257
+ chosen = msg_id if msg_id is not None else str(uuid.uuid4())
258
+ envelope = sign(
259
+ build(
260
+ msg_id=chosen,
261
+ message_type=message_type,
262
+ sender=sender[0],
263
+ recipient=self.harness.agent_id,
264
+ sent_at=self.now(),
265
+ body=body,
266
+ expires_at=expires_at,
267
+ ),
268
+ sender[1],
269
+ kid=sender[0] + "#k1",
270
+ )
271
+ return self._wrap(chosen, envelope, fence=fence)
272
+
273
+ def _wrap(self, msg_id: str, envelope: dict[str, Any], *, fence: int) -> dict[str, Any]:
274
+ return {
275
+ "delivery": {
276
+ "msg_id": msg_id,
277
+ "mailbox": "m-1",
278
+ "fence": fence,
279
+ "attempt": 1,
280
+ "lease_until": format_time(self.now()),
281
+ },
282
+ "envelope": envelope,
283
+ }