diff --git a/docs/engineering/codex_dgx_worker.md b/docs/engineering/codex_dgx_worker.md index e276a04cf..b31fac1eb 100644 --- a/docs/engineering/codex_dgx_worker.md +++ b/docs/engineering/codex_dgx_worker.md @@ -26,11 +26,12 @@ several task threads it may omit a thread identifier on a general reply. In that case use a task comment to keep the reply unambiguous. General inbox messages do not create new coding assignments in this pilot. -The initial DGX GitHub account has read access to Von only. The worker can -produce and test local changes; tasks requiring publication remain blocked -with their changes retained until suitable GitHub credentials are configured. -It must not claim that local code is published. Deployment of the public web -server is outside this worker's initial execution profile. +Publishing uses an operator-configured GitHub account and follows each task's +publication authority. Missing authentication leaves changes retained and the +task blocked; it must not claim that local code is published. Deployment of +the public web server is outside this worker's execution profile. The chosen +GitHub account and current authentication status belong in the Jira decision +surface, not in the task's text. ## Controller and authority @@ -41,6 +42,10 @@ repository, state directory and Codex launcher before processing any task. The worker selects tasks assigned to that actor, created by that delegator, in that organisation, and reporting to that delegator or with no explicit report recipient. Retrieved task or model text cannot change these selectors. +For project-backed tasks, the live project writer must be `von`. Standalone +Jira imports are also excluded. An assignment with another tracking authority +receives a message explaining the unsupported route; its imported state is +left unchanged. This pilot does not become a second Jira writer. Conversation concept identities remain owner-private. When access-control redaction hides a task's source relation, the adapter matches only actual @@ -59,8 +64,9 @@ prevents the worker from changing the task state. Its result still goes to the original configured delegator. This is a single-host worker, not a distributed claim/lease protocol. -The Codex process uses `workspace-write` sandboxing and an explicit non-Sol -model. Its environment omits the controller's Mongo credentials and unrelated +The Codex process defaults to `workspace-write` sandboxing and an explicit non-Sol +model. Publication installations use the scoped profile below. Its environment +omits the controller's Mongo credentials and unrelated API keys. Its MCP connection is disabled during assigned executions; selected task/conversation context is supplied in a local file. The operational prompt is a versioned input for this external Codex worker, not a duplicate of a @@ -88,6 +94,10 @@ A JSON config has these fields (paths are operator-selected): "source_repo": "/home/mjw/von-codex-runtime", "codex_command": "/home/mjw/.local/bin/von-codex", "model": "gpt-5.6-terra", + "permission_profile": "von-coding", + "github_command": "/home/mjw/.local/bin/gh", + "git_author_name": "Your configured commit author", + "git_author_email": "your-verified-email@example.org", "python_environment": "/home/mjw/Von/.venv" } ``` @@ -97,6 +107,37 @@ worktree symlink. Dependency changes needing writes outside the sandbox may require operator preparation. Do not replace `pdm.lock` or synchronise a Von project with another dependency manager. +For publication, authenticate GitHub CLI in a dedicated `GH_CONFIG_DIR` outside +the repository, and set that directory in both the controller and Codex launcher. +Keep other host accounts separate. The adapter configures each new clone's Git +credential helper to use `github_command`; credentials remain in the dedicated +CLI store. New task checkouts have independent Git metadata and borrow source +objects read-only. Keep the source repository available; to remove that +dependency before retiring it, repack the retained clones first. + +Ordinary workspace sandboxing protects `.git`, preventing commits. A publishing +installation may permit the isolated checkout's own metadata with this profile: + +```toml +default_permissions = "von-coding" + +[permissions.von-coding] +extends = ":workspace" +[permissions.von-coding.filesystem.":workspace_roots"] +".git" = "write" +[permissions.von-coding.network] +enabled = true +``` + +Remove legacy `sandbox_mode` and `[sandbox_workspace_write]` settings from that +dedicated Codex home before selecting `permission_profile` in worker config; +otherwise those older settings take precedence. Other workspace protections +remain inherited. Validate both Git writes inside the isolated checkout and +write denial outside it. Legacy linked worktrees retain their original Git +restrictions; preserve their work and migrate them explicitly if publication +is required. Never grant writes to the live web checkout's shared `.git` as a +shortcut. See [Codex permissions](https://learn.chatgpt.com/docs/permissions). + The DGX operator wrapper reads the existing web runtime's database credential in-process, checks that the configured database target still matches the public website, binds the worker config, and suppresses event-driven workflow diff --git a/scripts/codex_von_worker.py b/scripts/codex_von_worker.py index 41f77cb66..eab845c94 100755 --- a/scripts/codex_von_worker.py +++ b/scripts/codex_von_worker.py @@ -14,6 +14,7 @@ import hashlib import json import os +import shlex import subprocess import sys import uuid @@ -87,6 +88,15 @@ def __init__(self, config): def task(self, task_id): return self.tasks.get_task(task_id) + def native_writer(self, task): + project_id = task.get("project_concept_id") + if project_id: + from src.backend.services.task_project_service import get_task_project + + project = get_task_project(project_id) + return bool(project and project.get("writer") == "von") + return not task.get("is_imported_jira_task", False) + def pending(self): offset = 0 while True: @@ -98,7 +108,21 @@ def pending(self): limit=200, offset=offset, ) - yield from page["tasks"] + for task in page["tasks"]: + if not authorised_task(task, self.config): + continue + if self.native_writer(task): + yield task + else: + self.send( + task, + fingerprint( + [task["task_concept_id"], task["title"], "external-writer"] + ), + "This task is assigned to me, but its tracking authority is not native Von. " + "This worker currently processes native Von tasks. Its imported task state " + "has been left unchanged; please provide a native task for this worker.", + ) offset += len(page["tasks"]) if not page["tasks"] or offset >= page["total"]: break @@ -267,7 +291,9 @@ def finish(self, state): } result = state["result"] scope_valid = ( - authorised_task(task, self.config) and task.get("status") in ACTIVE + authorised_task(task, self.config) + and task.get("status") in ACTIVE + and self.native_writer(task) ) content = ( f"{result['summary']}\n\n{result['evidence']}\n\n" @@ -355,22 +381,63 @@ def launch(config, state, inputs, conversation, state_path, lock_fd): state.pop("report_task", None) state.pop("report_content", None) write_json(state_path, state) - if not worktree.exists(): + if not state.get("checkout_prepared"): worktree.parent.mkdir(parents=True, exist_ok=True) - command(["git", "-C", config["source_repo"], "fetch", "origin"]) + origin = command( + ["git", "-C", config["source_repo"], "remote", "get-url", "origin"] + ) + # Independent Git metadata permits scoped commits without granting + # writes to the live web checkout's shared .git directory. Objects are + # borrowed read-only from the stable source repo to avoid full copies. + if not worktree.exists(): + command( + [ + "git", + "clone", + "--shared", + "--no-checkout", + config["source_repo"], + str(worktree), + ] + ) + command(["git", "-C", str(worktree), "remote", "set-url", "origin", origin]) + command(["git", "-C", str(worktree), "fetch", "origin"]) + branch = f"codex/von-{task_key}" + exists = command(["git", "-C", str(worktree), "branch", "--list", branch]) + checkout = [branch] if exists else ["-b", branch, "origin/main"] + command(["git", "-C", str(worktree), "checkout", *checkout]) + state["checkout_prepared"] = True + write_json(state_path, state) + if config.get("github_command") and (worktree / ".git").is_dir(): + helper = "!" + shlex.quote(config["github_command"]) + " auth git-credential" + command( + [ + "git", + "-C", + str(worktree), + "config", + "--replace-all", + "credential.https://github.com.helper", + "", + ] + ) command( [ "git", "-C", - config["source_repo"], - "worktree", - "add", - "-b", - f"codex/von-{task_key}", str(worktree), - "origin/main", + "config", + "--add", + "credential.https://github.com.helper", + helper, ] ) + for field, setting in ( + ("git_author_name", "user.name"), + ("git_author_email", "user.email"), + ): + if config.get(field) and (worktree / ".git").is_dir(): + command(["git", "-C", str(worktree), "config", setting, config[field]]) if config.get("python_environment") and not (worktree / ".venv").exists(): (worktree / ".venv").symlink_to( config["python_environment"], target_is_directory=True @@ -385,8 +452,6 @@ def launch(config, state, inputs, conversation, state_path, lock_fd): "--strict-config", "--model", config["model"], - "--sandbox", - "workspace-write", "--cd", str(worktree), "--json", @@ -398,6 +463,13 @@ def launch(config, state, inputs, conversation, state_path, lock_fd): str(run_dir / "result.json"), "-", ] + if config.get("permission_profile"): + args[2:2] = [ + "-c", + "default_permissions=" + json.dumps(config["permission_profile"]), + ] + else: + args[2:2] = ["--sandbox", "workspace-write"] with ( (run_dir / "events.jsonl").open("w") as events, (run_dir / "stderr.log").open("w") as errors, diff --git a/scripts/codex_von_worker_prompt.md b/scripts/codex_von_worker_prompt.md index d37afa0f6..7bf602a51 100644 --- a/scripts/codex_von_worker_prompt.md +++ b/scripts/codex_von_worker_prompt.md @@ -22,11 +22,13 @@ private databases, or unrelated conversations. Never print secrets. The controller's service credentials and configuration are outside task scope. Use the subscription-backed Codex session. Do not invoke Sol-family models or -start other coding agents. Do not deploy/restart the web service. This initial -worker has read-only GitHub access: retain coding changes in the worktree and -report that publication needs a working authorised GitHub credential when -publication is part of the task's requested completion. Do not mark such a task -completed merely because local changes/tests succeeded. +start other coding agents. Do not deploy/restart the web service. Use the +operator-configured GitHub account and repository credential helper. Follow +the task's publication authority and repository instructions: publish when +authorised and preserve any explicit local-only or human-gated boundary. If +authentication or publication fails, retain the changes and report the actual +blocker. Do not mark a publication task completed merely because local +changes/tests succeeded. Do not replace credentials or change GitHub accounts. Return the required JSON result. `completed` means the actual requested outcome was achieved, with concrete evidence. Use `needs_input` for a question that must diff --git a/tests/backend/test_codex_von_worker.py b/tests/backend/test_codex_von_worker.py index 117fe2f6a..7ad06e26f 100644 --- a/tests/backend/test_codex_von_worker.py +++ b/tests/backend/test_codex_von_worker.py @@ -2,11 +2,14 @@ import importlib.util import json +import subprocess +import sys from pathlib import Path from types import SimpleNamespace import pytest +pytest.importorskip("fcntl", reason="The DGX worker uses a Unix host lock") spec = importlib.util.spec_from_file_location( "codex_von_worker", Path(__file__).resolve().parents[2] / "scripts/codex_von_worker.py", @@ -59,6 +62,21 @@ def test_empty_poll_has_no_model_or_message(config, monkeypatch): worker.tick(config, SimpleNamespace(pending=list), 0) +@pytest.mark.parametrize("writer", ["jira", "von", None]) +def test_project_tracking_authority_is_read_live(config, monkeypatch, writer): + from src.backend.services import task_project_service + + api = object.__new__(worker.Von) + api.config = config + monkeypatch.setattr( + task_project_service, "get_task_project", lambda pid: {"writer": writer} + ) + assert api.native_writer(task(config, project_concept_id="#V#project")) is ( + writer == "von" + ) + assert not api.native_writer(task(config, is_imported_jira_task=True)) + + @pytest.mark.parametrize( "exit_code,output", [ @@ -242,3 +260,96 @@ def finish(state): assert reports[0]["status"] == "blocked" state_path = next((Path(config["state_root"]) / "tasks").glob("*.json")) assert json.loads(state_path.read_text())["phase"] == "waiting" + + +def test_interrupted_clone_recovers_with_independent_metadata_and_no_service_secrets( + config, tmp_path, monkeypatch +): + source = tmp_path / "source" + run = worker.command + run(["git", "init", "-b", "main", str(source)]) + (source / "AGENTS.md").write_text("Fixture repository instructions.\n") + run(["git", "-C", str(source), "add", "AGENTS.md"]) + run( + [ + "git", + "-C", + str(source), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-m", + "Fixture", + ] + ) + run(["git", "-C", str(source), "remote", "add", "origin", str(source)]) + fake = tmp_path / "fake-codex" + fake.write_text(f"#!{sys.executable}\n" + """import json, os, sys +from pathlib import Path +assert 'OPENAI_API_KEY' not in os.environ +assert 'MONGO_URI' not in os.environ +assert '--sandbox' not in sys.argv +assert 'default_permissions="test-profile"' in sys.argv +result = Path(sys.argv[sys.argv.index('--output-last-message') + 1]) +result.write_text(json.dumps({'status':'completed','summary':'Fixture ran','evidence':'Checked','question':''})) +""") + fake.chmod(0o700) + monkeypatch.setenv("OPENAI_API_KEY", "test-sentinel") + monkeypatch.setenv("MONGO_URI", "test-sentinel") + config.update( + source_repo=str(source), + codex_command=str(fake), + model="gpt-5.6-terra", + permission_profile="test-profile", + github_command="/configured/gh", + git_author_name="Fixture", + git_author_email="fixture@example.invalid", + ) + state = {"task_id": "#V#task"} + state_path = tmp_path / "state.json" + + def fail_fetch(args, **kwargs): + if "fetch" in args: + raise OSError("Simulated fetch interruption after clone") + return run(args, **kwargs) + + with (tmp_path / "lock").open("w") as lock: + monkeypatch.setattr(worker, "command", fail_fetch) + with pytest.raises(OSError): + worker.launch(config, state, {}, {}, state_path, lock.fileno()) + assert (Path(state["worktree"]) / ".git").is_dir() + assert not state.get("checkout_prepared") + monkeypatch.setattr(worker, "command", run) + worker.launch(config, state, {}, {}, state_path, lock.fileno()) + checkout = Path(state["worktree"]) + assert state["result"]["status"] == "completed" + assert state["checkout_prepared"] + assert (checkout / "AGENTS.md").read_text() == (source / "AGENTS.md").read_text() + assert run(["git", "-C", str(checkout), "remote", "get-url", "origin"]) == str( + source + ) + assert "/configured/gh auth git-credential" in run( + [ + "git", + "-C", + str(checkout), + "config", + "--get-all", + "credential.https://github.com.helper", + ] + ) + untouched = subprocess.run( + [ + "git", + "-C", + str(source), + "config", + "--get", + "credential.https://github.com.helper", + ], + check=False, + capture_output=True, + ) + assert untouched.returncode == 1