diff --git a/.github/time/delegate.py b/.github/time/delegate.py new file mode 100644 index 0000000..b95c60d --- /dev/null +++ b/.github/time/delegate.py @@ -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 # read back and verify a receipt by its correlation ID + python3 .github/time/delegate.py id # 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/.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__) diff --git a/.github/time/test_delegate.py b/.github/time/test_delegate.py new file mode 100644 index 0000000..ac7ec24 --- /dev/null +++ b/.github/time/test_delegate.py @@ -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") diff --git a/CHANGELOG.md b/CHANGELOG.md index 09019f5..6b26e0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/.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. diff --git a/time/delegate/delegate.yml b/time/delegate/delegate.yml new file mode 100644 index 0000000..92ea3c1 --- /dev/null +++ b/time/delegate/delegate.yml @@ -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":""}} +# correlation id: python3 .github/time/delegate.py id (dlg-<20 hex>) +# result: GET /repos/{owner}/{repo}/contents/time/delegations/.json (durable receipt) +# or the `TIME-RECEIPT {...}` lines in the run's log (queued/running/succeeded|failed) +# or: python3 .github/time/delegate.py fetch +# 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 }}