passiveworkers 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.
- council/__init__.py +1 -0
- council/artifacts.py +161 -0
- council/batch.py +84 -0
- council/cli.py +54 -0
- council/coordinator.py +133 -0
- council/crypto.py +133 -0
- council/fidelity.py +197 -0
- council/judge.py +393 -0
- council/ledger.py +230 -0
- council/library.py +431 -0
- council/local.py +228 -0
- council/mcp_server.py +87 -0
- council/net/__init__.py +1 -0
- council/net/agent.py +231 -0
- council/net/app.py +390 -0
- council/net/baseline.py +86 -0
- council/net/config.py +79 -0
- council/net/coordinator_app.py +370 -0
- council/net/dashboard.py +111 -0
- council/net/store.py +964 -0
- council/net/submit.py +102 -0
- council/operator.py +412 -0
- council/research.py +520 -0
- council/researcher.py +300 -0
- council/retrieval.py +80 -0
- council/run_demo.py +175 -0
- council/sanitize.py +78 -0
- council/serve.py +183 -0
- council/trust.py +168 -0
- council/worker.py +123 -0
- passiveworkers-0.1.0.dist-info/METADATA +269 -0
- passiveworkers-0.1.0.dist-info/RECORD +36 -0
- passiveworkers-0.1.0.dist-info/WHEEL +5 -0
- passiveworkers-0.1.0.dist-info/entry_points.txt +2 -0
- passiveworkers-0.1.0.dist-info/licenses/LICENSE +21 -0
- passiveworkers-0.1.0.dist-info/top_level.txt +1 -0
council/net/submit.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
council/net/submit.py — ask the networked Council a question
|
|
4
|
+
============================================================
|
|
5
|
+
Submits a question to the coordinator and polls until the council has answered,
|
|
6
|
+
judged, and merged — then prints the perspectives, scores, the merged answer, and the
|
|
7
|
+
credit movements.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
PW_COORDINATOR=http://127.0.0.1:8088 PW_TOKEN=… \
|
|
11
|
+
python -m council.net.submit --asker alice "Your question here"
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def trunc(t: str, n: int = 110) -> str:
|
|
25
|
+
t = " ".join((t or "").split())
|
|
26
|
+
return t if len(t) <= n else t[:n] + "…"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> int:
|
|
30
|
+
ap = argparse.ArgumentParser()
|
|
31
|
+
ap.add_argument("question")
|
|
32
|
+
ap.add_argument("--asker", default="alice")
|
|
33
|
+
ap.add_argument("--timeout", type=int, default=600)
|
|
34
|
+
args = ap.parse_args()
|
|
35
|
+
|
|
36
|
+
base = os.environ.get("PW_COORDINATOR", "http://127.0.0.1:8088").rstrip("/")
|
|
37
|
+
|
|
38
|
+
# End-users authenticate with their own secret (not the operator token). Get one:
|
|
39
|
+
# reuse PW_USER_SECRET if set, else sign up the handle (with a unique suffix if taken).
|
|
40
|
+
user_secret = os.environ.get("PW_USER_SECRET")
|
|
41
|
+
handle = args.asker
|
|
42
|
+
if not user_secret:
|
|
43
|
+
for attempt in range(3):
|
|
44
|
+
ru = requests.post(f"{base}/users", json={"handle": handle}, timeout=15)
|
|
45
|
+
if ru.status_code == 409: # handle taken → try a fresh one
|
|
46
|
+
handle = f"{args.asker}-{os.urandom(3).hex()}"
|
|
47
|
+
continue
|
|
48
|
+
ru.raise_for_status()
|
|
49
|
+
user_secret = ru.json()["user_secret"]
|
|
50
|
+
break
|
|
51
|
+
if not user_secret:
|
|
52
|
+
print("✗ could not create a user"); return 1
|
|
53
|
+
headers = {"X-User-Secret": user_secret}
|
|
54
|
+
|
|
55
|
+
r = requests.post(f"{base}/jobs", json={"question": args.question},
|
|
56
|
+
headers=headers, timeout=15)
|
|
57
|
+
r.raise_for_status()
|
|
58
|
+
job = r.json()
|
|
59
|
+
if job["status"] == "failed":
|
|
60
|
+
print(f"✗ job failed: {job.get('error')}")
|
|
61
|
+
return 1
|
|
62
|
+
job_id = job["job_id"]
|
|
63
|
+
print(f"submitted job {job_id[:8]}… ({args.asker} asks) → {job.get('assigned', [])} workers")
|
|
64
|
+
|
|
65
|
+
deadline = time.monotonic() + args.timeout
|
|
66
|
+
last = None
|
|
67
|
+
while time.monotonic() < deadline:
|
|
68
|
+
v = requests.get(f"{base}/jobs/{job_id}", timeout=15).json()
|
|
69
|
+
if v["status"] != last:
|
|
70
|
+
print(f" … {v['status']}")
|
|
71
|
+
last = v["status"]
|
|
72
|
+
if v["status"] in ("done", "failed"):
|
|
73
|
+
break
|
|
74
|
+
time.sleep(2)
|
|
75
|
+
|
|
76
|
+
if v["status"] == "failed":
|
|
77
|
+
print(f"✗ job failed: {v.get('error')}")
|
|
78
|
+
return 1
|
|
79
|
+
if v["status"] != "done":
|
|
80
|
+
print("✗ timed out")
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
print("\n" + "=" * 78)
|
|
84
|
+
print(f"Q ({v['asker']}): {v['question']}")
|
|
85
|
+
print("=" * 78)
|
|
86
|
+
print(f"\n{'perspective':<34}{'score':>7} one-line")
|
|
87
|
+
print("-" * 78)
|
|
88
|
+
for a in sorted(v["answers"], key=lambda x: -(x["score"] or 0)):
|
|
89
|
+
lbl = f"{a['owner']} [{a['model']}/{a['lens']}/{a['country']}]"
|
|
90
|
+
print(f"{lbl:<34}{(a['score'] or 0):>7.1f} {trunc(a['text'])}")
|
|
91
|
+
print("\nMERGED ANSWER:\n")
|
|
92
|
+
print(v["merged"])
|
|
93
|
+
rec = v.get("receipt") or {}
|
|
94
|
+
if rec:
|
|
95
|
+
payouts = ", ".join(f"{o} +{c:.1f}" for o, c in (rec.get("payouts") or {}).items())
|
|
96
|
+
print(f"\nledger: {rec.get('asker_id')} −{rec.get('total_cost', 0):.1f} | {payouts} | "
|
|
97
|
+
f"judge +{rec.get('judge_fee', 0):.1f}")
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
sys.exit(main())
|
council/operator.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
council/operator.py — the operator side of assisted tasks (D21)
|
|
4
|
+
================================================================
|
|
5
|
+
"Computers doing work for other computers", with a human in the loop. An operator who
|
|
6
|
+
has joined a coordinator can see OPEN assisted offers, give informed consent to one,
|
|
7
|
+
do the work themselves (with their own agentic AI — Claude, Codex — or by hand), and
|
|
8
|
+
deliver the owned result. Our software NEVER automates the operator's computer; the
|
|
9
|
+
human is always the agent and always consents to a bounded brief.
|
|
10
|
+
|
|
11
|
+
pw tasks list open assisted offers you're eligible for
|
|
12
|
+
pw accept <task_id> consent to + claim an offer (prints the full brief)
|
|
13
|
+
pw deliver <task_id> <text> deliver your result (text, or @path to a file)
|
|
14
|
+
pw fingerprint print your signing pubkey + fingerprint to share (operator)
|
|
15
|
+
pw trust add <op> <key> pin an operator's signing key out of band (asker)
|
|
16
|
+
pw trust list | remove <op> show / drop pinned operators (asker)
|
|
17
|
+
pw fetch <job> <dir> download + verify a delivered file (asker)
|
|
18
|
+
pw keygen | rate <job> <0-10> encryption key / rate a deliverable (asker)
|
|
19
|
+
|
|
20
|
+
Requires (operator env): PW_COORDINATOR, PW_TOKEN, PW_OWNER (your handle). Optional:
|
|
21
|
+
PW_NAME, PW_COUNTRY. Identity (node_id + secret) is cached in ~/.passiveworkers/operator.json
|
|
22
|
+
per coordinator so accept→deliver use the same node.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import pathlib
|
|
30
|
+
import platform
|
|
31
|
+
import socket
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
import requests
|
|
35
|
+
|
|
36
|
+
STATE = pathlib.Path(os.environ.get("PW_LIBRARY_DIR",
|
|
37
|
+
str(pathlib.Path.home() / ".passiveworkers"))) / "operator.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _profile() -> dict:
|
|
41
|
+
prof = {"os": platform.system(), "machine": platform.machine()}
|
|
42
|
+
try:
|
|
43
|
+
import psutil
|
|
44
|
+
prof["ram_gb"] = round(psutil.virtual_memory().total / 1e9, 1)
|
|
45
|
+
prof["cores"] = psutil.cpu_count(logical=False) or psutil.cpu_count()
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
try:
|
|
49
|
+
base = os.environ.get("PW_OLLAMA_BASE", "http://localhost:11434")
|
|
50
|
+
r = requests.get(f"{base}/api/tags", timeout=5)
|
|
51
|
+
prof["models"] = sorted(m["name"] for m in r.json().get("models", []))[:40]
|
|
52
|
+
except Exception:
|
|
53
|
+
prof["models"] = []
|
|
54
|
+
return prof
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Operator:
|
|
58
|
+
def __init__(self):
|
|
59
|
+
self.base = os.environ.get("PW_COORDINATOR", "").rstrip("/")
|
|
60
|
+
self.token = os.environ.get("PW_TOKEN", "dev-token")
|
|
61
|
+
if not self.base:
|
|
62
|
+
raise SystemExit("set PW_COORDINATOR (e.g. http://127.0.0.1:8791)")
|
|
63
|
+
self.owner = os.environ.get("PW_OWNER", socket.gethostname())
|
|
64
|
+
self.node_id, self.secret = self._identity()
|
|
65
|
+
|
|
66
|
+
def _headers(self) -> dict:
|
|
67
|
+
h = {"X-PW-Token": self.token, "Content-Type": "application/json"}
|
|
68
|
+
if self.secret:
|
|
69
|
+
h["X-Node-Secret"] = self.secret
|
|
70
|
+
return h
|
|
71
|
+
|
|
72
|
+
def _sign_pub(self) -> str:
|
|
73
|
+
"""The operator's persisted Ed25519 verify key (b64), or '' without the crypto extra."""
|
|
74
|
+
try:
|
|
75
|
+
from council import crypto as C
|
|
76
|
+
if not C.available():
|
|
77
|
+
return ""
|
|
78
|
+
kp = C.load_or_create(STATE.parent / "operator_keys.json", "sign")
|
|
79
|
+
self._sign_priv = kp["priv"]
|
|
80
|
+
return kp["pub"]
|
|
81
|
+
except Exception:
|
|
82
|
+
return ""
|
|
83
|
+
|
|
84
|
+
def _sign(self, data: bytes) -> tuple[str, str]:
|
|
85
|
+
"""(signature_b64, signer_pub_b64) over data, or ('','') without the crypto extra."""
|
|
86
|
+
pub = self._sign_pub()
|
|
87
|
+
if not pub:
|
|
88
|
+
return "", ""
|
|
89
|
+
try:
|
|
90
|
+
from council import crypto as C
|
|
91
|
+
return C.sign(self._sign_priv, data), pub
|
|
92
|
+
except Exception:
|
|
93
|
+
return "", ""
|
|
94
|
+
|
|
95
|
+
def _identity(self) -> tuple[str, str]:
|
|
96
|
+
"""Reuse a cached node identity for this coordinator, else register one."""
|
|
97
|
+
if STATE.exists():
|
|
98
|
+
try:
|
|
99
|
+
cache = json.loads(STATE.read_text()).get(self.base)
|
|
100
|
+
if cache:
|
|
101
|
+
return cache["node_id"], cache["secret"]
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
prof = _profile()
|
|
105
|
+
prof["sign_pub"] = self._sign_pub() # publish the operator's verify key (D23)
|
|
106
|
+
body = {"name": os.environ.get("PW_NAME", socket.gethostname()),
|
|
107
|
+
"country": os.environ.get("PW_COUNTRY", "local"), "owner": self.owner,
|
|
108
|
+
"answer_model": "", "lens": "operator", "can_judge": False,
|
|
109
|
+
"judge_model": "", "machine_id": os.environ.get("PW_MACHINE_ID", socket.gethostname()),
|
|
110
|
+
"profile": prof}
|
|
111
|
+
r = requests.post(f"{self.base}/nodes/register", json=body,
|
|
112
|
+
headers={"X-PW-Token": self.token}, timeout=15)
|
|
113
|
+
r.raise_for_status()
|
|
114
|
+
d = r.json()
|
|
115
|
+
STATE.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
all_state = {}
|
|
117
|
+
if STATE.exists():
|
|
118
|
+
try:
|
|
119
|
+
all_state = json.loads(STATE.read_text())
|
|
120
|
+
except Exception:
|
|
121
|
+
all_state = {}
|
|
122
|
+
all_state[self.base] = {"node_id": d["node_id"], "secret": d["node_secret"]}
|
|
123
|
+
STATE.write_text(json.dumps(all_state))
|
|
124
|
+
try:
|
|
125
|
+
os.chmod(STATE, 0o600) # node_secret is a bearer credential — owner-only
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
return d["node_id"], d["node_secret"]
|
|
129
|
+
|
|
130
|
+
def tasks(self) -> int:
|
|
131
|
+
offers = requests.get(f"{self.base}/tasks/offers", headers=self._headers(),
|
|
132
|
+
timeout=15).json().get("offers", [])
|
|
133
|
+
if not offers:
|
|
134
|
+
print("No open assisted offers you're eligible for right now.")
|
|
135
|
+
return 0
|
|
136
|
+
for o in offers:
|
|
137
|
+
print(f"\n● {o['task_id']} (reward {o['price']} cr · open {o['age_s']:.0f}s)")
|
|
138
|
+
print(f" brief: {o['brief']}")
|
|
139
|
+
if o.get("requires"):
|
|
140
|
+
print(f" needs: {o['requires']}")
|
|
141
|
+
print("\nAccept one with: pw accept <task_id>")
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
def accept(self, task_id: str) -> int:
|
|
145
|
+
r = requests.post(f"{self.base}/tasks/{task_id}/accept", headers=self._headers(), timeout=15)
|
|
146
|
+
if not r.ok:
|
|
147
|
+
print(f"✗ {r.json().get('detail', r.text)}"); return 1
|
|
148
|
+
d = r.json()
|
|
149
|
+
print(f"✓ accepted {task_id}\n\nBRIEF:\n{d['brief']}\n")
|
|
150
|
+
if d.get("context"):
|
|
151
|
+
print(f"CONTEXT:\n{d['context']}\n")
|
|
152
|
+
print("Do the work (your own AI or by hand), then:\n"
|
|
153
|
+
f" pw deliver {task_id} \"<your result>\" (or @path/to/file)")
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
def _send_deliver(self, task_id: str, deliverable: str) -> "requests.Response":
|
|
157
|
+
payload = deliverable[:200_000] # the exact bytes that get stored/verified
|
|
158
|
+
sig, signer = self._sign(payload.encode()) # sign EXACTLY what is delivered (D23)
|
|
159
|
+
return requests.post(f"{self.base}/tasks/{task_id}/deliver", headers=self._headers(),
|
|
160
|
+
data=json.dumps({"deliverable": payload,
|
|
161
|
+
"signature": sig, "signer_pub": signer}), timeout=30)
|
|
162
|
+
|
|
163
|
+
def deliver(self, task_id: str, deliverable: str, job_id: str = "") -> int:
|
|
164
|
+
# @path delivers a real FILE: chunk → upload content-addressed blobs → deliver a signed
|
|
165
|
+
# manifest the asker fetches+verifies+reassembles (D22). Plain text stays inline. If the
|
|
166
|
+
# asker published an encryption key (encrypt_to), each chunk is sealed to it (D23).
|
|
167
|
+
if deliverable.startswith("@"):
|
|
168
|
+
from council import artifacts as A
|
|
169
|
+
path = deliverable[1:]
|
|
170
|
+
if not job_id:
|
|
171
|
+
print("✗ file delivery needs the job id: pw deliver <task_id> @file <job_id>")
|
|
172
|
+
return 2
|
|
173
|
+
view = requests.get(f"{self.base}/jobs/{job_id}", timeout=15).json()
|
|
174
|
+
encrypt_to = view.get("encrypt_to") or ""
|
|
175
|
+
if encrypt_to:
|
|
176
|
+
from council import crypto as C
|
|
177
|
+
if not C.available():
|
|
178
|
+
print("✗ this job requires encryption — install the crypto extra: pip install 'passiveworkers[crypto]'")
|
|
179
|
+
return 2
|
|
180
|
+
manifest, blobs = A.chunk_file_encrypted(path, lambda b: C.seal(encrypt_to, b))
|
|
181
|
+
tag = " (encrypted)"
|
|
182
|
+
else:
|
|
183
|
+
manifest, blobs = A.chunk_file(path)
|
|
184
|
+
tag = ""
|
|
185
|
+
bh = {"X-PW-Token": self.token, "Content-Type": "application/octet-stream"}
|
|
186
|
+
if self.secret:
|
|
187
|
+
bh["X-Node-Secret"] = self.secret
|
|
188
|
+
for h, buf in blobs.items():
|
|
189
|
+
rb = requests.post(f"{self.base}/jobs/{job_id}/blobs/{h}", headers=bh,
|
|
190
|
+
data=buf, timeout=60)
|
|
191
|
+
if not rb.ok:
|
|
192
|
+
print(f"✗ upload failed: {rb.json().get('detail', rb.text)}"); return 1
|
|
193
|
+
r = self._send_deliver(task_id, A.wrap_artifact(manifest))
|
|
194
|
+
if not r.ok:
|
|
195
|
+
print(f"✗ {r.json().get('detail', r.text)}"); return 1
|
|
196
|
+
print(f"✓ delivered file '{manifest['name']}' ({len(blobs)} chunks){tag} — you've been paid.")
|
|
197
|
+
return 0
|
|
198
|
+
r = self._send_deliver(task_id, deliverable)
|
|
199
|
+
if not r.ok:
|
|
200
|
+
print(f"✗ {r.json().get('detail', r.text)}"); return 1
|
|
201
|
+
print(f"✓ delivered — you've been paid. job {r.json().get('job_id', '')[:8]}")
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _verify_delivery_signature(view: dict, merged: str) -> tuple[bool, int]:
|
|
206
|
+
"""Decide whether a delivery's signature clears out-of-band trust (D25). Pure except for the
|
|
207
|
+
TOFU pin side effect (which it surfaces, never swallows). Returns (ok, exit_code): ok=False
|
|
208
|
+
means the caller must abort with exit_code.
|
|
209
|
+
|
|
210
|
+
The guarantee for a PINNED operator holds even against a fully hostile coordinator:
|
|
211
|
+
• it can't simply omit the signature (a pinned operator MUST sign → refused),
|
|
212
|
+
• it can't blank the operator handle to dodge the pin (signed-but-unidentified → refused),
|
|
213
|
+
• it can't present a different key (classify → MISMATCH → refused),
|
|
214
|
+
• it can't forge a signature under the pinned key (crypto.verify fails → refused),
|
|
215
|
+
• it can't make us accept unverified by hiding the crypto extra (→ refused, not downgraded).
|
|
216
|
+
"""
|
|
217
|
+
from council import trust
|
|
218
|
+
sig, signer = view.get("signature"), view.get("signer_pub")
|
|
219
|
+
operator = (view.get("operator") or "").strip()
|
|
220
|
+
# A signed delivery must name its operator, or there's no out-of-band anchor to trust.
|
|
221
|
+
if sig and signer and not operator:
|
|
222
|
+
print("✗ signed delivery has no operator handle to anchor trust — refusing")
|
|
223
|
+
return False, 1
|
|
224
|
+
pinned = trust.get(operator) if operator else None
|
|
225
|
+
# A pinned operator always signs; an unsigned delivery from them is a downgrade attack.
|
|
226
|
+
if pinned and not (sig and signer):
|
|
227
|
+
print(f"✗ operator '{operator}' is pinned but this delivery is unsigned — refusing")
|
|
228
|
+
return False, 1
|
|
229
|
+
if not (sig and signer):
|
|
230
|
+
print("⚠ delivery is unsigned and the operator is unpinned — integrity is NOT "
|
|
231
|
+
"cryptographically verified (ask the operator to deliver with the crypto extra, "
|
|
232
|
+
"then pin them with `pw trust add`)")
|
|
233
|
+
return True, 0
|
|
234
|
+
status, existing = trust.classify(operator, signer)
|
|
235
|
+
if status == trust.MISMATCH:
|
|
236
|
+
print(f"✗ operator '{operator}' presented key {trust.fingerprint(signer)} but the pinned "
|
|
237
|
+
f"key is {existing['fp']} — refusing (key rotation, another machine, or tampering).\n"
|
|
238
|
+
f" If you trust the new key, verify it out of band then: pw trust add {operator} {signer}")
|
|
239
|
+
return False, 1
|
|
240
|
+
from council import crypto as C
|
|
241
|
+
if not C.available():
|
|
242
|
+
print("✗ signature present but the crypto extra isn't installed — cannot verify, refusing "
|
|
243
|
+
"to accept unverified (pip install 'passiveworkers[crypto]')")
|
|
244
|
+
return False, 2
|
|
245
|
+
verify_key = existing["pub"] if existing else signer
|
|
246
|
+
if not C.verify(verify_key, merged.encode(), sig):
|
|
247
|
+
print("✗ signature INVALID — deliverable may be tampered or signed by another key")
|
|
248
|
+
return False, 1
|
|
249
|
+
if status == trust.PINNED_MATCH:
|
|
250
|
+
print(f"signature: ✓ valid · operator '{operator}' matches pinned key {existing['fp']}")
|
|
251
|
+
else: # UNPINNED → TOFU-pin a VALID key (never a bad one)
|
|
252
|
+
try:
|
|
253
|
+
trust.pin(operator, signer, source="tofu")
|
|
254
|
+
print(f"signature: ✓ valid · first contact — pinned operator '{operator}' as "
|
|
255
|
+
f"{trust.fingerprint(signer)} (confirm out of band: ask them for `pw fingerprint`)")
|
|
256
|
+
except Exception as e: # surface a persistence failure, don't hide it
|
|
257
|
+
print(f"signature: ✓ valid, but could NOT save the trust pin ({e}) — future deliveries "
|
|
258
|
+
f"from '{operator}' won't be auto-verified until you run: pw trust add {operator} {signer}")
|
|
259
|
+
return True, 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def fetch(job_id: str, out_dir: str) -> int:
|
|
263
|
+
"""Asker side: download a delivered FILE artifact, verify every chunk, reassemble.
|
|
264
|
+
Needs PW_COORDINATOR + PW_USER_SECRET (the asker's secret)."""
|
|
265
|
+
from council import artifacts as A
|
|
266
|
+
base = os.environ.get("PW_COORDINATOR", "").rstrip("/")
|
|
267
|
+
sec = os.environ.get("PW_USER_SECRET", "")
|
|
268
|
+
if not base or not sec:
|
|
269
|
+
print("✗ set PW_COORDINATOR and PW_USER_SECRET (the asker's secret)"); return 2
|
|
270
|
+
uh = {"X-User-Secret": sec}
|
|
271
|
+
view = requests.get(f"{base}/jobs/{job_id}", timeout=15).json()
|
|
272
|
+
merged = view.get("merged") or ""
|
|
273
|
+
ok, code = _verify_delivery_signature(view, merged)
|
|
274
|
+
if not ok:
|
|
275
|
+
return code
|
|
276
|
+
# downgrade guard: if WE required encryption (encrypt_to set), refuse a plaintext deliverable
|
|
277
|
+
if view.get("encrypt_to"):
|
|
278
|
+
m = A.read_artifact(merged)
|
|
279
|
+
if not (m and m.get("encrypted")):
|
|
280
|
+
print("✗ this job required encryption but the deliverable is not encrypted — rejecting")
|
|
281
|
+
return 1
|
|
282
|
+
manifest = A.read_artifact(merged)
|
|
283
|
+
if not manifest:
|
|
284
|
+
print("This job's deliverable is text, not a file:\n")
|
|
285
|
+
print(merged[:2000]); return 0
|
|
286
|
+
decrypt = None
|
|
287
|
+
if manifest.get("encrypted"):
|
|
288
|
+
from council import crypto as C
|
|
289
|
+
if not C.available():
|
|
290
|
+
print("✗ encrypted deliverable — install: pip install 'passiveworkers[crypto]'"); return 2
|
|
291
|
+
kp = C.load_or_create(_asker_keys_path(), "box")
|
|
292
|
+
decrypt = lambda ct: C.unseal(kp["priv"], ct)
|
|
293
|
+
def _chunk(h):
|
|
294
|
+
r = requests.get(f"{base}/jobs/{job_id}/blob/{h}", headers=uh, timeout=60)
|
|
295
|
+
return r.content if r.ok else None
|
|
296
|
+
dest = A.reassemble(manifest, _chunk, out_dir, decrypt=decrypt)
|
|
297
|
+
print(f"✓ verified + reassembled → {dest}")
|
|
298
|
+
return 0
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _asker_keys_path():
|
|
302
|
+
return pathlib.Path(os.environ.get("PW_LIBRARY_DIR",
|
|
303
|
+
str(pathlib.Path.home() / ".passiveworkers"))) / "asker_keys.json"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def rate(job_id: str, score: str) -> int:
|
|
307
|
+
"""Asker side: rate a completed assisted job (0-10) → the operator's reputation.
|
|
308
|
+
Needs PW_COORDINATOR + PW_USER_SECRET (the asker's secret)."""
|
|
309
|
+
base = os.environ.get("PW_COORDINATOR", "").rstrip("/")
|
|
310
|
+
sec = os.environ.get("PW_USER_SECRET", "")
|
|
311
|
+
if not base or not sec:
|
|
312
|
+
print("✗ set PW_COORDINATOR and PW_USER_SECRET (the asker's secret)"); return 2
|
|
313
|
+
try:
|
|
314
|
+
s = float(score)
|
|
315
|
+
except ValueError:
|
|
316
|
+
print("✗ score must be a number 0-10"); return 2
|
|
317
|
+
r = requests.post(f"{base}/jobs/{job_id}/rate", headers={"X-User-Secret": sec},
|
|
318
|
+
data=json.dumps({"score": s}), timeout=15)
|
|
319
|
+
if not r.ok:
|
|
320
|
+
print(f"✗ {r.json().get('detail', r.text)}"); return 1
|
|
321
|
+
print(f"✓ rated — operator reputation now {r.json().get('operator_reputation')}")
|
|
322
|
+
return 0
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def keygen() -> int:
|
|
326
|
+
"""Asker: create/reveal your encryption PUBLIC key. Include it as `encrypt_to` when posting
|
|
327
|
+
an assisted job so operators encrypt the deliverable so only you can open it."""
|
|
328
|
+
from council import crypto as C
|
|
329
|
+
if not C.available():
|
|
330
|
+
print("install the crypto extra first: pip install 'passiveworkers[crypto]'"); return 2
|
|
331
|
+
kp = C.load_or_create(_asker_keys_path(), "box")
|
|
332
|
+
print("Your encryption public key (pass as encrypt_to when posting a job):\n")
|
|
333
|
+
print(kp["pub"])
|
|
334
|
+
return 0
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def fingerprint() -> int:
|
|
338
|
+
"""Operator: print your SIGNING public key + its fingerprint so an asker can pin you out of
|
|
339
|
+
band (the trust anchor a hostile coordinator can't forge, D25)."""
|
|
340
|
+
from council import crypto as C
|
|
341
|
+
from council import trust
|
|
342
|
+
if not C.available():
|
|
343
|
+
print("install the crypto extra first: pip install 'passiveworkers[crypto]'"); return 2
|
|
344
|
+
kp = C.load_or_create(STATE.parent / "operator_keys.json", "sign")
|
|
345
|
+
print("Your signing public key (share with askers so they can `pw trust add <you> <key>`):\n")
|
|
346
|
+
print(kp["pub"])
|
|
347
|
+
print(f"\nFingerprint (read this to them over a trusted channel): {trust.fingerprint(kp['pub'])}")
|
|
348
|
+
return 0
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def trust_cmd(args: list) -> int:
|
|
352
|
+
"""Asker: manage pinned operator signing keys (out-of-band trust, D25).
|
|
353
|
+
pw trust add <operator> <pubkey> pin an operator's signing key
|
|
354
|
+
pw trust list show pinned operators + fingerprints
|
|
355
|
+
pw trust remove <operator> unpin
|
|
356
|
+
"""
|
|
357
|
+
from council import trust
|
|
358
|
+
sub = args[0] if args else ""
|
|
359
|
+
if sub == "add" and len(args) >= 3:
|
|
360
|
+
rec = trust.pin(args[1], args[2], source="manual")
|
|
361
|
+
print(f"✓ pinned operator '{args[1]}' → {rec['fp']}\n"
|
|
362
|
+
f" Confirm this fingerprint matches what the operator told you (pw fingerprint).")
|
|
363
|
+
return 0
|
|
364
|
+
if sub == "list":
|
|
365
|
+
pins = trust.list_pins()
|
|
366
|
+
if not pins:
|
|
367
|
+
print("No pinned operators yet. They're pinned on first signed delivery (TOFU), or "
|
|
368
|
+
"explicitly with: pw trust add <operator> <pubkey>")
|
|
369
|
+
return 0
|
|
370
|
+
for op, rec in sorted(pins.items()):
|
|
371
|
+
print(f" {op:<20} {rec['fp']} ({rec.get('source', '?')})")
|
|
372
|
+
return 0
|
|
373
|
+
if sub == "remove" and len(args) >= 2:
|
|
374
|
+
print("✓ unpinned" if trust.remove(args[1]) else "✗ no such pinned operator")
|
|
375
|
+
return 0
|
|
376
|
+
print("usage: pw trust add <operator> <pubkey> | pw trust list | pw trust remove <operator>")
|
|
377
|
+
return 2
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def main() -> int:
|
|
381
|
+
args = sys.argv[1:]
|
|
382
|
+
if not args:
|
|
383
|
+
print("usage: pw tasks | accept <id> | deliver <id> <text|@file <job>> | fetch <job> <dir>\n"
|
|
384
|
+
" keygen | rate <job> <0-10> | fingerprint | trust add|list|remove")
|
|
385
|
+
return 2
|
|
386
|
+
if args[0] == "fetch" and len(args) >= 3:
|
|
387
|
+
return fetch(args[1], args[2])
|
|
388
|
+
if args[0] == "keygen":
|
|
389
|
+
return keygen()
|
|
390
|
+
if args[0] == "fingerprint":
|
|
391
|
+
return fingerprint()
|
|
392
|
+
if args[0] == "trust":
|
|
393
|
+
return trust_cmd(args[1:])
|
|
394
|
+
if args[0] == "rate" and len(args) >= 3:
|
|
395
|
+
return rate(args[1], args[2])
|
|
396
|
+
op = Operator()
|
|
397
|
+
cmd, rest = args[0], args[1:]
|
|
398
|
+
if cmd == "tasks":
|
|
399
|
+
return op.tasks()
|
|
400
|
+
if cmd == "accept" and rest:
|
|
401
|
+
return op.accept(rest[0])
|
|
402
|
+
if cmd == "deliver" and len(rest) >= 2:
|
|
403
|
+
# file form: pw deliver <task_id> @path <job_id> · text form: pw deliver <task_id> <text…>
|
|
404
|
+
if rest[1].startswith("@"):
|
|
405
|
+
return op.deliver(rest[0], rest[1], rest[2] if len(rest) > 2 else "")
|
|
406
|
+
return op.deliver(rest[0], " ".join(rest[1:]))
|
|
407
|
+
print("usage: pw tasks | pw accept <id> | pw deliver <id> <text | @file <job_id>>")
|
|
408
|
+
return 2
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
if __name__ == "__main__":
|
|
412
|
+
sys.exit(main())
|