geyser-open 0.1.0__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.
- geyser_open-0.1.0/.gitignore +24 -0
- geyser_open-0.1.0/PKG-INFO +34 -0
- geyser_open-0.1.0/README.md +7 -0
- geyser_open-0.1.0/pyproject.toml +45 -0
- geyser_open-0.1.0/src/geyser_cli/__init__.py +3 -0
- geyser_open-0.1.0/src/geyser_cli/__main__.py +375 -0
- geyser_open-0.1.0/src/geyser_cli/auth.py +100 -0
- geyser_open-0.1.0/src/geyser_cli/credentials.py +97 -0
- geyser_open-0.1.0/src/geyser_cli/extensions.py +151 -0
- geyser_open-0.1.0/src/geyser_cli/output.py +31 -0
- geyser_open-0.1.0/src/geyser_cli/scaffolds.py +120 -0
- geyser_open-0.1.0/tests/test_auth.py +67 -0
- geyser_open-0.1.0/tests/test_cli_local.py +94 -0
- geyser_open-0.1.0/tests/test_cli_network.py +127 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.pytest_cache/
|
|
6
|
+
.mypy_cache/
|
|
7
|
+
.ruff_cache/
|
|
8
|
+
.coverage
|
|
9
|
+
htmlcov/
|
|
10
|
+
dist/
|
|
11
|
+
build/
|
|
12
|
+
.geyser/
|
|
13
|
+
.release-build/
|
|
14
|
+
.release-python/
|
|
15
|
+
release-standalone/
|
|
16
|
+
release-assets/
|
|
17
|
+
*.tar.gz
|
|
18
|
+
*.whl
|
|
19
|
+
*.sig
|
|
20
|
+
*.crt
|
|
21
|
+
*.pem
|
|
22
|
+
*.key
|
|
23
|
+
coverage.xml
|
|
24
|
+
site/
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: geyser-open
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Public developer CLI for durable Geyser agents
|
|
5
|
+
Project-URL: Homepage, https://geyserlabs.ai/developers
|
|
6
|
+
Project-URL: Documentation, https://docs.geyserlabs.ai
|
|
7
|
+
Project-URL: Repository, https://github.com/geyserlabs/geyser-open
|
|
8
|
+
Project-URL: Security, https://github.com/geyserlabs/geyser-open/security/policy
|
|
9
|
+
Author-email: Geyser Labs <security@geyserlabs.ai>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: geyser-sdk==0.1.0
|
|
21
|
+
Requires-Dist: keyring<26,>=25.6
|
|
22
|
+
Requires-Dist: platformdirs<5,>=4.3
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest-asyncio<2,>=0.24; extra == 'test'
|
|
25
|
+
Requires-Dist: pytest<10,>=8; extra == 'test'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# geyser-open
|
|
29
|
+
|
|
30
|
+
Installs the `geyser` command for scaffolding, validating, testing, packaging,
|
|
31
|
+
signing, staging, canarying, and inspecting durable Geyser work.
|
|
32
|
+
|
|
33
|
+
`--yes` skips only the local confirmation prompt. It never bypasses server
|
|
34
|
+
scopes, tenant policy, approval binding, qualification, or promotion gates.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# geyser-open
|
|
2
|
+
|
|
3
|
+
Installs the `geyser` command for scaffolding, validating, testing, packaging,
|
|
4
|
+
signing, staging, canarying, and inspecting durable Geyser work.
|
|
5
|
+
|
|
6
|
+
`--yes` skips only the local confirmation prompt. It never bypasses server
|
|
7
|
+
scopes, tenant policy, approval binding, qualification, or promotion gates.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "geyser-open"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Public developer CLI for durable Geyser agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Geyser Labs", email = "security@geyserlabs.ai" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"geyser-sdk==0.1.0",
|
|
25
|
+
"keyring>=25.6,<26",
|
|
26
|
+
"platformdirs>=4.3,<5",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
test = ["pytest>=8,<10", "pytest-asyncio>=0.24,<2"]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
geyser = "geyser_cli.__main__:main"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://geyserlabs.ai/developers"
|
|
37
|
+
Documentation = "https://docs.geyserlabs.ai"
|
|
38
|
+
Repository = "https://github.com/geyserlabs/geyser-open"
|
|
39
|
+
Security = "https://github.com/geyserlabs/geyser-open/security/policy"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/geyser_cli"]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.sdist]
|
|
45
|
+
include = ["src/geyser_cli", "tests", "README.md", "pyproject.toml"]
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Command line entry point for the public Geyser developer platform."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from geyser_sdk import (
|
|
19
|
+
ApprovalDecision,
|
|
20
|
+
CancelRequest,
|
|
21
|
+
ForkCreate,
|
|
22
|
+
GeyserClient,
|
|
23
|
+
LocalEmulator,
|
|
24
|
+
PackagePromotion,
|
|
25
|
+
PackageUpload,
|
|
26
|
+
ProblemError,
|
|
27
|
+
TypedTask,
|
|
28
|
+
bytes_digest,
|
|
29
|
+
normalize_contract,
|
|
30
|
+
validate_outcome,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
from . import __version__
|
|
34
|
+
from .auth import DeviceAuthorization, login_device, login_service_token
|
|
35
|
+
from .credentials import CredentialStore
|
|
36
|
+
from .extensions import inspect_archive, package_extension, test_extension, validate_extension
|
|
37
|
+
from .output import emit
|
|
38
|
+
from .scaffolds import KINDS, scaffold
|
|
39
|
+
|
|
40
|
+
DEFAULT_API_URL = "https://agents.geyserlabs.ai"
|
|
41
|
+
DEFAULT_SCOPES = ["development:read", "runs:read", "packages:upload", "packages:stage"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _add_path(command: argparse.ArgumentParser) -> None:
|
|
45
|
+
command.add_argument("path", nargs="?", type=Path, default=Path.cwd())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parser() -> argparse.ArgumentParser:
|
|
49
|
+
parser = argparse.ArgumentParser(prog="geyser", description="Build governed durable agents.")
|
|
50
|
+
parser.add_argument("--api-url", default=os.getenv("GEYSER_API_URL", DEFAULT_API_URL))
|
|
51
|
+
parser.add_argument("--profile", default=os.getenv("GEYSER_PROFILE", "default"))
|
|
52
|
+
parser.add_argument("--json", action="store_true", help="emit stable JSON")
|
|
53
|
+
parser.add_argument("--allow-file-credentials", action="store_true")
|
|
54
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
55
|
+
|
|
56
|
+
login = commands.add_parser("login", help="authenticate with OAuth device flow")
|
|
57
|
+
login.add_argument("--scope", action="append", dest="scopes")
|
|
58
|
+
login.add_argument("--no-browser", action="store_true")
|
|
59
|
+
login.add_argument("--service-token-stdin", action="store_true", help=argparse.SUPPRESS)
|
|
60
|
+
commands.add_parser("logout", help="remove the current profile credential")
|
|
61
|
+
commands.add_parser("doctor", help="check local configuration and public API reachability")
|
|
62
|
+
|
|
63
|
+
init = commands.add_parser("init", help="create a safe extension scaffold")
|
|
64
|
+
init.add_argument("kind", choices=KINDS)
|
|
65
|
+
init.add_argument("name")
|
|
66
|
+
init.add_argument("--output", type=Path, default=Path.cwd())
|
|
67
|
+
for name in ("validate", "test", "dev", "package"):
|
|
68
|
+
_add_path(commands.add_parser(name))
|
|
69
|
+
validate_result = commands.add_parser("validate-outcome")
|
|
70
|
+
validate_result.add_argument("contract", type=Path)
|
|
71
|
+
validate_result.add_argument("result", type=Path)
|
|
72
|
+
sign = commands.add_parser("sign", help="sign exact package bytes using Sigstore")
|
|
73
|
+
sign.add_argument("archive", type=Path)
|
|
74
|
+
sign.add_argument("--bundle", type=Path)
|
|
75
|
+
publish = commands.add_parser("publish", help="upload exact signed bytes to staging")
|
|
76
|
+
publish.add_argument("archive", type=Path)
|
|
77
|
+
publish.add_argument("--stage", action="store_true", required=True)
|
|
78
|
+
publish.add_argument("--signature-bundle", type=Path)
|
|
79
|
+
publish.add_argument("--yes", action="store_true")
|
|
80
|
+
promote = commands.add_parser("promote", help="request promotion of exact staged bytes")
|
|
81
|
+
promote.add_argument("package_id")
|
|
82
|
+
promote.add_argument("--digest", required=True)
|
|
83
|
+
promote.add_argument("--canary", action="store_true", required=True)
|
|
84
|
+
promote.add_argument("--yes", action="store_true")
|
|
85
|
+
commands.add_parser("status", help="list package lifecycle state")
|
|
86
|
+
|
|
87
|
+
runs = commands.add_parser("runs")
|
|
88
|
+
run_commands = runs.add_subparsers(dest="runs_command", required=True)
|
|
89
|
+
run_list = run_commands.add_parser("list")
|
|
90
|
+
run_list.add_argument("--customer", action="store_true")
|
|
91
|
+
for name in ("get", "watch", "trace"):
|
|
92
|
+
command = run_commands.add_parser(name)
|
|
93
|
+
command.add_argument("run_id")
|
|
94
|
+
command.add_argument("--customer", action="store_true")
|
|
95
|
+
fork = run_commands.add_parser("fork")
|
|
96
|
+
fork.add_argument("run_id")
|
|
97
|
+
fork.add_argument("--child-run-id", required=True)
|
|
98
|
+
fork.add_argument("--expected-sequence", type=int, required=True)
|
|
99
|
+
fork.add_argument("--reason-code", default="developer_requested")
|
|
100
|
+
fork.add_argument("--yes", action="store_true")
|
|
101
|
+
stop = run_commands.add_parser("stop")
|
|
102
|
+
stop.add_argument("run_id")
|
|
103
|
+
stop.add_argument("--expected-sequence", type=int, required=True)
|
|
104
|
+
stop.add_argument("--reason-code", default="developer_requested")
|
|
105
|
+
stop.add_argument("--yes", action="store_true")
|
|
106
|
+
|
|
107
|
+
approvals = commands.add_parser("approvals")
|
|
108
|
+
approval_commands = approvals.add_subparsers(dest="approvals_command", required=True)
|
|
109
|
+
approval_commands.add_parser("list")
|
|
110
|
+
approval_get = approval_commands.add_parser("get")
|
|
111
|
+
approval_get.add_argument("approval_id")
|
|
112
|
+
decide = approval_commands.add_parser("decide")
|
|
113
|
+
decide.add_argument("run_id")
|
|
114
|
+
decide.add_argument("approval_id")
|
|
115
|
+
decide.add_argument("decision", choices=("approve", "reject"))
|
|
116
|
+
decide.add_argument("--expected-sequence", type=int, required=True)
|
|
117
|
+
decide.add_argument("--binding-digest", required=True)
|
|
118
|
+
decide.add_argument("--reason-code", required=True)
|
|
119
|
+
decide.add_argument("--yes", action="store_true")
|
|
120
|
+
capabilities = commands.add_parser("capabilities")
|
|
121
|
+
capabilities.add_argument("--agent", required=True)
|
|
122
|
+
commands.add_parser("version")
|
|
123
|
+
return parser
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _store(args: argparse.Namespace) -> CredentialStore:
|
|
127
|
+
return CredentialStore(args.profile, allow_file_fallback=args.allow_file_credentials)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _client(args: argparse.Namespace) -> GeyserClient:
|
|
131
|
+
credential = _store(args).load()
|
|
132
|
+
if credential is None:
|
|
133
|
+
raise RuntimeError("not authenticated; run `geyser login`")
|
|
134
|
+
return GeyserClient(args.api_url, credential.access_token)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _confirm(args: argparse.Namespace, preview: dict[str, Any]) -> None:
|
|
138
|
+
emit({"preview": preview, "server_authority_required": True}, machine=args.json)
|
|
139
|
+
if args.yes:
|
|
140
|
+
return
|
|
141
|
+
if not sys.stdin.isatty() or input("Continue? [y/N] ").strip().casefold() not in {"y", "yes"}:
|
|
142
|
+
raise RuntimeError("operation canceled before server authorization")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _manifest_from_archive(path: Path) -> dict[str, Any]:
|
|
146
|
+
import zipfile
|
|
147
|
+
|
|
148
|
+
with zipfile.ZipFile(path) as archive:
|
|
149
|
+
return cast(dict[str, Any], json.loads(archive.read("geyser-package.json")))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _handle_local(args: argparse.Namespace) -> Any:
|
|
153
|
+
if args.command == "init":
|
|
154
|
+
return {"path": os.fspath(scaffold(args.kind, args.name, args.output)), "created": True}
|
|
155
|
+
if args.command == "validate":
|
|
156
|
+
return {"valid": True, **validate_extension(args.path)}
|
|
157
|
+
if args.command == "test":
|
|
158
|
+
result = test_extension(args.path)
|
|
159
|
+
if result["failed"]:
|
|
160
|
+
raise RuntimeError(f"frozen cases failed: {result['failed']}")
|
|
161
|
+
return result
|
|
162
|
+
if args.command == "package":
|
|
163
|
+
return package_extension(args.path)
|
|
164
|
+
if args.command == "validate-outcome":
|
|
165
|
+
contract = normalize_contract(json.loads(args.contract.read_text(encoding="utf-8")))
|
|
166
|
+
if contract is None:
|
|
167
|
+
raise ValueError("outcome contract is empty")
|
|
168
|
+
validate_outcome(contract, json.loads(args.result.read_text(encoding="utf-8")))
|
|
169
|
+
return {"valid": True, "contract_digest": contract["contract_digest"]}
|
|
170
|
+
if args.command == "dev":
|
|
171
|
+
root = args.path.expanduser().resolve()
|
|
172
|
+
result = test_extension(root)
|
|
173
|
+
task = TypedTask(
|
|
174
|
+
task_id="local-task",
|
|
175
|
+
context_id="local-context",
|
|
176
|
+
prompt_digest="sha256:" + "0" * 64,
|
|
177
|
+
model_ref="deterministic:echo",
|
|
178
|
+
)
|
|
179
|
+
emulator = LocalEmulator()
|
|
180
|
+
emulator.admit("local-run", task)
|
|
181
|
+
emulator.append(
|
|
182
|
+
"local-run",
|
|
183
|
+
event_type="run.completed",
|
|
184
|
+
key="complete",
|
|
185
|
+
expected_sequence=1,
|
|
186
|
+
data={
|
|
187
|
+
"extension_digest": bytes_digest(
|
|
188
|
+
root.joinpath("geyser-package.json").read_bytes()
|
|
189
|
+
)
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
return {"extension": result, "run": emulator.project("local-run"), "network_used": False}
|
|
193
|
+
raise RuntimeError("unknown local command")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _doctor(args: argparse.Namespace) -> dict[str, Any]:
|
|
197
|
+
credential = _store(args).load()
|
|
198
|
+
reachable = False
|
|
199
|
+
api_version = ""
|
|
200
|
+
try:
|
|
201
|
+
response = httpx.get(f"{args.api_url.rstrip('/')}/api/v1/openapi.json", timeout=5)
|
|
202
|
+
reachable = response.status_code == 200
|
|
203
|
+
if reachable:
|
|
204
|
+
api_version = str(response.json().get("info", {}).get("version") or "")
|
|
205
|
+
except httpx.HTTPError:
|
|
206
|
+
pass
|
|
207
|
+
return {
|
|
208
|
+
"python": sys.version.split()[0],
|
|
209
|
+
"cli_version": __version__,
|
|
210
|
+
"api_url": args.api_url,
|
|
211
|
+
"api_reachable": reachable,
|
|
212
|
+
"api_version": api_version,
|
|
213
|
+
"authenticated": credential is not None,
|
|
214
|
+
"profile": args.profile,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _handle_network(args: argparse.Namespace) -> Any:
|
|
219
|
+
with _client(args) as client:
|
|
220
|
+
if args.command == "status":
|
|
221
|
+
return client.list_packages()
|
|
222
|
+
if args.command == "capabilities":
|
|
223
|
+
return client.capabilities(agent_name=args.agent)
|
|
224
|
+
if args.command == "runs":
|
|
225
|
+
if args.runs_command == "list":
|
|
226
|
+
return client.list_runs(customer=args.customer)
|
|
227
|
+
if args.runs_command == "get":
|
|
228
|
+
return client.get_run(args.run_id, customer=args.customer)
|
|
229
|
+
if args.runs_command == "trace":
|
|
230
|
+
return client.trace(args.run_id, customer=args.customer)
|
|
231
|
+
if args.runs_command == "watch":
|
|
232
|
+
return {"events": list(client.watch_events(args.run_id, customer=args.customer))}
|
|
233
|
+
if args.runs_command == "stop":
|
|
234
|
+
operation = {
|
|
235
|
+
"operation": "stop_run",
|
|
236
|
+
"run_id": args.run_id,
|
|
237
|
+
"expected_sequence": args.expected_sequence,
|
|
238
|
+
}
|
|
239
|
+
_confirm(args, operation)
|
|
240
|
+
request = CancelRequest(
|
|
241
|
+
cancellation_id="cancel_" + uuid.uuid4().hex,
|
|
242
|
+
expected_sequence=args.expected_sequence,
|
|
243
|
+
reason_code=args.reason_code,
|
|
244
|
+
)
|
|
245
|
+
return client.cancel_run(args.run_id, request)
|
|
246
|
+
operation = {
|
|
247
|
+
"operation": "fork_run",
|
|
248
|
+
"run_id": args.run_id,
|
|
249
|
+
"child_run_id": args.child_run_id,
|
|
250
|
+
"expected_sequence": args.expected_sequence,
|
|
251
|
+
}
|
|
252
|
+
_confirm(args, operation)
|
|
253
|
+
fork_request = ForkCreate(
|
|
254
|
+
fork_id="fork_" + uuid.uuid4().hex,
|
|
255
|
+
child_run_id=args.child_run_id,
|
|
256
|
+
expected_sequence=args.expected_sequence,
|
|
257
|
+
reason_code=args.reason_code,
|
|
258
|
+
)
|
|
259
|
+
return client.fork(args.run_id, fork_request)
|
|
260
|
+
if args.command == "approvals":
|
|
261
|
+
if args.approvals_command == "list":
|
|
262
|
+
return client.list_approvals()
|
|
263
|
+
if args.approvals_command == "get":
|
|
264
|
+
return client.get_approval(args.approval_id)
|
|
265
|
+
operation = {
|
|
266
|
+
"operation": "approval_decision",
|
|
267
|
+
"run_id": args.run_id,
|
|
268
|
+
"approval_id": args.approval_id,
|
|
269
|
+
"decision": args.decision,
|
|
270
|
+
"binding_digest": args.binding_digest,
|
|
271
|
+
}
|
|
272
|
+
_confirm(args, operation)
|
|
273
|
+
decision = ApprovalDecision(
|
|
274
|
+
decision_id="decision_" + uuid.uuid4().hex,
|
|
275
|
+
decision=args.decision,
|
|
276
|
+
expected_approval_sequence=args.expected_sequence,
|
|
277
|
+
binding_digest=args.binding_digest,
|
|
278
|
+
reason_code=args.reason_code,
|
|
279
|
+
)
|
|
280
|
+
return client.decide_approval(args.run_id, args.approval_id, decision)
|
|
281
|
+
if args.command == "publish":
|
|
282
|
+
archive = args.archive.expanduser().resolve()
|
|
283
|
+
details = inspect_archive(archive)
|
|
284
|
+
manifest = _manifest_from_archive(archive)
|
|
285
|
+
_confirm(args, {"operation": "package_upload", "stage": "staging", **details})
|
|
286
|
+
bundle = {}
|
|
287
|
+
if args.signature_bundle:
|
|
288
|
+
bundle = json.loads(args.signature_bundle.read_text(encoding="utf-8"))
|
|
289
|
+
upload = PackageUpload(
|
|
290
|
+
name=str(manifest["name"]),
|
|
291
|
+
version=str(manifest["version"]),
|
|
292
|
+
digest=str(details["digest"]),
|
|
293
|
+
media_type="application/vnd.geyser.extension+zip",
|
|
294
|
+
content_base64=base64.b64encode(archive.read_bytes()).decode(),
|
|
295
|
+
signature_bundle=bundle,
|
|
296
|
+
)
|
|
297
|
+
return client.upload_package(upload, idempotency_key=str(details["digest"]))
|
|
298
|
+
if args.command == "promote":
|
|
299
|
+
_confirm(args, {
|
|
300
|
+
"operation": "package_promotion",
|
|
301
|
+
"package_id": args.package_id,
|
|
302
|
+
"target": "canary",
|
|
303
|
+
"expected_digest": args.digest,
|
|
304
|
+
})
|
|
305
|
+
promotion = PackagePromotion(
|
|
306
|
+
promotion_id="promote_" + uuid.uuid4().hex,
|
|
307
|
+
target="canary",
|
|
308
|
+
expected_digest=args.digest,
|
|
309
|
+
)
|
|
310
|
+
return client.promote_package(args.package_id, promotion)
|
|
311
|
+
raise RuntimeError("unknown network command")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
315
|
+
args = _parser().parse_args(argv)
|
|
316
|
+
try:
|
|
317
|
+
if args.command == "version":
|
|
318
|
+
value: Any = {"geyser_open": __version__}
|
|
319
|
+
elif args.command == "login":
|
|
320
|
+
store = _store(args)
|
|
321
|
+
if args.service_token_stdin:
|
|
322
|
+
value = login_service_token(store, sys.stdin.readline())
|
|
323
|
+
else:
|
|
324
|
+
def notify(device: DeviceAuthorization) -> None:
|
|
325
|
+
emit({
|
|
326
|
+
"verification_uri": device.verification_uri,
|
|
327
|
+
"user_code": device.user_code,
|
|
328
|
+
}, machine=args.json)
|
|
329
|
+
|
|
330
|
+
value = login_device(
|
|
331
|
+
args.api_url,
|
|
332
|
+
store,
|
|
333
|
+
scopes=args.scopes or DEFAULT_SCOPES,
|
|
334
|
+
open_browser=not args.no_browser,
|
|
335
|
+
notify=notify,
|
|
336
|
+
)
|
|
337
|
+
elif args.command == "logout":
|
|
338
|
+
value = {"removed": _store(args).delete(), "profile": args.profile}
|
|
339
|
+
elif args.command == "doctor":
|
|
340
|
+
value = _doctor(args)
|
|
341
|
+
elif args.command in {"init", "validate", "validate-outcome", "test", "dev", "package"}:
|
|
342
|
+
value = _handle_local(args)
|
|
343
|
+
elif args.command == "sign":
|
|
344
|
+
archive = args.archive.expanduser().resolve()
|
|
345
|
+
inspect_archive(archive)
|
|
346
|
+
bundle = (
|
|
347
|
+
args.bundle or archive.with_suffix(archive.suffix + ".sigstore.json")
|
|
348
|
+
).resolve()
|
|
349
|
+
executable = shutil.which("sigstore")
|
|
350
|
+
if executable is None:
|
|
351
|
+
raise RuntimeError("Sigstore CLI is not installed")
|
|
352
|
+
result = subprocess.run( # noqa: S603 - resolved executable and argv, no shell
|
|
353
|
+
[executable, "sign", "--bundle", os.fspath(bundle), os.fspath(archive)],
|
|
354
|
+
check=False,
|
|
355
|
+
)
|
|
356
|
+
if result.returncode:
|
|
357
|
+
raise RuntimeError("Sigstore signing failed; no package lifecycle state changed")
|
|
358
|
+
value = {"signed": True, "archive": os.fspath(archive), "bundle": os.fspath(bundle)}
|
|
359
|
+
else:
|
|
360
|
+
value = _handle_network(args)
|
|
361
|
+
emit(value, machine=args.json)
|
|
362
|
+
return 0
|
|
363
|
+
except (OSError, ValueError, RuntimeError, ProblemError, httpx.HTTPError) as exc:
|
|
364
|
+
if args.json:
|
|
365
|
+
emit({"error": type(exc).__name__, "detail": str(exc)}, machine=True)
|
|
366
|
+
else:
|
|
367
|
+
print(f"geyser: {exc}", file=sys.stderr)
|
|
368
|
+
return 2
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
if __name__ == "__main__":
|
|
372
|
+
raise SystemExit(main())
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
__all__ = ["main"]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""OAuth device flow for humans and bounded service tokens for CI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import webbrowser
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .credentials import CredentialStore, StoredCredential
|
|
14
|
+
|
|
15
|
+
CLIENT_ID = "geyser-public-cli"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class DeviceAuthorization:
|
|
20
|
+
device_code: str
|
|
21
|
+
user_code: str
|
|
22
|
+
verification_uri: str
|
|
23
|
+
verification_uri_complete: str
|
|
24
|
+
expires_in: int
|
|
25
|
+
interval: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def login_device(
|
|
29
|
+
base_url: str,
|
|
30
|
+
store: CredentialStore,
|
|
31
|
+
*,
|
|
32
|
+
scopes: list[str],
|
|
33
|
+
open_browser: bool = True,
|
|
34
|
+
timeout: float = 10.0,
|
|
35
|
+
notify: Callable[[DeviceAuthorization], None] | None = None,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
with httpx.Client(base_url=base_url.rstrip("/"), timeout=timeout) as client:
|
|
38
|
+
response = client.post(
|
|
39
|
+
"/api/v1/oauth/device/code",
|
|
40
|
+
json={"client_id": CLIENT_ID, "scope": " ".join(scopes)},
|
|
41
|
+
)
|
|
42
|
+
if response.status_code >= 400:
|
|
43
|
+
raise RuntimeError(
|
|
44
|
+
f"device authorization could not start ({response.status_code})"
|
|
45
|
+
)
|
|
46
|
+
device = DeviceAuthorization(**response.json())
|
|
47
|
+
if notify:
|
|
48
|
+
notify(device)
|
|
49
|
+
if open_browser:
|
|
50
|
+
webbrowser.open(device.verification_uri_complete or device.verification_uri)
|
|
51
|
+
deadline = time.monotonic() + device.expires_in
|
|
52
|
+
interval = max(1, device.interval)
|
|
53
|
+
while time.monotonic() < deadline:
|
|
54
|
+
token_response = client.post(
|
|
55
|
+
"/api/v1/oauth/token",
|
|
56
|
+
json={
|
|
57
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
58
|
+
"device_code": device.device_code,
|
|
59
|
+
"client_id": CLIENT_ID,
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
value = token_response.json()
|
|
63
|
+
if token_response.status_code == 200:
|
|
64
|
+
credential = StoredCredential(
|
|
65
|
+
access_token=str(value["access_token"]),
|
|
66
|
+
token_type=str(value.get("token_type") or "Bearer"),
|
|
67
|
+
expires_at=time.time() + float(value.get("expires_in") or 0),
|
|
68
|
+
scope=str(value.get("scope") or ""),
|
|
69
|
+
customer_id=int(value.get("customer_id") or 0),
|
|
70
|
+
project_id=str(value.get("project_id") or ""),
|
|
71
|
+
)
|
|
72
|
+
backend = store.save(credential)
|
|
73
|
+
return {
|
|
74
|
+
"authenticated": True,
|
|
75
|
+
"storage": backend,
|
|
76
|
+
"scope": credential.scope.split(),
|
|
77
|
+
"customer_id": credential.customer_id,
|
|
78
|
+
"project_id": credential.project_id,
|
|
79
|
+
}
|
|
80
|
+
error = str(value.get("error") or "")
|
|
81
|
+
if error == "authorization_pending":
|
|
82
|
+
time.sleep(interval)
|
|
83
|
+
continue
|
|
84
|
+
if error == "slow_down":
|
|
85
|
+
interval += 5
|
|
86
|
+
time.sleep(interval)
|
|
87
|
+
continue
|
|
88
|
+
failure = error or str(token_response.status_code)
|
|
89
|
+
raise RuntimeError(f"device authorization failed: {failure}")
|
|
90
|
+
raise RuntimeError("device authorization expired")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def login_service_token(store: CredentialStore, token: str) -> dict[str, Any]:
|
|
94
|
+
if not token.strip():
|
|
95
|
+
raise ValueError("service token input was empty")
|
|
96
|
+
backend = store.save(StoredCredential(access_token=token.strip()))
|
|
97
|
+
return {"authenticated": True, "storage": backend, "credential_type": "service"}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = ["CLIENT_ID", "DeviceAuthorization", "login_device", "login_service_token"]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Credential storage with keychain-first, explicitly opted-in file fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
import keyring
|
|
11
|
+
from keyring.errors import KeyringError
|
|
12
|
+
from platformdirs import user_config_path
|
|
13
|
+
|
|
14
|
+
SERVICE = "ai.geyser.developer-cli"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class StoredCredential:
|
|
19
|
+
access_token: str
|
|
20
|
+
token_type: str = "Bearer" # noqa: S105 - OAuth token scheme, not a secret
|
|
21
|
+
expires_at: float = 0
|
|
22
|
+
scope: str = ""
|
|
23
|
+
customer_id: int = 0
|
|
24
|
+
project_id: str = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CredentialStore:
|
|
28
|
+
def __init__(self, profile: str = "default", *, allow_file_fallback: bool = False) -> None:
|
|
29
|
+
self.profile = profile
|
|
30
|
+
self.allow_file_fallback = allow_file_fallback
|
|
31
|
+
self.path = user_config_path("geyser", "Geyser Labs") / "credentials.json"
|
|
32
|
+
|
|
33
|
+
def save(self, credential: StoredCredential) -> str:
|
|
34
|
+
payload = json.dumps(credential.__dict__ if hasattr(credential, "__dict__") else {
|
|
35
|
+
"access_token": credential.access_token,
|
|
36
|
+
"token_type": credential.token_type,
|
|
37
|
+
"expires_at": credential.expires_at,
|
|
38
|
+
"scope": credential.scope,
|
|
39
|
+
"customer_id": credential.customer_id,
|
|
40
|
+
"project_id": credential.project_id,
|
|
41
|
+
})
|
|
42
|
+
try:
|
|
43
|
+
keyring.set_password(SERVICE, self.profile, payload)
|
|
44
|
+
return "os-keychain"
|
|
45
|
+
except (KeyringError, RuntimeError):
|
|
46
|
+
if not self.allow_file_fallback:
|
|
47
|
+
raise RuntimeError(
|
|
48
|
+
"OS keychain is unavailable; rerun with --allow-file-credentials to opt in "
|
|
49
|
+
"to a permission-restricted file"
|
|
50
|
+
) from None
|
|
51
|
+
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
52
|
+
existing: dict[str, str] = {}
|
|
53
|
+
if self.path.exists():
|
|
54
|
+
existing = json.loads(self.path.read_text(encoding="utf-8"))
|
|
55
|
+
existing[self.profile] = payload
|
|
56
|
+
temporary = self.path.with_suffix(".tmp")
|
|
57
|
+
temporary.write_text(json.dumps(existing), encoding="utf-8")
|
|
58
|
+
os.chmod(temporary, stat.S_IRUSR | stat.S_IWUSR)
|
|
59
|
+
temporary.replace(self.path)
|
|
60
|
+
return "restricted-file"
|
|
61
|
+
|
|
62
|
+
def load(self) -> StoredCredential | None:
|
|
63
|
+
payload: str | None = None
|
|
64
|
+
try:
|
|
65
|
+
payload = keyring.get_password(SERVICE, self.profile)
|
|
66
|
+
except (KeyringError, RuntimeError):
|
|
67
|
+
pass
|
|
68
|
+
if payload is None and self.allow_file_fallback and self.path.exists():
|
|
69
|
+
mode = stat.S_IMODE(self.path.stat().st_mode)
|
|
70
|
+
if mode & 0o077:
|
|
71
|
+
raise RuntimeError("credential fallback file permissions are not 0600")
|
|
72
|
+
payload = json.loads(self.path.read_text(encoding="utf-8")).get(self.profile)
|
|
73
|
+
if payload is None:
|
|
74
|
+
return None
|
|
75
|
+
value = json.loads(payload)
|
|
76
|
+
return StoredCredential(**value)
|
|
77
|
+
|
|
78
|
+
def delete(self) -> bool:
|
|
79
|
+
removed = False
|
|
80
|
+
try:
|
|
81
|
+
if keyring.get_password(SERVICE, self.profile) is not None:
|
|
82
|
+
keyring.delete_password(SERVICE, self.profile)
|
|
83
|
+
removed = True
|
|
84
|
+
except (KeyringError, RuntimeError):
|
|
85
|
+
pass
|
|
86
|
+
if self.allow_file_fallback and self.path.exists():
|
|
87
|
+
values = json.loads(self.path.read_text(encoding="utf-8"))
|
|
88
|
+
removed = values.pop(self.profile, None) is not None or removed
|
|
89
|
+
if values:
|
|
90
|
+
self.path.write_text(json.dumps(values), encoding="utf-8")
|
|
91
|
+
os.chmod(self.path, 0o600)
|
|
92
|
+
else:
|
|
93
|
+
self.path.unlink()
|
|
94
|
+
return removed
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
__all__ = ["CredentialStore", "StoredCredential"]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Validation, deterministic packaging, and frozen extension tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
import zipfile
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from geyser_sdk import bytes_digest
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
from .scaffolds import KINDS
|
|
16
|
+
|
|
17
|
+
MAX_FILE_BYTES = 2 * 1024 * 1024
|
|
18
|
+
MAX_PACKAGE_BYTES = 10 * 1024 * 1024
|
|
19
|
+
IGNORED_PARTS = frozenset({".git", ".geyser", ".venv", "__pycache__"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ExtensionManifest(BaseModel):
|
|
23
|
+
model_config = ConfigDict(extra="forbid")
|
|
24
|
+
|
|
25
|
+
schema_version: int = 1
|
|
26
|
+
kind: str
|
|
27
|
+
name: str
|
|
28
|
+
version: str
|
|
29
|
+
permissions: list[str] = Field(default_factory=list, max_length=128)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load_json(path: Path) -> Any:
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
35
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
36
|
+
raise ValueError(f"invalid JSON file {path}: {exc}") from exc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_extension(root: Path) -> dict[str, Any]:
|
|
40
|
+
root = root.expanduser().resolve()
|
|
41
|
+
if not root.is_dir():
|
|
42
|
+
raise ValueError(f"extension directory does not exist: {root}")
|
|
43
|
+
manifest_path = root / "geyser-package.json"
|
|
44
|
+
manifest = ExtensionManifest.model_validate(_load_json(manifest_path))
|
|
45
|
+
if manifest.schema_version != 1 or manifest.kind not in KINDS:
|
|
46
|
+
raise ValueError("unsupported extension manifest kind or schema version")
|
|
47
|
+
files = 0
|
|
48
|
+
total = 0
|
|
49
|
+
for path in sorted(root.rglob("*")):
|
|
50
|
+
relative = path.relative_to(root)
|
|
51
|
+
if set(relative.parts) & IGNORED_PARTS:
|
|
52
|
+
continue
|
|
53
|
+
mode = path.lstat().st_mode
|
|
54
|
+
if stat.S_ISLNK(mode) or not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
|
|
55
|
+
raise ValueError(f"extension contains unsafe file type: {relative}")
|
|
56
|
+
if path.is_file():
|
|
57
|
+
size = path.stat().st_size
|
|
58
|
+
if size > MAX_FILE_BYTES:
|
|
59
|
+
raise ValueError(f"extension file exceeds 2 MiB: {relative}")
|
|
60
|
+
files += 1
|
|
61
|
+
total += size
|
|
62
|
+
if total > MAX_PACKAGE_BYTES:
|
|
63
|
+
raise ValueError("extension exceeds the 10 MiB unpacked bound")
|
|
64
|
+
cases_path = root / "evals" / "cases.json"
|
|
65
|
+
cases = _load_json(cases_path)
|
|
66
|
+
if not isinstance(cases, dict) or cases.get("frozen") is not True:
|
|
67
|
+
raise ValueError("evals/cases.json must declare frozen=true")
|
|
68
|
+
rows = cases.get("cases")
|
|
69
|
+
if not isinstance(rows, list) or not rows:
|
|
70
|
+
raise ValueError("evals/cases.json must contain at least one case")
|
|
71
|
+
if not any(isinstance(row, dict) and row.get("critical") is True for row in rows):
|
|
72
|
+
raise ValueError("at least one frozen critical denial case is required")
|
|
73
|
+
return {"manifest": manifest.model_dump(), "files": files, "size_bytes": total}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_extension(root: Path) -> dict[str, Any]:
|
|
77
|
+
validation = validate_extension(root)
|
|
78
|
+
cases = _load_json(root.expanduser().resolve() / "evals" / "cases.json")["cases"]
|
|
79
|
+
failed = [row.get("case_id", "unknown") for row in cases if row.get("expected") not in {
|
|
80
|
+
"success", "deny_without_explicit_authority"
|
|
81
|
+
}]
|
|
82
|
+
return {
|
|
83
|
+
**validation,
|
|
84
|
+
"cases": len(cases),
|
|
85
|
+
"passed": len(cases) - len(failed),
|
|
86
|
+
"failed": failed,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def package_extension(root: Path, output: Path | None = None) -> dict[str, Any]:
|
|
91
|
+
root = root.expanduser().resolve()
|
|
92
|
+
validation = validate_extension(root)
|
|
93
|
+
manifest = validation["manifest"]
|
|
94
|
+
output = output or root / ".geyser" / "dist" / (
|
|
95
|
+
f"{manifest['name']}-{manifest['version']}.geyser.zip"
|
|
96
|
+
)
|
|
97
|
+
output = output.expanduser().resolve()
|
|
98
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
|
100
|
+
for path in sorted(root.rglob("*")):
|
|
101
|
+
relative = path.relative_to(root)
|
|
102
|
+
if not path.is_file() or set(relative.parts) & IGNORED_PARTS:
|
|
103
|
+
continue
|
|
104
|
+
info = zipfile.ZipInfo(PurePosixPath(relative).as_posix(), (1980, 1, 1, 0, 0, 0))
|
|
105
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
106
|
+
info.external_attr = (stat.S_IFREG | 0o644) << 16
|
|
107
|
+
archive.writestr(info, path.read_bytes())
|
|
108
|
+
package_bytes = output.read_bytes()
|
|
109
|
+
if len(package_bytes) > MAX_PACKAGE_BYTES:
|
|
110
|
+
output.unlink(missing_ok=True)
|
|
111
|
+
raise ValueError("extension archive exceeds 10 MiB")
|
|
112
|
+
return {
|
|
113
|
+
"path": os.fspath(output),
|
|
114
|
+
"name": manifest["name"],
|
|
115
|
+
"version": manifest["version"],
|
|
116
|
+
"digest": bytes_digest(package_bytes),
|
|
117
|
+
"size_bytes": len(package_bytes),
|
|
118
|
+
"media_type": "application/vnd.geyser.extension+zip",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def inspect_archive(path: Path) -> dict[str, Any]:
|
|
123
|
+
path = path.expanduser().resolve()
|
|
124
|
+
if path.stat().st_size > MAX_PACKAGE_BYTES:
|
|
125
|
+
raise ValueError("archive exceeds 10 MiB")
|
|
126
|
+
total = 0
|
|
127
|
+
names: set[str] = set()
|
|
128
|
+
with zipfile.ZipFile(path) as archive:
|
|
129
|
+
for info in archive.infolist():
|
|
130
|
+
pure = PurePosixPath(info.filename)
|
|
131
|
+
if pure.is_absolute() or ".." in pure.parts or "" in pure.parts:
|
|
132
|
+
raise ValueError("archive contains path traversal")
|
|
133
|
+
if info.filename in names:
|
|
134
|
+
raise ValueError("archive contains duplicate paths")
|
|
135
|
+
names.add(info.filename)
|
|
136
|
+
mode = info.external_attr >> 16
|
|
137
|
+
if stat.S_ISLNK(mode):
|
|
138
|
+
raise ValueError("archive contains a symbolic link")
|
|
139
|
+
total += info.file_size
|
|
140
|
+
if info.file_size > MAX_FILE_BYTES or total > MAX_PACKAGE_BYTES:
|
|
141
|
+
raise ValueError("archive exceeds unpacked safety bounds")
|
|
142
|
+
return {"path": os.fspath(path), "digest": bytes_digest(path.read_bytes()), "files": len(names)}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
__all__ = [
|
|
146
|
+
"ExtensionManifest",
|
|
147
|
+
"inspect_archive",
|
|
148
|
+
"package_extension",
|
|
149
|
+
"test_extension",
|
|
150
|
+
"validate_extension",
|
|
151
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Stable human and machine-readable command output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def serializable(value: Any) -> Any:
|
|
12
|
+
if isinstance(value, BaseModel):
|
|
13
|
+
return value.model_dump(mode="json")
|
|
14
|
+
if isinstance(value, dict):
|
|
15
|
+
return {key: serializable(child) for key, child in value.items()}
|
|
16
|
+
if isinstance(value, (list, tuple)):
|
|
17
|
+
return [serializable(child) for child in value]
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def emit(value: Any, *, machine: bool) -> None:
|
|
22
|
+
normalized = serializable(value)
|
|
23
|
+
if machine:
|
|
24
|
+
print(json.dumps(normalized, sort_keys=True, separators=(",", ":"), ensure_ascii=False))
|
|
25
|
+
elif isinstance(normalized, str):
|
|
26
|
+
print(normalized)
|
|
27
|
+
else:
|
|
28
|
+
print(json.dumps(normalized, indent=2, sort_keys=True, ensure_ascii=False))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
__all__ = ["emit", "serializable"]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Safe extension scaffolds used by ``geyser init``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
KINDS = ("skill", "connector", "tool", "evaluator", "model-profile", "agent-bundle")
|
|
10
|
+
_NAME = re.compile(r"^[a-z][a-z0-9-]{1,63}$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _json(value: object) -> str:
|
|
14
|
+
return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def scaffold_files(kind: str, name: str) -> dict[str, str]:
|
|
18
|
+
common = {
|
|
19
|
+
"geyser-package.json": _json({
|
|
20
|
+
"schema_version": 1,
|
|
21
|
+
"kind": kind,
|
|
22
|
+
"name": name,
|
|
23
|
+
"version": "0.1.0",
|
|
24
|
+
"permissions": [],
|
|
25
|
+
}),
|
|
26
|
+
"README.md": (
|
|
27
|
+
f"# {name}\n\nA credential-free `{kind}` extension for Geyser Open. "
|
|
28
|
+
"Declared permissions do not grant authority; server policy remains authoritative.\n"
|
|
29
|
+
),
|
|
30
|
+
"evals/cases.json": _json({
|
|
31
|
+
"schema_version": 1,
|
|
32
|
+
"frozen": True,
|
|
33
|
+
"cases": [
|
|
34
|
+
{"case_id": "bounded-success-1", "critical": False, "expected": "success"},
|
|
35
|
+
{
|
|
36
|
+
"case_id": "critical-denial-1",
|
|
37
|
+
"critical": True,
|
|
38
|
+
"expected": "deny_without_explicit_authority",
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
}),
|
|
42
|
+
}
|
|
43
|
+
if kind == "skill":
|
|
44
|
+
return {**common, "SKILL.md": (
|
|
45
|
+
f"# {name}\n\n## Purpose\n\nDescribe one bounded capability.\n\n"
|
|
46
|
+
"## Authority\n\nThis skill never grants tools, credentials, or approval.\n"
|
|
47
|
+
)}
|
|
48
|
+
if kind == "connector":
|
|
49
|
+
return {**common, "connector.json": _json({
|
|
50
|
+
"schema_version": 1,
|
|
51
|
+
"connector_id": name,
|
|
52
|
+
"directions": ["inbound"],
|
|
53
|
+
"identity_link_required": True,
|
|
54
|
+
"content_storage": "customer_cell_reference_only",
|
|
55
|
+
"delivery": {"deduplicate": True, "max_loop_hops": 8},
|
|
56
|
+
"permissions": {"event_types": [], "network_origins": []},
|
|
57
|
+
"revocable": True,
|
|
58
|
+
})}
|
|
59
|
+
if kind == "tool":
|
|
60
|
+
return {**common, "tool.json": _json({
|
|
61
|
+
"schema_version": 1,
|
|
62
|
+
"tool_name": name,
|
|
63
|
+
"description": "Describe the bounded effect without promising authority.",
|
|
64
|
+
"input_schema": {"type": "object", "additionalProperties": False},
|
|
65
|
+
"effect_class": "read_only",
|
|
66
|
+
"approval_posture": "policy_decides",
|
|
67
|
+
"idempotency_required": True,
|
|
68
|
+
})}
|
|
69
|
+
if kind == "evaluator":
|
|
70
|
+
return {**common, "evaluator.json": _json({
|
|
71
|
+
"schema_version": 1,
|
|
72
|
+
"evaluator_id": name,
|
|
73
|
+
"score_range": [0, 1],
|
|
74
|
+
"critical_slices": ["authorization", "privacy", "false_success"],
|
|
75
|
+
"content_capture": False,
|
|
76
|
+
})}
|
|
77
|
+
if kind == "model-profile":
|
|
78
|
+
return {**common, "model-profile.json": _json({
|
|
79
|
+
"schema_version": 1,
|
|
80
|
+
"profile_id": name,
|
|
81
|
+
"qualified": False,
|
|
82
|
+
"qualification_ref": "",
|
|
83
|
+
"custody": "declare-before-use",
|
|
84
|
+
"fallback": "none",
|
|
85
|
+
"task_performance_cards": {},
|
|
86
|
+
})}
|
|
87
|
+
return {**common, "agent-bundle-selection.json": _json({
|
|
88
|
+
"schema_version": 1,
|
|
89
|
+
"name": name,
|
|
90
|
+
"components": [
|
|
91
|
+
"identity_persona",
|
|
92
|
+
"skills",
|
|
93
|
+
"policy_references",
|
|
94
|
+
"brain_export",
|
|
95
|
+
"history_index",
|
|
96
|
+
"artifact_manifest",
|
|
97
|
+
],
|
|
98
|
+
"owner_review_required": True,
|
|
99
|
+
"include_credentials": False,
|
|
100
|
+
"include_provider_sessions": False,
|
|
101
|
+
"include_hidden_reasoning": False,
|
|
102
|
+
})}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def scaffold(kind: str, name: str, output: Path) -> Path:
|
|
106
|
+
if kind not in KINDS:
|
|
107
|
+
raise ValueError("unknown scaffold kind")
|
|
108
|
+
if _NAME.fullmatch(name) is None:
|
|
109
|
+
raise ValueError("name must be 2-64 lowercase letters, numbers, and hyphens")
|
|
110
|
+
root = output.expanduser().resolve() / name
|
|
111
|
+
if root.exists() and any(root.iterdir()):
|
|
112
|
+
raise FileExistsError(f"refusing to overwrite non-empty scaffold: {root}")
|
|
113
|
+
for relative, body in scaffold_files(kind, name).items():
|
|
114
|
+
target = root / relative
|
|
115
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
target.write_text(body, encoding="utf-8")
|
|
117
|
+
return root
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__all__ = ["KINDS", "scaffold", "scaffold_files"]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
from geyser_cli.auth import login_device
|
|
8
|
+
from geyser_cli.credentials import StoredCredential
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MemoryStore:
|
|
12
|
+
credential: StoredCredential | None = None
|
|
13
|
+
|
|
14
|
+
def save(self, credential: StoredCredential) -> str:
|
|
15
|
+
self.credential = credential
|
|
16
|
+
return "test-keychain"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_device_flow(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
20
|
+
responses = iter([
|
|
21
|
+
httpx.Response(200, json={
|
|
22
|
+
"device_code": "device-secret",
|
|
23
|
+
"user_code": "ABCD-EFGH",
|
|
24
|
+
"verification_uri": "https://auth.example/device",
|
|
25
|
+
"verification_uri_complete": "https://auth.example/device?code=ABCD-EFGH",
|
|
26
|
+
"expires_in": 60,
|
|
27
|
+
"interval": 1,
|
|
28
|
+
}),
|
|
29
|
+
httpx.Response(400, json={"error": "authorization_pending"}),
|
|
30
|
+
httpx.Response(200, json={
|
|
31
|
+
"access_token": "access-secret",
|
|
32
|
+
"token_type": "Bearer",
|
|
33
|
+
"expires_in": 3600,
|
|
34
|
+
"scope": "development:read runs:read",
|
|
35
|
+
"customer_id": 1,
|
|
36
|
+
"project_id": "project-1",
|
|
37
|
+
}),
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
class Client:
|
|
41
|
+
def __init__(self, **_kwargs: Any) -> None:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def __enter__(self) -> Client:
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def __exit__(self, *_args: object) -> None:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
def post(self, *_args: object, **_kwargs: object) -> httpx.Response:
|
|
51
|
+
return next(responses)
|
|
52
|
+
|
|
53
|
+
monkeypatch.setattr("geyser_cli.auth.httpx.Client", Client)
|
|
54
|
+
monkeypatch.setattr("geyser_cli.auth.time.sleep", lambda _seconds: None)
|
|
55
|
+
store = MemoryStore()
|
|
56
|
+
notifications: list[str] = []
|
|
57
|
+
value = login_device(
|
|
58
|
+
"https://api.example",
|
|
59
|
+
store, # type: ignore[arg-type]
|
|
60
|
+
scopes=["development:read"],
|
|
61
|
+
open_browser=False,
|
|
62
|
+
notify=lambda device: notifications.append(device.user_code),
|
|
63
|
+
)
|
|
64
|
+
assert value["storage"] == "test-keychain"
|
|
65
|
+
assert store.credential is not None
|
|
66
|
+
assert store.credential.access_token == "access-secret" # noqa: S105 - test fixture
|
|
67
|
+
assert notifications == ["ABCD-EFGH"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import stat
|
|
5
|
+
import zipfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from geyser_cli.__main__ import main
|
|
10
|
+
from geyser_cli.credentials import CredentialStore, StoredCredential
|
|
11
|
+
from geyser_cli.extensions import inspect_archive, package_extension
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_complete_local_workflow(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
15
|
+
assert main(["--json", "init", "tool", "careful-search", "--output", str(tmp_path)]) == 0
|
|
16
|
+
root = tmp_path / "careful-search"
|
|
17
|
+
created = json.loads(capsys.readouterr().out)
|
|
18
|
+
assert created["created"] is True
|
|
19
|
+
assert main(["--json", "validate", str(root)]) == 0
|
|
20
|
+
assert json.loads(capsys.readouterr().out)["valid"] is True
|
|
21
|
+
assert main(["--json", "test", str(root)]) == 0
|
|
22
|
+
assert json.loads(capsys.readouterr().out)["passed"] == 2
|
|
23
|
+
assert main(["--json", "dev", str(root)]) == 0
|
|
24
|
+
assert json.loads(capsys.readouterr().out)["network_used"] is False
|
|
25
|
+
assert main(["--json", "package", str(root)]) == 0
|
|
26
|
+
packaged = json.loads(capsys.readouterr().out)
|
|
27
|
+
archive = Path(packaged["path"])
|
|
28
|
+
first = archive.read_bytes()
|
|
29
|
+
assert package_extension(root)["digest"] == packaged["digest"]
|
|
30
|
+
assert archive.read_bytes() == first
|
|
31
|
+
assert inspect_archive(archive)["files"] >= 4
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_outcome_cli_and_errors(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
35
|
+
contract = tmp_path / "contract.json"
|
|
36
|
+
result = tmp_path / "result.json"
|
|
37
|
+
contract.write_text(json.dumps({
|
|
38
|
+
"schema_version": 1,
|
|
39
|
+
"schema_ref": "example:answer:v1",
|
|
40
|
+
"json_schema": {
|
|
41
|
+
"type": "object", "properties": {"answer": {"type": "string"}},
|
|
42
|
+
"required": ["answer"], "additionalProperties": False,
|
|
43
|
+
},
|
|
44
|
+
}))
|
|
45
|
+
result.write_text('{"answer":"yes"}')
|
|
46
|
+
assert main(["--json", "validate-outcome", str(contract), str(result)]) == 0
|
|
47
|
+
assert json.loads(capsys.readouterr().out)["valid"] is True
|
|
48
|
+
result.write_text('{"wrong":true}')
|
|
49
|
+
assert main(["--json", "validate-outcome", str(contract), str(result)]) == 2
|
|
50
|
+
assert "OutcomeValidationError" in capsys.readouterr().out
|
|
51
|
+
assert main(["--json", "init", "tool", "BAD", "--output", str(tmp_path)]) == 2
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_archive_traversal_symlink_and_bounds(tmp_path: Path) -> None:
|
|
55
|
+
traversal = tmp_path / "traversal.zip"
|
|
56
|
+
with zipfile.ZipFile(traversal, "w") as archive:
|
|
57
|
+
archive.writestr("../escape", "bad")
|
|
58
|
+
with pytest.raises(ValueError, match="traversal"):
|
|
59
|
+
inspect_archive(traversal)
|
|
60
|
+
symlink = tmp_path / "symlink.zip"
|
|
61
|
+
with zipfile.ZipFile(symlink, "w") as archive:
|
|
62
|
+
info = zipfile.ZipInfo("link")
|
|
63
|
+
info.external_attr = (stat.S_IFLNK | 0o777) << 16
|
|
64
|
+
archive.writestr(info, "target")
|
|
65
|
+
with pytest.raises(ValueError, match="symbolic link"):
|
|
66
|
+
inspect_archive(symlink)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_explicit_restricted_file_credential_fallback(
|
|
70
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
71
|
+
) -> None:
|
|
72
|
+
def unavailable(*_args: object) -> None:
|
|
73
|
+
raise RuntimeError("no keychain")
|
|
74
|
+
|
|
75
|
+
monkeypatch.setattr("geyser_cli.credentials.keyring.set_password", unavailable)
|
|
76
|
+
monkeypatch.setattr("geyser_cli.credentials.keyring.get_password", unavailable)
|
|
77
|
+
monkeypatch.setattr("geyser_cli.credentials.keyring.delete_password", unavailable)
|
|
78
|
+
denied = CredentialStore("test", allow_file_fallback=False)
|
|
79
|
+
denied.path = tmp_path / "credentials.json"
|
|
80
|
+
with pytest.raises(RuntimeError, match="opt in"):
|
|
81
|
+
denied.save(StoredCredential(access_token="secret")) # noqa: S106 - test fixture
|
|
82
|
+
store = CredentialStore("test", allow_file_fallback=True)
|
|
83
|
+
store.path = tmp_path / "credentials.json"
|
|
84
|
+
assert store.save(StoredCredential(access_token="secret")) == "restricted-file" # noqa: S106
|
|
85
|
+
assert stat.S_IMODE(store.path.stat().st_mode) == 0o600
|
|
86
|
+
assert store.load() is not None
|
|
87
|
+
assert store.load().access_token == "secret" # type: ignore[union-attr] # noqa: S105
|
|
88
|
+
assert store.delete() is True
|
|
89
|
+
assert not store.path.exists()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_version_is_machine_readable(capsys: pytest.CaptureFixture[str]) -> None:
|
|
93
|
+
assert main(["--json", "version"]) == 0
|
|
94
|
+
assert json.loads(capsys.readouterr().out)["geyser_open"] == "0.1.0"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from geyser_cli import __main__ as cli
|
|
11
|
+
from geyser_cli.extensions import package_extension
|
|
12
|
+
from geyser_cli.scaffolds import scaffold
|
|
13
|
+
|
|
14
|
+
SHA = "sha256:" + "a" * 64
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FakeClient:
|
|
18
|
+
def __enter__(self) -> FakeClient:
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
def __exit__(self, *_args: object) -> None:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
def list_packages(self) -> dict[str, Any]:
|
|
25
|
+
return {"data": []}
|
|
26
|
+
|
|
27
|
+
def capabilities(self, *, agent_name: str) -> dict[str, Any]:
|
|
28
|
+
return {"agent_name": agent_name, "capabilities": {}}
|
|
29
|
+
|
|
30
|
+
def list_runs(self, *, customer: bool = False) -> dict[str, Any]:
|
|
31
|
+
return {"data": [], "customer": customer}
|
|
32
|
+
|
|
33
|
+
def get_run(self, run_id: str, *, customer: bool = False) -> dict[str, Any]:
|
|
34
|
+
return {"run_id": run_id, "customer": customer}
|
|
35
|
+
|
|
36
|
+
def trace(self, run_id: str, *, customer: bool = False) -> dict[str, Any]:
|
|
37
|
+
return {"trace_id": run_id, "customer": customer}
|
|
38
|
+
|
|
39
|
+
def watch_events(self, run_id: str, *, customer: bool = False) -> Iterator[dict[str, Any]]:
|
|
40
|
+
yield {"run_id": run_id, "customer": customer}
|
|
41
|
+
|
|
42
|
+
def cancel_run(self, run_id: str, request: Any) -> dict[str, Any]:
|
|
43
|
+
return {"run_id": run_id, "cancellation_id": request.cancellation_id}
|
|
44
|
+
|
|
45
|
+
def fork(self, run_id: str, request: Any) -> dict[str, Any]:
|
|
46
|
+
return {"run_id": run_id, "fork_id": request.fork_id}
|
|
47
|
+
|
|
48
|
+
def list_approvals(self) -> dict[str, Any]:
|
|
49
|
+
return {"data": []}
|
|
50
|
+
|
|
51
|
+
def get_approval(self, approval_id: str) -> dict[str, Any]:
|
|
52
|
+
return {"approval_id": approval_id}
|
|
53
|
+
|
|
54
|
+
def decide_approval(self, run_id: str, approval_id: str, decision: Any) -> dict[str, Any]:
|
|
55
|
+
return {"run_id": run_id, "approval_id": approval_id, "decision": decision.decision}
|
|
56
|
+
|
|
57
|
+
def upload_package(self, package: Any, *, idempotency_key: str) -> dict[str, Any]:
|
|
58
|
+
return {"name": package.name, "digest": idempotency_key, "stage": "staging"}
|
|
59
|
+
|
|
60
|
+
def promote_package(self, package_id: str, promotion: Any) -> dict[str, Any]:
|
|
61
|
+
return {"package_id": package_id, "target": promotion.target}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.mark.parametrize("arguments", [
|
|
65
|
+
["status"],
|
|
66
|
+
["capabilities", "--agent", "Ada"],
|
|
67
|
+
["runs", "list", "--customer"],
|
|
68
|
+
["runs", "get", "run-1"],
|
|
69
|
+
["runs", "watch", "run-1"],
|
|
70
|
+
["runs", "trace", "run-1", "--customer"],
|
|
71
|
+
["runs", "stop", "run-1", "--expected-sequence", "2", "--yes"],
|
|
72
|
+
["runs", "fork", "run-1", "--child-run-id", "run-2", "--expected-sequence", "2", "--yes"],
|
|
73
|
+
["approvals", "list"],
|
|
74
|
+
["approvals", "get", "approval-1"],
|
|
75
|
+
[
|
|
76
|
+
"approvals", "decide", "run-1", "approval-1", "approve",
|
|
77
|
+
"--expected-sequence", "2", "--binding-digest", SHA,
|
|
78
|
+
"--reason-code", "reviewed", "--yes",
|
|
79
|
+
],
|
|
80
|
+
["promote", "package-1", "--digest", SHA, "--canary", "--yes"],
|
|
81
|
+
])
|
|
82
|
+
def test_network_commands(
|
|
83
|
+
arguments: list[str], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
84
|
+
) -> None:
|
|
85
|
+
monkeypatch.setattr(cli, "_client", lambda _args: FakeClient())
|
|
86
|
+
assert cli.main(["--json", *arguments]) == 0
|
|
87
|
+
output = capsys.readouterr().out.strip().splitlines()
|
|
88
|
+
assert json.loads(output[-1])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_publish_and_sign(
|
|
92
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
93
|
+
) -> None:
|
|
94
|
+
root = scaffold("tool", "sample-tool", tmp_path)
|
|
95
|
+
archive = Path(package_extension(root)["path"])
|
|
96
|
+
monkeypatch.setattr(cli, "_client", lambda _args: FakeClient())
|
|
97
|
+
assert cli.main(["--json", "publish", str(archive), "--stage", "--yes"]) == 0
|
|
98
|
+
assert json.loads(capsys.readouterr().out.strip().splitlines()[-1])["stage"] == "staging"
|
|
99
|
+
monkeypatch.setattr(cli.shutil, "which", lambda _name: "/usr/bin/true")
|
|
100
|
+
monkeypatch.setattr(
|
|
101
|
+
cli.subprocess,
|
|
102
|
+
"run",
|
|
103
|
+
lambda *_args, **_kwargs: SimpleNamespace(returncode=0),
|
|
104
|
+
)
|
|
105
|
+
assert cli.main(["--json", "sign", str(archive)]) == 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_doctor_and_confirmation_cancel(
|
|
109
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
110
|
+
) -> None:
|
|
111
|
+
monkeypatch.setattr(cli, "_store", lambda _args: SimpleNamespace(load=lambda: None))
|
|
112
|
+
monkeypatch.setattr(cli.httpx, "get", lambda *_args, **_kwargs: SimpleNamespace(
|
|
113
|
+
status_code=200, json=lambda: {"info": {"version": "2026-08-24"}}
|
|
114
|
+
))
|
|
115
|
+
assert cli.main(["--json", "doctor"]) == 0
|
|
116
|
+
assert json.loads(capsys.readouterr().out)["api_reachable"] is True
|
|
117
|
+
monkeypatch.setattr(cli, "_client", lambda _args: FakeClient())
|
|
118
|
+
monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: False)
|
|
119
|
+
assert cli.main(["--json", "runs", "stop", "run-1", "--expected-sequence", "2"]) == 2
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.mark.parametrize(
|
|
123
|
+
"kind", ["skill", "connector", "evaluator", "model-profile", "agent-bundle"]
|
|
124
|
+
)
|
|
125
|
+
def test_all_scaffold_kinds(kind: str, tmp_path: Path) -> None:
|
|
126
|
+
root = scaffold(kind, f"sample-{kind}", tmp_path)
|
|
127
|
+
assert (root / "geyser-package.json").is_file()
|