Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions .github/workflows/jules-dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@ on:
required: true
default: "phase0-private-repo-smoke-test"
type: string
correlation:
description: "Opaque private-contract locator; only used by private-contract-v1"
required: false
default: ""
type: string

permissions:
contents: read

jobs:
dispatch:
# Defense in depth. Configure GitHub Workflow Execution Protection as the
# primary actor gate; this check prevents accidental execution by others.
if: github.actor == 'mark-e-deyoung'
runs-on: ubuntu-latest
timeout-minutes: 2
Expand All @@ -34,9 +37,13 @@ jobs:
env:
JULES_API_KEY: ${{ secrets.JULES_API_KEY }}
JULES_TARGETS_JSON: ${{ secrets.JULES_TARGETS_JSON }}
JULES_TARGETS_JSON_EXTRA: ${{ secrets.JULES_TARGETS_JSON_EXTRA }}
TARGET_ID: ${{ inputs.target }}
TASK_ID: ${{ inputs.task }}
CORRELATION_ID: ${{ inputs.correlation }}
run: |
python3 scripts/jules.py dispatch \
--target "$TARGET_ID" \
--task "$TASK_ID"
args=(dispatch --target "$TARGET_ID" --task "$TASK_ID")
if [ -n "$CORRELATION_ID" ]; then
args+=(--correlation "$CORRELATION_ID")
fi
python3 scripts/jules.py "${args[@]}"
3 changes: 2 additions & 1 deletion config/tasks.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"phase0-private-repo-smoke-test": "tasks/phase0-private-repo-smoke-test.md"
"phase0-private-repo-smoke-test": "tasks/phase0-private-repo-smoke-test.md",
"private-contract-v1": "tasks/private-contract-v1.md"
}
76 changes: 37 additions & 39 deletions scripts/jules.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
ROOT = Path(__file__).resolve().parents[1]
TASK_REGISTRY = ROOT / "config" / "tasks.json"
ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
PRIVATE_CONTRACT_TASK = "private-contract-v1"


def fail(message: str) -> NoReturn:
Expand All @@ -33,35 +34,40 @@ def request(method: str, path: str, body: dict | None = None) -> dict:
BASE_URL + path,
data=data,
method=method,
headers={
"x-goog-api-key": key,
"Content-Type": "application/json",
"Accept": "application/json",
},
headers={"x-goog-api-key": key, "Content-Type": "application/json", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=30) as response:
payload = response.read()
return json.loads(payload) if payload else {}
except urllib.error.HTTPError as exc:
# Do not echo provider response bodies in a public Actions log; they can
# contain private source names or other account metadata.
fail(f"Jules API request failed with HTTP {exc.code}")
except urllib.error.URLError:
fail("Jules API request failed")


def load_targets() -> dict[str, dict]:
raw = os.environ.get("JULES_TARGETS_JSON")
def parse_target_policy(name: str, required: bool) -> dict[str, dict]:
raw = os.environ.get(name)
if not raw:
fail("Jules target policy is not configured")
if required:
fail("Jules target policy is not configured")
return {}
try:
targets = json.loads(raw)
value = json.loads(raw)
except json.JSONDecodeError:
fail("Jules target policy is invalid JSON")
if not isinstance(targets, dict):
fail("Jules target policy must be a JSON object")
return targets
fail(f"{name} is invalid JSON")
if not isinstance(value, dict):
fail(f"{name} must be a JSON object")
return value


def load_targets() -> dict[str, dict]:
targets = parse_target_policy("JULES_TARGETS_JSON", required=True)
extra = parse_target_policy("JULES_TARGETS_JSON_EXTRA", required=False)
overlap = set(targets).intersection(extra)
if overlap:
fail("Extra target policy may not override an existing target alias")
return {**targets, **extra}


def resolve_target(target_id: str) -> tuple[str, str]:
Expand All @@ -79,7 +85,7 @@ def resolve_target(target_id: str) -> tuple[str, str]:
return repository, branch


def load_task(task_id: str) -> str:
def load_task(task_id: str, correlation: str | None = None) -> str:
if not ID_RE.fullmatch(task_id):
fail("Invalid task identifier")
try:
Expand All @@ -89,7 +95,6 @@ def load_task(task_id: str) -> str:
rel = registry.get(task_id) if isinstance(registry, dict) else None
if not isinstance(rel, str):
fail("Task is not approved")

tasks_root = (ROOT / "tasks").resolve()
candidate = ROOT / rel
if candidate.is_symlink():
Expand All @@ -99,7 +104,14 @@ def load_task(task_id: str) -> str:
fail("Task registry entry violates path policy")
if not path.is_file():
fail("Approved task file is unavailable")
return path.read_text(encoding="utf-8")
prompt = path.read_text(encoding="utf-8")
if task_id == PRIVATE_CONTRACT_TASK:
if correlation is None or not ID_RE.fullmatch(correlation):
fail("Private-contract task requires a valid opaque correlation identifier")
prompt = prompt.replace("{{CORRELATION}}", correlation)
elif correlation:
fail("Correlation input is only valid for the private-contract task")
return prompt


def list_sources() -> list[dict]:
Expand Down Expand Up @@ -132,63 +144,49 @@ def resolve_source(repo_full_name: str) -> dict:

def dispatch(args: argparse.Namespace) -> None:
repository, branch = resolve_target(args.target)
prompt = load_task(args.task)
prompt = load_task(args.task, args.correlation)
source = resolve_source(repository)
body = {
"prompt": prompt,
"title": f"agent-dispatch:{args.target}:{args.task}",
"sourceContext": {
"source": source["name"],
"githubRepoContext": {"startingBranch": branch},
},
"sourceContext": {"source": source["name"], "githubRepoContext": {"startingBranch": branch}},
"requirePlanApproval": False,
"automationMode": "AUTO_CREATE_PR",
}
result = request("POST", "/sessions", body)
session_id = str(result.get("id") or "")
correlation = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:12] if session_id else None
# Public workflow output intentionally omits repository, branch, session ID,
# provider URL, prompt, source identifier, activities, patches and PR URLs.
provider_correlation = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:12] if session_id else None
print(json.dumps({
"accepted": bool(session_id),
"target": args.target,
"task": args.task,
"correlation": correlation,
"request_correlation": args.correlation,
"provider_correlation": provider_correlation,
}, indent=2))


def status(args: argparse.Namespace) -> None:
# Intended for trusted/local use only. There is deliberately no public
# GitHub Actions status workflow because its output can disclose private
# target and PR metadata.
session_id = args.session.removeprefix("sessions/")
result = request("GET", f"/sessions/{urllib.parse.quote(session_id, safe='')}")
prs = []
for output in result.get("outputs", []) or []:
pr = output.get("pullRequest")
if pr:
prs.append({"url": pr.get("url"), "title": pr.get("title")})
print(json.dumps({
"id": result.get("id"),
"state": result.get("state"),
"url": result.get("url"),
"pullRequests": prs,
}, indent=2))
print(json.dumps({"id": result.get("id"), "state": result.get("state"), "url": result.get("url"), "pullRequests": prs}, indent=2))


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)

p = sub.add_parser("dispatch", help="create an approved Jules session")
p.add_argument("--target", required=True, help="approved opaque target ID")
p.add_argument("--task", required=True, help="approved task ID")
p.add_argument("--correlation", help="opaque private-contract locator")
p.set_defaults(func=dispatch)

p = sub.add_parser("status", help="retrieve a Jules session result (trusted/local use)")
p.add_argument("session")
p.set_defaults(func=status)

args = parser.parse_args()
args.func(args)

Expand Down
14 changes: 14 additions & 0 deletions tasks/private-contract-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Private contract loader v1

Open `.agent/transport/{{CORRELATION}}.json` in the already-authorized target repository.

Treat that transport record only as a locator. Follow its `dispatch_ref` to the authoritative private delegation record, then follow that record's `objective_ref` and execute exactly that private task.

Rules:

- do not infer or expand authority beyond the private delegation/task records;
- do not copy private repository names, private task content, source URLs, artifact identities, provider metadata, or result details into public repositories;
- obey the private task's egress, validator, attempt, and completion policies;
- do not weaken validation or classification controls to obtain a passing result;
- produce the durable result in the target repository as required by the private task;
- if the private locator/dispatch/task record is missing or malformed, stop without improvising.