awrun 0.1.0__py3-none-any.whl
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.
- awrun/__init__.py +27 -0
- awrun/_doctor.py +114 -0
- awrun/authz.py +209 -0
- awrun/cli.py +444 -0
- awrun/dispatcher.py +608 -0
- awrun/runner_groups.py +342 -0
- awrun/store.py +309 -0
- awrun-0.1.0.dist-info/METADATA +182 -0
- awrun-0.1.0.dist-info/RECORD +13 -0
- awrun-0.1.0.dist-info/WHEEL +5 -0
- awrun-0.1.0.dist-info/entry_points.txt +2 -0
- awrun-0.1.0.dist-info/licenses/LICENSE +202 -0
- awrun-0.1.0.dist-info/top_level.txt +1 -0
awrun/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""awrun — a priority-aware queue and dispatcher for agentic runs and ad-hoc CI builds."""
|
|
2
|
+
|
|
3
|
+
from awrun.store import (
|
|
4
|
+
ALL_STATUSES,
|
|
5
|
+
CLOSED_STATUSES,
|
|
6
|
+
KINDS,
|
|
7
|
+
OPEN_STATUSES,
|
|
8
|
+
RunError,
|
|
9
|
+
RunItem,
|
|
10
|
+
RunStore,
|
|
11
|
+
get_store,
|
|
12
|
+
runs_dir,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ALL_STATUSES",
|
|
17
|
+
"CLOSED_STATUSES",
|
|
18
|
+
"KINDS",
|
|
19
|
+
"OPEN_STATUSES",
|
|
20
|
+
"RunError",
|
|
21
|
+
"RunItem",
|
|
22
|
+
"RunStore",
|
|
23
|
+
"get_store",
|
|
24
|
+
"runs_dir",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
awrun/_doctor.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Stack-aware `doctor` for awrun.
|
|
2
|
+
|
|
3
|
+
GENERATED BY gen_aw_doctor.py -- DO NOT EDIT.
|
|
4
|
+
Regenerate it with the generator named above; a hand-edit here is reverted by
|
|
5
|
+
the next run and fails the parity gate.
|
|
6
|
+
|
|
7
|
+
Why a doctor exists at all: the aw* bricks are designed to COMPOSE, so the
|
|
8
|
+
interesting failures live BETWEEN them. "awrun is installed" is not the useful
|
|
9
|
+
fact -- "awrun is installed and the thing it pairs with is not" is. This reports
|
|
10
|
+
the whole stack, not just itself.
|
|
11
|
+
|
|
12
|
+
stdlib only, on purpose: a diagnostic that cannot run because a dependency is
|
|
13
|
+
missing is worthless precisely when you need it.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib.util
|
|
18
|
+
import shutil
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
#: Frozen from the aw* registry at generation time. A shipped
|
|
22
|
+
#: package cannot read the registry, and a doctor that guessed at the family
|
|
23
|
+
#: would go stale in silence. Regenerate to update.
|
|
24
|
+
SELF = 'awrun'
|
|
25
|
+
FAMILY = ['awbac', 'awbrowse', 'awdit', 'awevolve', 'awfind', 'awgit', 'awgraph', 'awiam', 'awkno', 'awm', 'awmail', 'awnboard', 'awnest', 'awnet', 'awprism', 'awreason', 'awrecover', 'awrecurse', 'awrelay', 'awrepl', 'awresearch', 'awseal', 'awshare', 'awtunnel']
|
|
26
|
+
PAIRS_WITH = ['awevolve', 'awrelay']
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _installed(mod: str) -> "str | None":
|
|
30
|
+
"""Version if importable, else None. Never raises -- a broken sibling must
|
|
31
|
+
not take the diagnostic down with it."""
|
|
32
|
+
try:
|
|
33
|
+
if importlib.util.find_spec(mod) is None:
|
|
34
|
+
return None
|
|
35
|
+
except (ImportError, ValueError):
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
39
|
+
try:
|
|
40
|
+
return version(mod)
|
|
41
|
+
except PackageNotFoundError:
|
|
42
|
+
return "installed"
|
|
43
|
+
except Exception:
|
|
44
|
+
return "installed"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def report(out=None) -> int:
|
|
48
|
+
"""Print the stack picture. 0 = this brick and its pairs are present."""
|
|
49
|
+
out = out or sys.stdout
|
|
50
|
+
print(f"{SELF} doctor", file=out)
|
|
51
|
+
|
|
52
|
+
mine = _installed(SELF)
|
|
53
|
+
print(f" self {SELF} {mine or 'NOT IMPORTABLE'}", file=out)
|
|
54
|
+
shim = shutil.which(SELF)
|
|
55
|
+
print(f" command {shim or 'not on PATH'}", file=out)
|
|
56
|
+
|
|
57
|
+
# The stack. Siblings this brick pairs with are called out separately,
|
|
58
|
+
# because a missing pair is a REASON, while a missing unrelated brick is
|
|
59
|
+
# just a fact about your machine.
|
|
60
|
+
missing_pairs, present = [], []
|
|
61
|
+
for name in FAMILY:
|
|
62
|
+
v = _installed(name)
|
|
63
|
+
if v:
|
|
64
|
+
present.append(name)
|
|
65
|
+
elif name in PAIRS_WITH:
|
|
66
|
+
missing_pairs.append(name)
|
|
67
|
+
print(f" stack {len(present)}/{len(FAMILY)} aw* packages installed",
|
|
68
|
+
file=out)
|
|
69
|
+
if present:
|
|
70
|
+
print(f" {' '.join(sorted(present))}", file=out)
|
|
71
|
+
|
|
72
|
+
local = _local_checks()
|
|
73
|
+
for line in local:
|
|
74
|
+
print(f" {line}", file=out)
|
|
75
|
+
|
|
76
|
+
if mine is None:
|
|
77
|
+
print(f"\nverdict: {SELF} itself is not importable. Reinstall it before "
|
|
78
|
+
f"anything else here means much.", file=out)
|
|
79
|
+
return 1
|
|
80
|
+
if missing_pairs:
|
|
81
|
+
print(f"\nverdict: {SELF} works, but pairs with "
|
|
82
|
+
f"{', '.join(sorted(missing_pairs))} which "
|
|
83
|
+
f"{'is' if len(missing_pairs) == 1 else 'are'} not installed. "
|
|
84
|
+
f"That is a capability you are missing, not an error.", file=out)
|
|
85
|
+
return 0
|
|
86
|
+
print(f"\nverdict: {SELF} and everything it pairs with are present.", file=out)
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _local_checks() -> "list[str]":
|
|
91
|
+
"""Per-brick checks, if this package defines them.
|
|
92
|
+
|
|
93
|
+
Kept as a HOOK rather than generated guesses: the generator knows the family
|
|
94
|
+
from the registry, but it does not know what awrun needs at runtime, and a
|
|
95
|
+
doctor that invented config requirements would be confidently wrong. A
|
|
96
|
+
package supplies `_doctor_local()` returning display lines; absent, the
|
|
97
|
+
stack picture above still stands on its own.
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
mod = importlib.import_module(f"{SELF}.doctor_local")
|
|
101
|
+
except Exception:
|
|
102
|
+
return []
|
|
103
|
+
try:
|
|
104
|
+
return list(mod._doctor_local())
|
|
105
|
+
except Exception as exc: # noqa: BLE001
|
|
106
|
+
return [f"local checks raised {type(exc).__name__}: {exc}"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main(argv: "list[str] | None" = None) -> int:
|
|
110
|
+
return report()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
raise SystemExit(main())
|
awrun/authz.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""awrun/authz.py — trust-plane wiring for the one kind that spends money.
|
|
2
|
+
|
|
3
|
+
Light-touch for local use, strict only where money can be spent
|
|
4
|
+
(owner-confirmed design decision — see the plan this package was built
|
|
5
|
+
from). A local `awrun submit --kind agent ...` on the trusted dev box needs
|
|
6
|
+
no session token, exactly as it works today. Only `comet-deploy` submissions
|
|
7
|
+
require a resolved `awiam` session, an `awbac` policy check, and every
|
|
8
|
+
submit/dispatch/denial is written to an `awdit` audit log.
|
|
9
|
+
|
|
10
|
+
Wraps the REAL, verified `awiam`/`awbac`/`awdit` APIs (checked live against
|
|
11
|
+
develop, 2026-08-20 — `Sessions(directory).resolve(token) -> Resolution`,
|
|
12
|
+
`Policy().role(...).assign(...).check(subject, perm) -> Decision`,
|
|
13
|
+
`append(log_path, event, **fields) -> dict`) rather than an imagined
|
|
14
|
+
`Policy.load()` classmethod, which does not exist on the real class.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Optional
|
|
22
|
+
|
|
23
|
+
#: The one permission this whole module exists to gate. Not a general-purpose
|
|
24
|
+
#: RBAC layer -- one string, one door, matching "strict only where money can
|
|
25
|
+
#: be spent".
|
|
26
|
+
COMET_DEPLOY_PERMISSION = "awrun:submit:comet-deploy"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _iam_directory_path() -> Path:
|
|
30
|
+
env = os.getenv("AWRUN_IAM_DIRECTORY", "").strip()
|
|
31
|
+
return Path(env) if env else Path.home() / ".aither" / "awrun-iam.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _audit_log_path() -> Path:
|
|
35
|
+
env = os.getenv("AWRUN_AUDIT_LOG", "").strip()
|
|
36
|
+
return Path(env) if env else Path.home() / ".aither" / "awrun-audit.log"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _operators_env() -> list[str]:
|
|
40
|
+
"""Who may submit a comet-deploy, read from AWRUN_COMET_DEPLOY_OPERATORS
|
|
41
|
+
(comma-separated subject ids). Deliberately NOT a `Policy.load()` --
|
|
42
|
+
that method does not exist on the real class (an earlier draft of this
|
|
43
|
+
package's plan guessed at it). Empty by default: a money-spend path
|
|
44
|
+
fails CLOSED until an owner explicitly names who may use it, matching
|
|
45
|
+
"strict only where money can be spent" rather than "open until someone
|
|
46
|
+
remembers to lock it down"."""
|
|
47
|
+
raw = os.getenv("AWRUN_COMET_DEPLOY_OPERATORS", "").strip()
|
|
48
|
+
return [s.strip() for s in raw.split(",") if s.strip()]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _policy():
|
|
52
|
+
"""Built fresh from env on every call -- this package has no long-lived
|
|
53
|
+
process to keep a cached Policy warm across a config change, and a
|
|
54
|
+
money-gating permission set is cheap enough to rebuild every check."""
|
|
55
|
+
from awbac import Policy
|
|
56
|
+
|
|
57
|
+
p = Policy().role("comet-deploy-operator", [COMET_DEPLOY_PERMISSION])
|
|
58
|
+
operators = _operators_env()
|
|
59
|
+
if operators:
|
|
60
|
+
p = p.assign(operators[0], "comet-deploy-operator")
|
|
61
|
+
for subject in operators[1:]:
|
|
62
|
+
p = p.assign(subject, "comet-deploy-operator")
|
|
63
|
+
return p
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve_session(token: str) -> Optional[str]:
|
|
67
|
+
"""Resolve a session token to a subject id, or None if unresolvable
|
|
68
|
+
(missing token, expired session, no directory configured). Never
|
|
69
|
+
raises -- an authz-adjacent failure here must read as "not
|
|
70
|
+
authenticated", not crash the CLI."""
|
|
71
|
+
if not token:
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
from awiam import Directory, Sessions
|
|
75
|
+
except Exception:
|
|
76
|
+
return None
|
|
77
|
+
directory_path = _iam_directory_path()
|
|
78
|
+
if not directory_path.exists():
|
|
79
|
+
return None
|
|
80
|
+
try:
|
|
81
|
+
directory = Directory(str(directory_path))
|
|
82
|
+
resolution = Sessions(directory).resolve(token)
|
|
83
|
+
except Exception:
|
|
84
|
+
return None
|
|
85
|
+
if not resolution:
|
|
86
|
+
return None
|
|
87
|
+
return resolution.subject_id
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def check_permission(subject_id: str, permission: str = COMET_DEPLOY_PERMISSION):
|
|
91
|
+
"""Returns an awbac Decision (truthy on allow, `.reason` always set).
|
|
92
|
+
Fails CLOSED -- an exception building or checking the policy is treated
|
|
93
|
+
as a denial, never an allow, matching every other fail-closed gate in
|
|
94
|
+
this codebase (security-review-patterns.md #1)."""
|
|
95
|
+
from awbac import Decision
|
|
96
|
+
|
|
97
|
+
if not subject_id:
|
|
98
|
+
return Decision(allowed=False, reason="no resolved subject", via="")
|
|
99
|
+
try:
|
|
100
|
+
return _policy().check(subject_id, permission)
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
return Decision(allowed=False, reason=f"policy check raised: {exc}", via="")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def audit(event: str, **fields: Any) -> Optional[dict]:
|
|
106
|
+
"""Append one hash-chained audit record. Returns the record, or None on
|
|
107
|
+
failure -- this function itself never raises, so a submit path can
|
|
108
|
+
decide independently whether a failed audit write should block (Phase 8
|
|
109
|
+
design: it blocks comet-deploy, never agent/ci)."""
|
|
110
|
+
try:
|
|
111
|
+
from awdit import append
|
|
112
|
+
except Exception:
|
|
113
|
+
return None
|
|
114
|
+
path = _audit_log_path()
|
|
115
|
+
try:
|
|
116
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
return append(str(path), event, **fields)
|
|
118
|
+
except Exception:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _self_test() -> int:
|
|
123
|
+
import tempfile
|
|
124
|
+
|
|
125
|
+
ok = True
|
|
126
|
+
|
|
127
|
+
def check(label: str, cond: bool) -> None:
|
|
128
|
+
nonlocal ok
|
|
129
|
+
print(f" {'ok' if cond else 'FAIL'} - {label}")
|
|
130
|
+
if not cond:
|
|
131
|
+
ok = False
|
|
132
|
+
|
|
133
|
+
check("resolve_session('') is None without touching any directory",
|
|
134
|
+
resolve_session("") is None)
|
|
135
|
+
check("resolve_session() with no configured directory is None, not a crash",
|
|
136
|
+
resolve_session("some-token") is None or True) # env-dependent; must not raise
|
|
137
|
+
|
|
138
|
+
old_operators = os.environ.pop("AWRUN_COMET_DEPLOY_OPERATORS", None)
|
|
139
|
+
try:
|
|
140
|
+
os.environ.pop("AWRUN_COMET_DEPLOY_OPERATORS", None)
|
|
141
|
+
denied = check_permission("random-subject")
|
|
142
|
+
check("with no operators configured, comet-deploy permission FAILS CLOSED",
|
|
143
|
+
not denied and "no resolved subject" not in denied.reason)
|
|
144
|
+
|
|
145
|
+
os.environ["AWRUN_COMET_DEPLOY_OPERATORS"] = "david,ops-bot"
|
|
146
|
+
allowed = check_permission("david")
|
|
147
|
+
check("an operator named via AWRUN_COMET_DEPLOY_OPERATORS is granted",
|
|
148
|
+
bool(allowed))
|
|
149
|
+
allowed2 = check_permission("ops-bot")
|
|
150
|
+
check("a SECOND operator in the comma-separated list is also granted",
|
|
151
|
+
bool(allowed2))
|
|
152
|
+
still_denied = check_permission("random-subject")
|
|
153
|
+
check("a subject NOT in the operator list stays denied",
|
|
154
|
+
not still_denied)
|
|
155
|
+
finally:
|
|
156
|
+
if old_operators is not None:
|
|
157
|
+
os.environ["AWRUN_COMET_DEPLOY_OPERATORS"] = old_operators
|
|
158
|
+
else:
|
|
159
|
+
os.environ.pop("AWRUN_COMET_DEPLOY_OPERATORS", None)
|
|
160
|
+
|
|
161
|
+
check("check_permission('') fails closed with no policy call at all",
|
|
162
|
+
not check_permission(""))
|
|
163
|
+
|
|
164
|
+
with tempfile.TemporaryDirectory() as td:
|
|
165
|
+
log_path = Path(td) / "audit.log"
|
|
166
|
+
old_log = os.environ.get("AWRUN_AUDIT_LOG")
|
|
167
|
+
os.environ["AWRUN_AUDIT_LOG"] = str(log_path)
|
|
168
|
+
try:
|
|
169
|
+
record = audit("test-event", subject="david", allowed=True)
|
|
170
|
+
check("audit() returns the appended record on success", record is not None)
|
|
171
|
+
check("the audit log file was actually created", log_path.exists())
|
|
172
|
+
|
|
173
|
+
from awdit import verify
|
|
174
|
+
result = verify(str(log_path))
|
|
175
|
+
check("the audit chain verifies (no gaps)", bool(result))
|
|
176
|
+
finally:
|
|
177
|
+
if old_log is not None:
|
|
178
|
+
os.environ["AWRUN_AUDIT_LOG"] = old_log
|
|
179
|
+
else:
|
|
180
|
+
os.environ.pop("AWRUN_AUDIT_LOG", None)
|
|
181
|
+
|
|
182
|
+
# A directory that cannot possibly resolve (no such file) must degrade,
|
|
183
|
+
# never raise -- this is the difference between "not authenticated" and
|
|
184
|
+
# a crashed CLI.
|
|
185
|
+
os.environ["AWRUN_IAM_DIRECTORY"] = "/does/not/exist/iam.json"
|
|
186
|
+
try:
|
|
187
|
+
check("an unresolvable IAM directory degrades to None, not an exception",
|
|
188
|
+
resolve_session("anything") is None)
|
|
189
|
+
finally:
|
|
190
|
+
os.environ.pop("AWRUN_IAM_DIRECTORY", None)
|
|
191
|
+
|
|
192
|
+
print("SELF-TEST", "PASS" if ok else "FAIL")
|
|
193
|
+
return 0 if ok else 1
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main() -> int:
|
|
197
|
+
import argparse
|
|
198
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
199
|
+
ap.add_argument("--self-test", action="store_true")
|
|
200
|
+
args = ap.parse_args()
|
|
201
|
+
if args.self_test:
|
|
202
|
+
return _self_test()
|
|
203
|
+
ap.print_help()
|
|
204
|
+
return 2
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
import sys
|
|
209
|
+
sys.exit(main())
|