Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85ebc48a37 | ||
|
|
23d5d31132 | ||
|
|
32c50e17f3 | ||
|
|
5c0b2d64df | ||
|
|
cbf0ce29c1 | ||
|
|
b254628d37 |
No files matched your search
@@ -0,0 +1,235 @@
|
||||
"""Claude delegation: a bounded API task dispatched through a workflow, with a job receipt a caller can fetch.
|
||||
|
||||
python3 .github/time/delegate.py run # in the `delegate` workflow (workflow_dispatch inputs arrive as env)
|
||||
python3 .github/time/delegate.py fetch <id> # read back and verify a receipt by its correlation ID
|
||||
python3 .github/time/delegate.py id <caller> <key> # the correlation ID a caller will get for (caller, key)
|
||||
|
||||
Not a new provider client: the model call is time.py's `think`, so seat routing, the Anthropic client, retries, the
|
||||
ledger and the dollar governor (TIME_DAILY_USD / TIME_MONTHLY_USD) are the ones every other Time workflow uses. Nothing
|
||||
is posted to an issue: the transport is workflow_dispatch in, and a receipt out.
|
||||
|
||||
Inputs (all env, set by the workflow from its dispatch inputs; never interpolated into a shell):
|
||||
DELEGATE_TASK the prompt, at most MAX_TASK characters
|
||||
DELEGATE_SEAT `sonnet` or `opus`
|
||||
DELEGATE_KEY idempotency key, 8-64 chars of [A-Za-z0-9._-]. NOT a secret: it appears in the run name.
|
||||
GITHUB_ACTOR the caller; must pass time.py's `trusted()` (owner, TIME_TRUSTED, collaborator)
|
||||
|
||||
Correlation ID: `dlg-` + the first 20 hex of sha256(lowercase(caller) NUL key). The caller can compute it before
|
||||
dispatching, and two callers can never read or collide with each other's keys.
|
||||
|
||||
Receipt: time/delegations/<id>.json on the default branch (durable, pushed like the spend ledger), and the same JSON on a
|
||||
`TIME-RECEIPT {...}` line of the run's log at every state change (the Actions run/log API). State machine:
|
||||
queued -> running -> succeeded | failed. A receipt holds the request's hash (never the task text), the source SHA and
|
||||
run URL, the model, tokens and dollars, the result and its sha256, or a redacted error. `fetch` recomputes the hash.
|
||||
|
||||
Duplicates: the same (caller, key, request) after a success replays the stored receipt and spends nothing; while a run is
|
||||
live (updated within STALE_MIN minutes) it reports the live state; after a failure or a stale run it retries, up to
|
||||
MAX_ATTEMPTS. The same key with a different seat or task is refused. Failed attempts are retryable by re-dispatching.
|
||||
|
||||
Secrets never reach a receipt or the log: the task is stored only as a hash, and every result and error passes through
|
||||
`redact` (env secret values, key-shaped strings, credentialed URLs, bearer tokens) before it is hashed or written.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
_spec = importlib.util.spec_from_file_location("tm", HERE / "time.py")
|
||||
tm = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(tm)
|
||||
|
||||
ALLOWED_SEATS = ("sonnet", "opus")
|
||||
MAX_TASK = 8000 # characters of task
|
||||
KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{7,63}$")
|
||||
ID_RE = re.compile(r"^dlg-[0-9a-f]{20}$")
|
||||
STALE_MIN = 20 # a run silent this long is presumed dead, and a re-dispatch takes over
|
||||
MAX_ATTEMPTS = 3 # per key: a failing key must not spend without bound
|
||||
DIR = "time/delegations"
|
||||
MARK = "TIME-RECEIPT "
|
||||
SYSTEM = ("You are a Claude seat reached through Time's delegation endpoint. Do the task you are given, concretely and "
|
||||
"briefly. You cannot browse or run code; say so if the task needs that. Never reveal credentials.")
|
||||
|
||||
|
||||
class Rejected(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def correlation_id(caller: str, key: str) -> str:
|
||||
return "dlg-" + hashlib.sha256(f"{caller.lower()}\0{key}".encode()).hexdigest()[:20]
|
||||
|
||||
|
||||
def receipt_path(cid: str) -> str:
|
||||
return f"{DIR}/{cid}.json"
|
||||
|
||||
|
||||
def sha(text: str) -> str:
|
||||
return hashlib.sha256(text.encode()).hexdigest()
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
# ---------- no secrets ----------
|
||||
|
||||
SECRET_ENV = re.compile(r"TOKEN|KEY|SECRET|PASSWORD", re.I)
|
||||
SECRET_SHAPES = [re.compile(p) for p in (r"sk-[A-Za-z0-9_-]{16,}", r"(?i)bearer\s+[A-Za-z0-9._~+/=-]{12,}",
|
||||
r"(?i)x-access-token:[^@\s]+", r"://[^/\s:@]+:[^/\s@]+@")]
|
||||
|
||||
|
||||
def redact(text: str) -> str:
|
||||
"""Remove every secret value in the environment (6+ chars) and anything shaped like a key or a credentialed URL."""
|
||||
text = text or ""
|
||||
for k, v in os.environ.items():
|
||||
if SECRET_ENV.search(k) and len(v) >= 6:
|
||||
text = text.replace(v, "[redacted]")
|
||||
for pat in SECRET_SHAPES:
|
||||
text = pat.sub("[redacted]", text)
|
||||
return text
|
||||
|
||||
|
||||
# ---------- validation: bounded, and only for people allowed to spend ----------
|
||||
|
||||
def validate(caller: str, seat: str, key: str, task: str) -> None:
|
||||
if not tm.trusted(caller): # first, so a stranger learns nothing else
|
||||
raise Rejected("unauthorized: caller is not the owner, in TIME_TRUSTED, or a collaborator")
|
||||
if seat not in ALLOWED_SEATS:
|
||||
raise Rejected(f"unsupported seat {seat[:20]!r}; use one of {', '.join(ALLOWED_SEATS)}")
|
||||
if not KEY_RE.match(key):
|
||||
raise Rejected("invalid idempotency key: 8-64 chars of A-Z a-z 0-9 . _ -")
|
||||
if not task.strip():
|
||||
raise Rejected("empty task")
|
||||
if len(task) > MAX_TASK:
|
||||
raise Rejected(f"task is {len(task)} characters; the cap is {MAX_TASK}")
|
||||
|
||||
|
||||
# ---------- receipts ----------
|
||||
|
||||
def read_receipt(cid: str) -> dict | None:
|
||||
try:
|
||||
f = tm.gh("GET", f"/repos/{tm.REPO}/contents/{receipt_path(cid)}")
|
||||
except RuntimeError as e:
|
||||
if "-> 404" in str(e):
|
||||
return None
|
||||
raise
|
||||
return json.loads(base64.b64decode(f.get("content") or "").decode())
|
||||
|
||||
|
||||
def emit(r: dict) -> None:
|
||||
"""The receipt on the run's log: a caller reading the run's log through the Actions API finds the last such line."""
|
||||
print(MARK + json.dumps(r, sort_keys=True, separators=(",", ":")), flush=True)
|
||||
|
||||
|
||||
def save(r: dict, why: str) -> None:
|
||||
r["updated_at"] = now()
|
||||
emit(r)
|
||||
tm.git_commit(tm.default_branch(), {receipt_path(r["id"]): (json.dumps(r, sort_keys=True, indent=1) + "\n").encode()},
|
||||
f"delegation {r['id']}: {why}")
|
||||
|
||||
|
||||
def move(r: dict, state: str) -> None:
|
||||
r["state"] = state
|
||||
r["history"].append({"state": state, "at": now()})
|
||||
save(r, state)
|
||||
|
||||
|
||||
def stale(r: dict) -> bool:
|
||||
try:
|
||||
at = dt.datetime.strptime(r["updated_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=dt.timezone.utc)
|
||||
except (KeyError, ValueError):
|
||||
return True
|
||||
return dt.datetime.now(dt.timezone.utc) - at > dt.timedelta(minutes=STALE_MIN)
|
||||
|
||||
|
||||
def source() -> dict:
|
||||
run_id = os.environ.get("GITHUB_RUN_ID") or ""
|
||||
return {"sha": os.environ.get("GITHUB_SHA") or "", "run_id": run_id,
|
||||
"run_url": f"{tm.SERVER}/{tm.REPO}/actions/runs/{run_id}" if run_id else "",
|
||||
"workflow": os.environ.get("GITHUB_WORKFLOW") or ""}
|
||||
|
||||
|
||||
# ---------- run ----------
|
||||
|
||||
def run() -> int:
|
||||
caller = (os.environ.get("GITHUB_ACTOR") or "").strip()
|
||||
seat = (os.environ.get("DELEGATE_SEAT") or "sonnet").strip().lower()
|
||||
key = (os.environ.get("DELEGATE_KEY") or "").strip()
|
||||
task = os.environ.get("DELEGATE_TASK") or ""
|
||||
try:
|
||||
validate(caller, seat, key, task)
|
||||
except Rejected as e:
|
||||
emit({"state": "rejected", "reason": str(e)}) # no receipt file: a stranger must not be able to write to the repo
|
||||
print(f"rejected: {e}")
|
||||
return 2
|
||||
cid, req = correlation_id(caller, key), sha(f"{seat}\0{task}")
|
||||
prior = read_receipt(cid)
|
||||
if prior:
|
||||
if prior.get("request_sha256") != req:
|
||||
emit({"state": "rejected", "id": cid, "reason": "idempotency key already used for a different request"})
|
||||
return 2
|
||||
if prior["state"] == "succeeded" or (prior["state"] in ("queued", "running") and not stale(prior)):
|
||||
emit({**prior, "replayed": True}) # same request: the stored answer, no new spend, no new commit
|
||||
return 0
|
||||
if prior.get("attempt", 1) >= MAX_ATTEMPTS:
|
||||
emit({**prior, "replayed": True, "note": f"attempts exhausted ({MAX_ATTEMPTS}); use a new key"})
|
||||
return 1
|
||||
r = {"schema": 1, "id": cid, "key": key, "caller": caller, "seat": seat, "model": tm.SEATS[seat]["default"],
|
||||
"request_sha256": req, "task_chars": len(task), "state": "queued", "attempt": (prior or {}).get("attempt", 0) + 1,
|
||||
"history": [{"state": "queued", "at": now()}], "source": source(), "created_at": (prior or {}).get("created_at") or now()}
|
||||
if prior:
|
||||
r["previous"] = {"state": prior["state"], "error": prior.get("error"), "run_url": (prior.get("source") or {}).get("run_url")}
|
||||
save(r, "queued")
|
||||
move(r, "running")
|
||||
before = len(tm.LEDGER)
|
||||
try:
|
||||
text = redact(tm.think(seat, None, None, SYSTEM, task))
|
||||
rows = tm.LEDGER[before:]
|
||||
r["usage"] = {"in": sum(x["in"] for x in rows), "out": sum(x["out"] for x in rows),
|
||||
"usd": round(sum(x["usd"] or 0 for x in rows), 6)}
|
||||
r["result"], r["result_sha256"] = text, sha(text)
|
||||
final = "succeeded"
|
||||
except tm.Budget as e:
|
||||
r["error"], final = {"kind": "budget_exhausted", "message": redact(str(e))[:300]}, "failed"
|
||||
except LookupError as e:
|
||||
r["error"], final = {"kind": "not_configured", "message": redact(str(e))[:300]}, "failed"
|
||||
except (RuntimeError, OSError) as e:
|
||||
r["error"], final = {"kind": "api_error", "message": redact(str(e))[:300]}, "failed"
|
||||
tm.flush_ledger() # the spend is booked before the receipt claims it
|
||||
move(r, final)
|
||||
return 0 if final == "succeeded" else 1
|
||||
|
||||
|
||||
def fetch(cid: str) -> int:
|
||||
"""Print the receipt for `cid`. Exit 0: found and verified (read the state in it), 1: failed verification, 2: bad id, 3: none."""
|
||||
if not ID_RE.match(cid):
|
||||
print("bad correlation id")
|
||||
return 2
|
||||
r = read_receipt(cid)
|
||||
if r is None:
|
||||
print(f"no receipt for {cid}")
|
||||
return 3
|
||||
if r.get("id") != cid or (r["state"] == "succeeded" and sha(r.get("result") or "") != r.get("result_sha256")):
|
||||
print(f"verification failed for {cid}: the stored result does not match its hash")
|
||||
return 1
|
||||
print(json.dumps(r, sort_keys=True, indent=1))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cmd = sys.argv[1:2]
|
||||
if cmd == ["run"]:
|
||||
sys.exit(run())
|
||||
if cmd == ["fetch"] and len(sys.argv) == 3:
|
||||
sys.exit(fetch(sys.argv[2]))
|
||||
if cmd == ["id"] and len(sys.argv) == 4:
|
||||
print(correlation_id(sys.argv[2], sys.argv[3]))
|
||||
sys.exit(0)
|
||||
sys.exit(__doc__)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Offline tests for delegate.py: fake forge + fake Anthropic, through time.py's real think/chat/budget/ledger.
|
||||
Run: python3 .github/time/test_delegate.py"""
|
||||
import base64, contextlib, importlib.util, io, json, os, sys, datetime as dt
|
||||
from pathlib import Path
|
||||
|
||||
for _k in [k for k in os.environ if k.startswith(("TIME_", "DELEGATE_"))] + ["ANTHROPIC_API_KEY", "VENICE_API_KEY"]:
|
||||
os.environ.pop(_k, None)
|
||||
os.environ.update(GITHUB_REPOSITORY="o/r", GITHUB_TOKEN="gh-token-secret", GITHUB_SERVER_URL="https://forge.test",
|
||||
GITHUB_API_URL="https://forge.test/api/v1", TIME_VENICE_BACKOFF="0", TIME_TRUSTED="alice",
|
||||
GITHUB_SHA="a" * 40, GITHUB_RUN_ID="77", GITHUB_WORKFLOW="delegate")
|
||||
spec = importlib.util.spec_from_file_location("dg", Path(__file__).resolve().parent / "delegate.py")
|
||||
dg = importlib.util.module_from_spec(spec); spec.loader.exec_module(dg)
|
||||
tm = dg.tm
|
||||
KEY = "ak-live-secret-value-9876"
|
||||
NOW = dt.datetime.now(dt.timezone.utc)
|
||||
|
||||
|
||||
class World:
|
||||
def __init__(self):
|
||||
self.files, self.sent, self.mode, self.pushes = {}, [], "ok", 0
|
||||
|
||||
def gh(self, method, path, body=None, *, as_time=None):
|
||||
if path == "/repos/o/r":
|
||||
return {"default_branch": "main"}
|
||||
if path.startswith("/repos/o/r/collaborators/"):
|
||||
raise RuntimeError(f"GET {path} -> 404: no")
|
||||
if "/contents/" in path:
|
||||
fp = path.split("/contents/")[1].split("?")[0]
|
||||
if fp not in self.files:
|
||||
raise RuntimeError(f"GET {path} -> 404: not found")
|
||||
return {"content": base64.b64encode(self.files[fp].encode()).decode(), "sha": "s"}
|
||||
raise AssertionError(f"unexpected {method} {path}")
|
||||
|
||||
def git_commit(self, branch, files, message, *, base=None, append=None):
|
||||
assert branch == "main"
|
||||
self.pushes += 1
|
||||
for fp, data in (files or {}).items():
|
||||
self.files[fp] = data.decode()
|
||||
for fp, data in (append or {}).items():
|
||||
self.files[fp] = self.files.get(fp, "") + data.decode()
|
||||
return "sha"
|
||||
|
||||
def http(self, method, url, token, body=None, timeout=0):
|
||||
assert url.startswith(tm.ANTHROPIC) and token == KEY, url # the existing client, the repo secret
|
||||
self.sent.append(body)
|
||||
if self.mode == "down":
|
||||
raise RuntimeError(f"POST {url} -> 500: upstream said {KEY} is bad")
|
||||
text = {"ok": "the answer", "leak": f"here: {KEY} and sk-abcdefghijklmnopqrstuvwx and gh-token-secret"}[self.mode]
|
||||
return {"choices": [{"message": {"content": text}}], "usage": {"prompt_tokens": 1000, "completion_tokens": 200}}
|
||||
|
||||
def ledger(self, usd):
|
||||
self.files[f"time/ledger/{NOW:%Y-%m}.jsonl"] = json.dumps({"at": NOW.strftime("%Y-%m-%dT%H:%M:%SZ"), "model": "claude-opus-5-5", "usd": usd}) + "\n"
|
||||
|
||||
|
||||
def dispatch(w, task="Summarize X", seat="sonnet", key="idem-key-0001", actor="alice", mode="ok"):
|
||||
"""One workflow run: env in, receipt out. Returns (exit code, log text)."""
|
||||
tm.gh, tm.git_commit, tm.http = w.gh, w.git_commit, w.http
|
||||
tm._ledger_files.clear(); tm.LEDGER.clear()
|
||||
w.mode = mode
|
||||
os.environ.update(GITHUB_ACTOR=actor, DELEGATE_SEAT=seat, DELEGATE_KEY=key, DELEGATE_TASK=task, ANTHROPIC_API_KEY=KEY)
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
code = dg.run()
|
||||
return code, out.getvalue()
|
||||
|
||||
|
||||
def last_receipt(log):
|
||||
lines = [l for l in log.splitlines() if l.startswith(dg.MARK)]
|
||||
return json.loads(lines[-1][len(dg.MARK):]) if lines else None
|
||||
|
||||
|
||||
def fetch(w, cid):
|
||||
tm.gh = w.gh
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
code = dg.fetch(cid)
|
||||
return code, out.getvalue()
|
||||
|
||||
|
||||
CID = dg.correlation_id("alice", "idem-key-0001")
|
||||
|
||||
# the correlation id is stable, caller-scoped, and computable before dispatching
|
||||
assert CID == dg.correlation_id("ALICE", "idem-key-0001") and CID != dg.correlation_id("o", "idem-key-0001") and dg.ID_RE.match(CID)
|
||||
|
||||
# ---- success: queued -> running -> succeeded, source and cost on the receipt, result fetched back by id
|
||||
w = World()
|
||||
code, log = dispatch(w)
|
||||
r = last_receipt(log)
|
||||
assert code == 0 and r["state"] == "succeeded" and r["result"] == "the answer" and r["id"] == CID, log
|
||||
assert [h["state"] for h in r["history"]] == ["queued", "running", "succeeded"]
|
||||
assert [json.loads(l[len(dg.MARK):])["state"] for l in log.splitlines() if l.startswith(dg.MARK)] == ["queued", "running", "succeeded"]
|
||||
assert r["source"] == {"sha": "a" * 40, "run_id": "77", "run_url": "https://forge.test/o/r/actions/runs/77", "workflow": "delegate"}
|
||||
assert r["usage"] == {"in": 1000, "out": 200, "usd": 0.004} and r["model"] == "claude-sonnet-5-5" and r["attempt"] == 1
|
||||
assert len(w.sent) == 1 and w.sent[0]["model"] == "claude-sonnet-5-5" and w.sent[0]["messages"][1]["content"] == "Summarize X"
|
||||
assert "Summarize X" not in w.files[dg.receipt_path(CID)], "the task is stored as a hash only"
|
||||
assert json.loads(w.files[dg.receipt_path(CID)]) == r or json.loads(w.files[dg.receipt_path(CID)])["state"] == "succeeded"
|
||||
# the spend landed in the ledger the governor reads
|
||||
assert json.loads(w.files[f"time/ledger/{NOW:%Y-%m}.jsonl"].splitlines()[-1])["usd"] == 0.004
|
||||
code, text = fetch(w, CID)
|
||||
assert code == 0 and json.loads(text)["result"] == "the answer"
|
||||
assert fetch(w, "dlg-" + "0" * 20)[0] == 3 and fetch(w, "../x")[0] == 2
|
||||
# a tampered result fails verification
|
||||
good = w.files[dg.receipt_path(CID)]
|
||||
w.files[dg.receipt_path(CID)] = good.replace("the answer", "forged")
|
||||
assert fetch(w, CID)[0] == 1
|
||||
w.files[dg.receipt_path(CID)] = good
|
||||
|
||||
# ---- duplicate key: same request replays, spends nothing, writes nothing
|
||||
pushes = w.pushes
|
||||
code, log = dispatch(w)
|
||||
assert code == 0 and last_receipt(log)["replayed"] is True and last_receipt(log)["result"] == "the answer"
|
||||
assert len(w.sent) == 1 and w.pushes == pushes
|
||||
# same key, different task or seat: refused
|
||||
for kw in ({"task": "Something else"}, {"seat": "opus"}):
|
||||
code, log = dispatch(w, **kw)
|
||||
assert code == 2 and "different request" in last_receipt(log)["reason"] and len(w.sent) == 1
|
||||
# another caller's identical key is a separate job
|
||||
code, log = dispatch(w, actor="o")
|
||||
assert code == 0 and last_receipt(log)["id"] != CID and len(w.sent) == 2
|
||||
|
||||
# ---- rejected inputs: unauthorized, unsupported, oversized; no model call, no file
|
||||
w = World()
|
||||
for kw, why in (({"actor": "mallory"}, "unauthorized"), ({"seat": "haiku"}, "unsupported seat"), ({"seat": "kimi"}, "unsupported seat"),
|
||||
({"task": "x" * (dg.MAX_TASK + 1)}, "cap is"), ({"task": " "}, "empty"), ({"key": "short"}, "invalid idempotency key"),
|
||||
({"key": "bad key with spaces"}, "invalid idempotency key")):
|
||||
code, log = dispatch(w, **kw)
|
||||
assert code == 2 and why in last_receipt(log)["reason"] and last_receipt(log)["state"] == "rejected", (kw, log)
|
||||
assert w.sent == [] and w.files == {} and w.pushes == 0
|
||||
code, log = dispatch(w, seat="opus", task="y" * dg.MAX_TASK) # exactly at the cap is fine, and opus is allowed
|
||||
assert code == 0 and last_receipt(log)["model"] == "claude-opus-5-5"
|
||||
|
||||
# ---- budget exhaustion: failed with a clear kind, no API call, still a durable receipt
|
||||
w = World(); w.ledger(6)
|
||||
code, log = dispatch(w)
|
||||
r = last_receipt(log)
|
||||
assert code == 1 and r["state"] == "failed" and r["error"]["kind"] == "budget_exhausted" and "cap" in r["error"]["message"] and w.sent == []
|
||||
assert json.loads(w.files[dg.receipt_path(CID)])["state"] == "failed"
|
||||
assert "result" not in r and fetch(w, CID)[0] == 0
|
||||
|
||||
# ---- API failure then retry: failed, then the same key re-dispatched succeeds as attempt 2
|
||||
w = World()
|
||||
code, log = dispatch(w, mode="down")
|
||||
r = last_receipt(log)
|
||||
assert code == 1 and r["state"] == "failed" and r["error"]["kind"] == "api_error" and KEY not in log and KEY not in json.dumps(w.files)
|
||||
assert len(w.sent) == 4, "time.py's own retry loop ran before giving up"
|
||||
code, log = dispatch(w)
|
||||
r = last_receipt(log)
|
||||
assert code == 0 and r["state"] == "succeeded" and r["attempt"] == 2 and r["previous"]["state"] == "failed" and r["created_at"]
|
||||
assert [h["state"] for h in r["history"]] == ["queued", "running", "succeeded"]
|
||||
# a budget failure is retryable too, once the cap is raised
|
||||
w = World(); w.ledger(6); dispatch(w); w.files.pop(f"time/ledger/{NOW:%Y-%m}.jsonl")
|
||||
assert dispatch(w)[0] == 0 and last_receipt(dispatch(w)[1])["attempt"] == 2
|
||||
# retries are bounded
|
||||
w = World()
|
||||
for _ in range(dg.MAX_ATTEMPTS):
|
||||
dispatch(w, mode="down")
|
||||
n = len(w.sent)
|
||||
code, log = dispatch(w)
|
||||
assert code == 1 and "attempts exhausted" in last_receipt(log)["note"] and len(w.sent) == n
|
||||
|
||||
# ---- an in-flight run is not duplicated; a stale one is taken over
|
||||
w = World()
|
||||
dispatch(w, mode="down")
|
||||
live = json.loads(w.files[dg.receipt_path(CID)])
|
||||
live.update(state="running", updated_at=dg.now())
|
||||
w.files[dg.receipt_path(CID)] = json.dumps(live)
|
||||
n = len(w.sent)
|
||||
code, log = dispatch(w)
|
||||
assert code == 0 and last_receipt(log)["state"] == "running" and last_receipt(log)["replayed"] and len(w.sent) == n
|
||||
live["updated_at"] = (NOW - dt.timedelta(minutes=dg.STALE_MIN + 1)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
w.files[dg.receipt_path(CID)] = json.dumps(live)
|
||||
code, log = dispatch(w)
|
||||
assert code == 0 and last_receipt(log)["state"] == "succeeded" and last_receipt(log)["previous"]["state"] == "running"
|
||||
|
||||
# ---- no secret leakage: a model that echoes secrets, and every place they could land
|
||||
w = World()
|
||||
code, log = dispatch(w, mode="leak")
|
||||
blob = log + json.dumps(w.files) + json.dumps(fetch(w, CID))
|
||||
for secret in (KEY, "gh-token-secret", "sk-abcdefghijklmnopqrstuvwx"):
|
||||
assert secret not in blob, secret
|
||||
assert "[redacted]" in last_receipt(log)["result"] and last_receipt(log)["state"] == "succeeded"
|
||||
assert dg.sha(last_receipt(log)["result"]) == last_receipt(log)["result_sha256"] and fetch(w, CID)[0] == 0
|
||||
assert "x-access-token:[redacted]" in dg.redact("https://x-access-token:abc123@forge.test/o/r.git") or "abc123" not in dg.redact("https://x-access-token:abc123@forge.test/o/r.git")
|
||||
assert "abc123" not in dg.redact("https://u:abc123@h/x") and "Bearer" not in dg.redact("Authorization: Bearer abcdefghijklmnop12345").replace("Authorization: ", "")
|
||||
# a missing key is a failed receipt that names the secret, never its value
|
||||
w = World(); tm.gh, tm.git_commit, tm.http = w.gh, w.git_commit, w.http
|
||||
tm._ledger_files.clear(); tm.LEDGER.clear()
|
||||
os.environ.update(GITHUB_ACTOR="alice", DELEGATE_SEAT="sonnet", DELEGATE_KEY="idem-key-0002", DELEGATE_TASK="t")
|
||||
os.environ.pop("ANTHROPIC_API_KEY")
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
assert dg.run() == 1
|
||||
assert last_receipt(out.getvalue())["error"]["kind"] == "not_configured" and w.sent == []
|
||||
|
||||
print("ALL OK delegate")
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Claude delegation over `workflow_dispatch` (`.github/time/delegate.py`, tests in `test_delegate.py`): a bounded task for the `sonnet` or `opus` seat with an idempotency key, run through time.py's own `think`, seat routing and dollar governor. Each job has a stable correlation ID (`dlg-<20 hex>`, from caller and key) and a receipt (`queued`/`running`/`succeeded`/`failed`, source SHA, run URL, model, tokens, dollars, result and its hash) at `time/delegations/<id>.json` and on `TIME-RECEIPT` log lines. Duplicate keys replay the stored result without spending, a failed key retries (3 attempts), and the task text, secrets and key-shaped strings never reach a receipt or log. No issue comments. The workflow itself is a proposal in `time/delegate/delegate.yml`: it needs a human to review and install it under `.github/workflows/`.
|
||||
|
||||
### Security
|
||||
- The worker's own commit status (`time/worker-run`) can fail or delay a merge but never earns one: with no other check on the PR head, Time dispatches the project's CI workflow on the PR branch (`TIME_CI_WORKFLOW`, default `ci.yml`, which must accept `workflow_dispatch`; GitHub skips `pull_request` runs for pushes made with the Actions token) and merges on that. `off` keeps the old "no checks" stop.
|
||||
- `.github/` is protected from auto-merge whatever `TIME_PROTECTED` says.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: delegate
|
||||
# PROPOSED, NOT ACTIVE. A human installs this as .github/workflows/delegate.yml after independent review
|
||||
# (workflow files are off limits to the worker, and Time never auto-merges anything under .github/).
|
||||
#
|
||||
# Bounded Claude API delegation: workflow_dispatch in, job receipt out. No issue comments.
|
||||
# POST /api/v1/repos/{owner}/{repo}/actions/workflows/delegate.yml/dispatches
|
||||
# {"ref":"main","inputs":{"seat":"sonnet","key":"<8-64 chars>","task":"<prompt, <= 8000 chars>"}}
|
||||
# correlation id: python3 .github/time/delegate.py id <caller-login> <key> (dlg-<20 hex>)
|
||||
# result: GET /repos/{owner}/{repo}/contents/time/delegations/<id>.json (durable receipt)
|
||||
# or the `TIME-RECEIPT {...}` lines in the run's log (queued/running/succeeded|failed)
|
||||
# or: python3 .github/time/delegate.py fetch <id>
|
||||
# Inputs reach the script only through env, never through shell interpolation. Authorization is time.py's trusted()
|
||||
# on the dispatching actor (owner, TIME_TRUSTED, collaborators); the dispatch API itself already needs write access.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
seat:
|
||||
description: "Claude seat: sonnet or opus"
|
||||
required: true
|
||||
default: sonnet
|
||||
key:
|
||||
description: "Idempotency key, 8-64 chars [A-Za-z0-9._-]. Not a secret: it shows in the run name."
|
||||
required: true
|
||||
task:
|
||||
description: "The task, at most 8000 characters"
|
||||
required: true
|
||||
run-name: delegate ${{ inputs.seat }} ${{ inputs.key }}
|
||||
# (GitHub-style `permissions:` are ignored by Gitea; the Actions token has write access to this repo)
|
||||
concurrency:
|
||||
group: delegate-${{ github.actor }}-${{ inputs.key }} # one live run per (caller, key): duplicates queue behind it and replay
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
delegate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: .github/time
|
||||
- run: python3 .github/time/test_delegate.py
|
||||
- run: python3 .github/time/delegate.py run
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
TIME_TRUSTED: ${{ vars.TIME_TRUSTED }}
|
||||
TIME_DAILY_USD: ${{ vars.TIME_DAILY_USD }}
|
||||
TIME_MONTHLY_USD: ${{ vars.TIME_MONTHLY_USD }}
|
||||
DELEGATE_SEAT: ${{ inputs.seat }}
|
||||
DELEGATE_KEY: ${{ inputs.key }}
|
||||
DELEGATE_TASK: ${{ inputs.task }}
|
||||
Reference in new issue
Block a user