From 3e38d72bba862b339452aeec4ac21497b140684d Mon Sep 17 00:00:00 2001 From: sint <17594145+jugol@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:07:58 +0900 Subject: [PATCH 1/5] Add expiring artifact delivery outbox --- .agents/skills/codex-agent-init/SKILL.md | 15 +- .agents/skills/portable-agent-memory/SKILL.md | 9 + .codex/hooks.json | 15 +- .github/workflows/smoke.yml | 2 + .gitignore | 1 + AGENTS.md | 10 + CONTRIBUTING.md | 2 + README.ko.md | 16 +- README.md | 16 +- docs/DESIGN.md | 29 +- docs/SECURITY.md | 10 +- scripts/agent-startup-context.py | 20 +- scripts/artifactctl.py | 693 ++++++++++++++++++ scripts/prepublish-check.ps1 | 29 +- tests/artifactctl_test.py | 484 ++++++++++++ tests/prepublish-smoke.ps1 | 18 +- 16 files changed, 1347 insertions(+), 22 deletions(-) create mode 100644 scripts/artifactctl.py create mode 100644 tests/artifactctl_test.py diff --git a/.agents/skills/codex-agent-init/SKILL.md b/.agents/skills/codex-agent-init/SKILL.md index 5232376..65a486b 100644 --- a/.agents/skills/codex-agent-init/SKILL.md +++ b/.agents/skills/codex-agent-init/SKILL.md @@ -53,6 +53,7 @@ Before the first external write, show a compact plan containing: - the current or proposed local device ID and its private `50-devices//` scope - the fact that `.agent-memory.local.json` and `.agent-vault` remain machine-local and ignored by Git - the fact that `.codex/hooks.json` is reviewed public source and Codex will require one-time trust before its startup commands run +- the hourly device-local scheduler that permanently deletes expired files from the ignored seven-day `output/` delivery outbox Resolve any correction, then run one matching command from the repository root: @@ -64,6 +65,15 @@ python scripts/agent-memory.py setup --vault-root --project-id Do not supply a device ID unless the user explicitly needs a stable custom value. Let the tool generate a unique local ID. +When Python is available, install or refresh the device-local outbox sweeper from the repository root. Before the first install, inspect `output/`. If it contains pre-policy entries and no activation state, show the exact directory and item count, then require the user to review and move that legacy directory outside the repository; never enroll it automatically. + +```text +python scripts/artifactctl.py sweep --json +python scripts/artifactctl.py install-sweeper --json +``` + +The install command activates only an empty outbox, uses the current user's Windows Task Scheduler on Windows and the current user's crontab on macOS or Linux, pins the current script hash, and affects only this checkout's ignored `output/` path. Explain this local scheduled write before running it. Use `python scripts/artifactctl.py uninstall-sweeper` when intentionally decommissioning the checkout. + If permissions prevent writing the selected external folder or creating the local link, request only the narrow permission needed for those exact paths. Never weaken the repository's ignore or publication rules. ## 3. Verify the result @@ -73,8 +83,8 @@ If permissions prevent writing the selected external folder or creating the loca 3. Confirm that the project ID, device ID, agent profile ID, vault path, and eligible sources are internally consistent. The full `INDEX.md` and `MEMORY_POLICY.md` are routing-only documents and must not be treated as automatic model context. 4. Confirm that `doctor` bounded its budget scan to the working-set areas, stopped once review was certain or a hard traversal cap was reached, and did not inventory large cold storage. Review any warning it emitted. Run the exhaustive read-only `budget` command when setup adopted a non-empty vault or storage size is otherwise relevant. Its default working-set, whole-vault, and single-file thresholds are 1 MiB/250 files, 100 MiB/1,000 files, and 16 KiB, with warnings at 80%; a successful aggregate report must not expose private filenames or contents. 5. With Python, run `python scripts/agent-startup-context.py --check`. Confirm `status=ready`, both event profiles, the expected compact sections, the 12 KiB and 8 KiB rendered ceilings, the expected connection mode, and a hook/script hash match. Do not print private rendered content as a setup report. -6. Confirm that `.codex/hooks.json` contains both `SessionStart` and `SubagentStart`. Explain that Codex skips new or changed non-managed hooks until the user reviews and trusts them. Use the surface's hook review UI when available; in the CLI, direct the user to `/hooks`. -7. Explain that hook trust and startup injection begin on a new or resumed task. When Python is available and the current setup turn needs the normal session profile without invoking a hook, use `python scripts/agent-startup-context.py --render --event SessionStart`. +6. Confirm that `.codex/hooks.json` contains both `SessionStart` and `SubagentStart`, that `SessionStart` also pins `scripts/artifactctl.py sweep`, and that the hourly local sweeper is installed. Explain that Codex skips new or changed non-managed hooks until the user reviews and trusts them. Use the surface's hook review UI when available; in the CLI, direct the user to `/hooks`. +7. Explain that hook trust, startup injection, and catch-up outbox cleanup begin on a new or resumed task. The independent hourly scheduler handles expiry without spending a model turn. When Python is available and the current setup turn needs the normal session profile without invoking a hook, use `python scripts/agent-startup-context.py --render --event SessionStart`. 8. Confirm the event profiles: `SessionStart` uses compact Soul, project, and durable-preference sections plus available current-device/current-handoff sections; `SubagentStart` uses only compact project plus available current-device/current-handoff sections. Confirm that only the current device profile can be selected and another device's folder is never adopted automatically. If an older vault lacks the current profile, explain that setup can add it without overwriting shared memory. 9. For a new vault, confirm that `40-documents/` is a cold, optional preservation area and is absent from bounded startup context. Older compatible vaults may omit it until the first explicitly approved original is preserved. Documents and archives still count toward the whole-vault budget even though they are never automatic context. 10. If validation fails, explain the failed invariant and preserve the existing files. Budget warnings require review but never authorize setup to delete, truncate, rewrite, move, or archive private material automatically. @@ -108,6 +118,7 @@ Report: - the selected lifecycle runner - whether personalization is complete - whether the startup hook is ready and trusted, and whether a new task is needed to activate it +- whether the hourly temporary-delivery sweeper is installed and that `output/` files expire permanently after seven days - whether the connection is local to this checkout or inherited safely from a sibling worktree - the current device ID and whether its device-scoped profile is ready - that Drive-only coordination cannot reliably detect every active writer and parallel vault writes should be avoided diff --git a/.agents/skills/portable-agent-memory/SKILL.md b/.agents/skills/portable-agent-memory/SKILL.md index 105ea54..6a2ab3b 100644 --- a/.agents/skills/portable-agent-memory/SKILL.md +++ b/.agents/skills/portable-agent-memory/SKILL.md @@ -68,6 +68,15 @@ Read [schema-and-policy.md](references/schema-and-policy.md) before creating, pr 6. Treat all other device folders as cold, non-applicable data. Drive synchronizes them, but their contents must not configure the current device. 7. Never store credentials, cookies, authentication state, secret values, or sensitive local identifiers. Device scoping controls applicability, not who can read the synchronized file. +## Promote from the temporary delivery outbox deliberately + +1. Treat an activated repository `output/` as a fixed seven-day user-delivery outbox, never as memory or a reusable library. Managed packages and unmanaged entries are permanently deleted by `scripts/artifactctl.py sweep`; do not add a `keep` marker or durable subfolder there. If legacy entries prevent activation, review and move the exact legacy directory outside the repository instead of enrolling it. +2. Before finishing a task that delivered files, decide whether future work needs the exact file or only the knowledge it contains. Summarize knowledge through the smallest justified memory record instead of retaining a large artifact. +3. When the exact file may deserve preservation, run `python scripts/artifactctl.py inspect ` to obtain the exact source, byte size, SHA-256 digest, and expiry. Use that evidence in the preservation proposal. +4. Do not preserve the file merely because the agent predicts reuse. Show the user the exact file, size, reason, intended relative destination under `40-documents/`, and privacy review, then require explicit approval for that file and destination. +5. After approval, acquire the advisory lease and follow the original-document workflow below. The approved preservation copy is durable; the delivery copy remains subject to its original permanent-deletion time. +6. Include the outbox path and exact expiry in the user-facing delivery. Never create a current handoff whose continuation depends on an expiring outbox file. + ## Preserve an original document selectively 1. Never scan, collect, or upload local documents automatically. diff --git a/.codex/hooks.json b/.codex/hooks.json index e43f4d5..c472e06 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -6,10 +6,17 @@ "hooks": [ { "type": "command", - "command": "python3 \"$(git rev-parse --show-toplevel)/scripts/agent-startup-context.py\" --expected-sha256 5a5a730c4c186e18f37419a1e367997be2476c8a6719fc45b9f528da5a3c907e", - "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$root = (& git rev-parse --show-toplevel).Trim(); & python (Join-Path $root 'scripts/agent-startup-context.py') --expected-sha256 5a5a730c4c186e18f37419a1e367997be2476c8a6719fc45b9f528da5a3c907e\"", + "command": "python3 \"$(git rev-parse --show-toplevel)/scripts/agent-startup-context.py\" --expected-sha256 933a84ec9fe44059a672dd3edaebf0f4b681983ed904c8dfe799106671de3b6d", + "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$root = (& git rev-parse --show-toplevel).Trim(); & python (Join-Path $root 'scripts/agent-startup-context.py') --expected-sha256 933a84ec9fe44059a672dd3edaebf0f4b681983ed904c8dfe799106671de3b6d\"", "timeout": 30, "statusMessage": "Loading Codex Agent memory" + }, + { + "type": "command", + "command": "python3 \"$(git rev-parse --show-toplevel)/scripts/artifactctl.py\" sweep --quiet --expected-sha256 045c2042e5b649cf3caba3ab6e98a48ccf0bd70368d3c3abbe61eba4dc9b72ba", + "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$root = (& git rev-parse --show-toplevel).Trim(); & python (Join-Path $root 'scripts/artifactctl.py') sweep --quiet --expected-sha256 045c2042e5b649cf3caba3ab6e98a48ccf0bd70368d3c3abbe61eba4dc9b72ba\"", + "timeout": 30, + "statusMessage": "Cleaning expired delivery artifacts" } ] } @@ -19,8 +26,8 @@ "hooks": [ { "type": "command", - "command": "python3 \"$(git rev-parse --show-toplevel)/scripts/agent-startup-context.py\" --expected-sha256 5a5a730c4c186e18f37419a1e367997be2476c8a6719fc45b9f528da5a3c907e", - "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$root = (& git rev-parse --show-toplevel).Trim(); & python (Join-Path $root 'scripts/agent-startup-context.py') --expected-sha256 5a5a730c4c186e18f37419a1e367997be2476c8a6719fc45b9f528da5a3c907e\"", + "command": "python3 \"$(git rev-parse --show-toplevel)/scripts/agent-startup-context.py\" --expected-sha256 933a84ec9fe44059a672dd3edaebf0f4b681983ed904c8dfe799106671de3b6d", + "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$root = (& git rev-parse --show-toplevel).Trim(); & python (Join-Path $root 'scripts/agent-startup-context.py') --expected-sha256 933a84ec9fe44059a672dd3edaebf0f4b681983ed904c8dfe799106671de3b6d\"", "timeout": 30, "statusMessage": "Loading Codex Agent memory for subagent" } diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 1c86fe2..4f082b0 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -20,6 +20,8 @@ jobs: python-version: "3.x" - name: Run cross-platform Python lifecycle test run: python tests/smoke.py + - name: Test temporary delivery expiry + run: python tests/artifactctl_test.py - name: Verify Python and PowerShell interoperability shell: pwsh run: ./tests/interop.ps1 diff --git a/.gitignore b/.gitignore index 41a20a8..2fdaf14 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ secrets/ # Test and editor noise. /.tmp/ /tmp/ +/output/ __pycache__/ *.pyc *.log diff --git a/AGENTS.md b/AGENTS.md index f9abf11..354bc7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,16 @@ When the trusted repository hook injects approved portable memory, apply it only - Treat the project lease as advisory. Use only one writing computer/task per project-ID folder at a time and wait for Google Drive to finish syncing before switching computers. - Before editing `00-system/SOUL.md`, warn that an unsynchronized writer may exist. Ask the user to confirm that other vault-writing tasks are stopped and Drive is up to date, or to explicitly accept the concurrency risk. After that confirmation and exact content approval, acquire the advisory lease and proceed; do not turn the warning into a permanent block. +## Temporary delivery outbox + +- Treat `output/` only as an expiring outbox for files being delivered to the user. It is ignored by Git, publication-blocked, and must never be used as durable memory, a reusable library, or the only copy of resumable work. +- Put deliverables into a managed seven-day package with `python scripts/artifactctl.py deliver --slug `. The lifetime is fixed, not caller-selectable. Do not write directly under an activated `output/`; unmanaged entries are enrolled for permanent deletion seven days after the sweeper first sees them. +- Before the final response, decide whether each delivered file has durable value. If only the knowledge matters, record the smallest justified candidate, decision, lesson, or handoff through the normal memory workflow. If the exact file matters, run `python scripts/artifactctl.py inspect ` and propose preservation with its exact source, byte size, reason, intended `40-documents/` destination, and privacy review. +- Preserve an exact file only after the user explicitly approves that file and destination. Use the advisory lease and the `$portable-agent-memory` preservation workflow; do not create an exemption, `keep` marker, or permanent subfolder inside `output/`. The delivery copy remains expiring even after an approved preservation copy exists. +- State the absolute delivery path, exact `expires_at` time, and permanent-deletion policy in the final response. Never make a handoff depend on an expiring file. +- Activate cleanup only on an empty outbox. If an existing checkout has pre-policy `output/` entries, do not enroll them: inspect and move the exact legacy directory outside the repository first, then install the sweeper. The hash-pinned `SessionStart` handler fails closed while unreviewed legacy entries remain. +- The hash-pinned `SessionStart` sweeper and the installed hourly local scheduler delete expired managed packages permanently. A machine that was off catches up at the next scheduler run or trusted session start. `scripts/artifactctl.py uninstall-sweeper` removes the device-local schedule when intentionally decommissioning a checkout. + ## At a meaningful checkpoint - Create a unique shared handoff only when a later task would otherwise need to rediscover unfinished or non-obvious state. Use the selected lifecycle runner's `new-handoff` command and the exact current lease ID. Keep the handoff compact, and change `status` from `draft` to `current` only when it represents actively resumable state. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b3d328c..bff785d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,7 @@ Run from the repository root: ```powershell python ./tests/smoke.py +python ./tests/artifactctl_test.py pwsh -NoProfile -File ./tests/smoke.ps1 pwsh -NoProfile -File ./tests/interop.ps1 pwsh -NoProfile -File ./tests/prepublish-smoke.ps1 @@ -23,6 +24,7 @@ Python is the preferred cross-platform lifecycle runtime. Windows PowerShell 5.1 - Do not weaken ignore rules or make the tool follow the `.agent-vault` link during publication checks. - Keep `.codex/hooks.json` as the only tracked project source under `.codex`. Never publish Codex logs, caches, approvals, or generated state. - When changing `scripts/agent-startup-context.py`, update all four pinned SHA-256 occurrences in `.codex/hooks.json`. The publication check rejects stale pins, and users must review the changed hook definition again. +- When changing `scripts/artifactctl.py`, update both pinned SHA-256 occurrences in the `SessionStart` hook and refresh the device-local sweeper schedule. Never commit `output/`; it is a seven-day temporary delivery outbox. - New automatic promotion behavior must remain opt-in and reviewable. `SOUL.md`, approved lessons, and durable preferences are human-governed. ## Pull requests diff --git a/README.ko.md b/README.ko.md index b472718..612602d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -72,6 +72,19 @@ Codex Agent는 이런 업무를 더 개인적이고 연속적인 경험으로 지속적으로 보존된다는 것이 항상 주입된다는 뜻은 아닙니다. 후보, 세션, 보존 원본, 대체된 기록, 보관 자료와 상세 근거는 자동 시작 컨텍스트에 들어가지 않습니다. +## 임시 결과물 전달함 + +생성한 파일 자체는 기억이 아닙니다. Codex Agent는 Git에서 제외된 `output/`을 사용자에게 파일을 전달하기 위한 임시 outbox로만 사용합니다. `artifactctl deliver`는 7일 뒤 만료되는 관리 패키지를 만들고, 신뢰된 `SessionStart` 훅과 기기별 hourly scheduler가 모두 `artifactctl sweep`을 실행합니다. 만료된 패키지는 archive나 휴지통으로 옮기지 않고 영구 삭제합니다. 만료 시점에 컴퓨터가 꺼져 있었다면 다음 hourly 실행 또는 신뢰된 작업 시작 때 밀린 정리를 수행합니다. + +```sh +python scripts/artifactctl.py deliver --slug quarterly-report ./tmp/report.pdf +python scripts/artifactctl.py inspect ./output//report.pdf +``` + +7일 수명은 고정되어 있으며 전달 명령으로 연장할 수 없습니다. 결과물을 전달할 때는 절대 경로와 정확한 `expires_at` 시각을 함께 알려야 합니다. `output/` 안에는 `keep` 표시나 영구 library를 만들지 않습니다. 빈 outbox가 활성화된 뒤에는 manifest 없이 직접 놓인 파일도 sweeper가 처음 발견한 때부터 7일 뒤 삭제 대상으로 등록합니다. 교훈만 중요하다면 필요한 만큼만 기억으로 남깁니다. 파일 자체가 중요하다면 정확한 크기, 보존 이유, digest, 개인정보 및 보안 검토와 함께 구체적인 `40-documents/` 위치를 제안합니다. 사용자가 그 파일과 위치를 명시적으로 승인하기 전에는 보존하지 않으며, 승인된 보존본이 생겨도 전달용 사본은 예정대로 만료됩니다. + +기존 checkout을 업그레이드할 때는 안전하게 실패하도록 설계되어 있습니다. `output/`에 정책 도입 전 파일이 남아 있으면 `install-sweeper`와 `sweep`이 그 파일을 자동 편입하지 않고 중단합니다. scheduler를 설치하기 전에 정확한 기존 디렉터리를 검토하여 저장소 외부로 옮겨야 하므로, 업그레이드만으로 오래된 결과물에 삭제 시계가 조용히 시작되지 않습니다. + ## 작동 방식 ```mermaid @@ -152,13 +165,14 @@ $codex-agent-init 이 프로젝트에 Codex Agent를 설정해줘. - 다른 기기의 로컬 정보를 가져오지 않고 현재 컴퓨터의 기기별 프로필 생성 또는 보존 - 링크, manifest, 공개 방지 마커, 시작 컨텍스트 검증 - SHA-256으로 고정된 `SessionStart`·`SubagentStart` 훅을 검증하고 Codex의 일회성 훅 검토 안내 +- 7일 임시 결과물 전달함을 정리하는 기기별 hourly scheduler 설치 - 프로젝트 정보와 soul, 사용자 선호의 초안을 만든 뒤 승인 요청 최초 설정에서는 비공개 manifest에 안정적인 에이전트 프로필 ID가 생성됩니다. 다른 컴퓨터는 이 프로필을 공유하되 각자 별도의 장치 ID를 받습니다. 연결된 비공개 파일은 Git 작업 복사본 밖에 있으므로 공개 대상에 포함되지 않습니다. ## 평소 사용 방법 -초기화가 끝나면 평소처럼 프로젝트를 열고 작업하면 됩니다. 신뢰된 [`SessionStart` 훅](.codex/hooks.json)이 작업 전에 검토된 작은 세션 요약을 불러옵니다. [`AGENTS.md`](AGENTS.md)는 공개 가능한 기본 운영 원칙과 신뢰 순서, 작업별 기억 검색 및 안전한 인계 규칙을 제공합니다. 저장소 전용 [`$portable-agent-memory`](.agents/skills/portable-agent-memory/SKILL.md)는 진단, 필요한 기억 검색, 근거가 있는 쓰기 작업을 담당합니다. +초기화가 끝나면 평소처럼 프로젝트를 열고 작업하면 됩니다. 신뢰된 [`SessionStart` 훅](.codex/hooks.json)이 작업 전에 검토된 작은 세션 요약을 불러오고 만료된 전달물을 정리합니다. 별도의 기기별 hourly scheduler는 모델 작업을 소비하지 않고 만료를 집행합니다. [`AGENTS.md`](AGENTS.md)는 공개 가능한 기본 운영 원칙과 신뢰 순서, 임시 결과물 전달 규칙, 작업별 기억 검색 및 안전한 인계 규칙을 제공합니다. 저장소 전용 [`$portable-agent-memory`](.agents/skills/portable-agent-memory/SKILL.md)는 진단, 필요한 기억 검색, 근거가 있는 쓰기 작업을 담당합니다. Codex 사이드바에서 같은 프로젝트 아래에 새 작업을 만들고 평소처럼 요청하면 됩니다. `$portable-agent-memory`를 직접 입력하거나 수명주기 명령을 실행할 필요는 거의 없습니다. 훅을 사용할 수 없거나 아직 신뢰하지 않았다면 Codex는 `AGENTS.md`의 공개 기본 규칙을 따르며, 비공개 soul을 읽은 것처럼 행동해서는 안 됩니다. diff --git a/README.md b/README.md index 2899bd6..57df8f0 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,19 @@ The aim is not to bolt a second general-purpose agent beside Codex. It is to mak Persistent does not mean always loaded. Candidates, sessions, preserved originals, superseded records, archives, and other detailed evidence stay outside automatic startup context. +## Temporary delivery outbox + +Generated files are not memory. Codex Agent uses the ignored `output/` directory only as a temporary outbox for files being handed to the user. `artifactctl deliver` creates a managed package with a seven-day expiry, and both the trusted `SessionStart` hook and an hourly device-local scheduler run `artifactctl sweep`. Expired packages are permanently deleted rather than moved to an archive or recycle bin. If the computer is off when a package expires, cleanup catches up at the next hourly run or trusted session start. + +```sh +python scripts/artifactctl.py deliver --slug quarterly-report ./tmp/report.pdf +python scripts/artifactctl.py inspect ./output//report.pdf +``` + +The seven-day lifetime is fixed and cannot be extended by the delivery command. The response must include the absolute path and exact `expires_at` time. There is no `keep` marker or permanent library inside `output/`; after the empty outbox has been activated, even files written there without a delivery manifest are enrolled for deletion seven days after first discovery. When only the learning matters, Codex records the smallest justified memory. When the exact file matters, it inspects the file and proposes a specific `40-documents/` destination with size, reason, digest, and privacy review. Nothing is preserved until the user explicitly approves that file and destination, and the delivery copy still expires on schedule. + +Activation is deliberately fail-closed for upgrades. If `output/` already contains pre-policy files, `install-sweeper` and `sweep` refuse to enroll them. Review and move that exact legacy directory outside the repository before installing the scheduler; this prevents an upgrade from silently starting a deletion clock on old artifacts. + ## How it works ```mermaid @@ -152,13 +165,14 @@ The repo-scoped init skill will: - create or preserve this computer's isolated device profile without adopting another device's local facts - validate the link, manifest, privacy markers, and bounded startup context - verify the hash-pinned `SessionStart` and `SubagentStart` hook and guide you through Codex's one-time hook review +- install the hourly device-local sweeper for the seven-day temporary delivery outbox - offer to draft the project profile, soul, and preferences for your approval The setup creates a stable random agent profile ID in the private manifest. Each computer adopts that shared profile while receiving its own device ID. The linked files remain outside the Git checkout and are not publication candidates. ## Day-to-day use -After initialization, open the project and work normally. The trusted [`SessionStart` hook](.codex/hooks.json) loads the reviewed compact session brief before work begins. [`AGENTS.md`](AGENTS.md) supplies a publishable fallback, trust order, and the rules for task-specific retrieval and safe handoffs. The repo-scoped [`$portable-agent-memory`](.agents/skills/portable-agent-memory/SKILL.md) handles diagnostics, targeted memory, and justified writes. +After initialization, open the project and work normally. The trusted [`SessionStart` hook](.codex/hooks.json) loads the reviewed compact session brief and catches up expired delivery cleanup before work begins. The independent hourly local scheduler enforces expiry without spending a model turn. [`AGENTS.md`](AGENTS.md) supplies a publishable fallback, trust order, temporary-delivery rules, and the rules for task-specific retrieval and safe handoffs. The repo-scoped [`$portable-agent-memory`](.agents/skills/portable-agent-memory/SKILL.md) handles diagnostics, targeted memory, and justified writes. Start each new task beneath the same project in the Codex sidebar and ask for work normally. You do not need to type `$portable-agent-memory` or run lifecycle commands yourself. If the hook is unavailable or untrusted, Codex falls back to `AGENTS.md` and must not pretend that a private Soul was loaded. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 035bc49..1adfcf6 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -55,6 +55,33 @@ Continuity is not transcript replay. Every automatic injection consumes task con Memory writes follow the same economy. A completed task does not automatically produce a session, candidate, handoff, or preserved original. Write only when there is a concrete continuity, audit, reuse, or user-approved preservation signal. +## Temporary delivery lifecycle + +The repository separates generated delivery artifacts from durable memory. `output/` is a Git-ignored outbox with a fixed seven-day lifetime, not a memory tier. `scripts/artifactctl.py` is the deep module at this seam: + +- `deliver` copies one or more final files into a uniquely named package, writes size and SHA-256 evidence, and returns the exact UTC expiry; +- `inspect` reports the evidence needed for a file-specific preservation proposal; +- `sweep` permanently deletes expired managed packages and, after safe activation, enrolls unmanaged direct children for deletion seven days after discovery; +- `install-sweeper` activates an empty outbox and installs an hourly user-level Windows Task Scheduler or crontab entry pinned to the current script hash. + +The trusted `SessionStart` hook also runs the hash-pinned sweeper, so an overdue checkout catches up when Codex resumes. The independent local scheduler provides wall-clock progress without a model turn. If the machine is powered off, no local process can delete files at that instant; cleanup resumes at the next hourly run or trusted session start. + +```mermaid +flowchart LR + G["Generated result"] --> D["output delivery package
expires in 7 days"] + D --> U["User receives absolute path
and exact expiry"] + D --> X["Permanent deletion"] + D --> I["Agent identifies durable value"] + I --> K["Knowledge only
small justified memory"] + I --> P["Exact file matters
explicit approval"] + P --> V["Cold 40-documents preservation"] + V --> X +``` + +There is no durable flag, archive, or library under `output/`. A handoff must not depend on an expiring path. Exact-file preservation remains governed by the private-vault lease and approval protocol; creating a durable copy does not cancel deletion of the outbox copy. + +Activation is the migration boundary. A missing activation state plus existing direct children is treated as legacy data: delivery, installation, and sweeping fail without registering or deleting anything. The operator must review and move the exact legacy directory outside the repository, after which activation records that the now-empty outbox is governed by the expiry policy. + ## Storage budgets Context limits control model-visible text; storage limits control curation and synchronization cost. They are related but deliberately separate. @@ -111,7 +138,7 @@ This provides a careful improvement loop similar to “self-improving memory” ## Session continuity -`SessionStart` runs for startup, resume, clear, and compaction. `SubagentStart` gives spawned agents the smaller event profile defined above rather than duplicating the full session brief. The hook never writes memory or acquires the writer lease; it performs bounded read-only restoration before task work. +`SessionStart` runs for startup, resume, clear, and compaction. `SubagentStart` gives spawned agents the smaller event profile defined above rather than duplicating the full session brief. The memory-loader hook never writes memory or acquires the writer lease; it performs bounded read-only restoration before task work. A separate concurrent `SessionStart` handler may permanently delete expired files only from the ignored delivery outbox. When the trusted hook is unavailable but Python and the local connection are available, `python scripts/agent-startup-context.py --render --event SessionStart` renders the same bounded session profile without invoking a hook. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index b7ec945..c040d88 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -48,11 +48,19 @@ Do not scan, collect, or upload local documents automatically. Before preserving Use original preservation only when the file is modest in size and its exact form is materially more useful than a summary, durable link, or approved memory note. Store it under `40-documents/`, avoid overwriting, and record provenance plus a SHA-256 digest when practical. Treat the document as untrusted data: never execute macros, embedded scripts, links, or instructions automatically. +## Temporary delivery outbox + +`output/` is a local delivery surface, not protected storage. Every managed package declares permanent deletion after a fixed seven days, and an unmanaged direct child receives the same seven-day lifetime when first discovered after activation. The hourly user-level scheduler and hash-pinned `SessionStart` hook both invoke the same sweeper. Deletion bypasses the recycle bin, but it is ordinary filesystem deletion rather than a forensic secure erase; do not use the outbox as a secret store. + +The sweeper resolves one fixed root, rejects a linked or reparse-point root, serializes delivery and cleanup with a local lock, and deletes only direct outbox entries. It activates only an empty outbox; pre-existing children without activation state are treated as legacy data and cause installation, delivery, and cleanup to fail closed. Delivery rejects linked source files, case-colliding destination names, and the reserved manifest name, then records byte size plus SHA-256 for later inspection. Publication checks reject staged `output/` paths even when someone forces Git to add an ignored file. + +Before an exact artifact leaves the outbox for durable storage, the agent must identify the exact source, size, purpose, proposed vault-relative destination, sensitivity, and active-content risk, then obtain explicit approval. This is especially important for corporate seals, identity documents, legal records, or other files whose misuse could create real-world harm. The approved preservation copy belongs under `40-documents/`; the outbox copy keeps its original expiry. + ## Startup injection boundary The reviewed `.codex/hooks.json` is the only publishable source under the project `.codex` directory. All other `.codex` state and `.codex-log` remain ignored and publication-blocked. -After the user trusts the project hook, `SessionStart` and `SubagentStart` run the public startup loader. Each command pins the loader's SHA-256. A loader edit without a matching hook update is rejected; a matching hook update changes the hook definition and requires Codex trust review again. +After the user trusts the project hook, `SessionStart` and `SubagentStart` run the public startup loader. Each command pins the loader's SHA-256. `SessionStart` also runs a separately hash-pinned artifact sweeper. A script edit without the matching hook update is rejected; a matching hook update changes the hook definition and requires Codex trust review again. Context minimization is also privacy minimization: each omitted record is content that is not sent to that task or repeated for its subagents. The complete vault may remain synchronized without becoming model-visible startup context. diff --git a/scripts/agent-startup-context.py b/scripts/agent-startup-context.py index be736bf..df17165 100644 --- a/scripts/agent-startup-context.py +++ b/scripts/agent-startup-context.py @@ -153,15 +153,23 @@ def verify_hook_config(actual_hash: str) -> None: for hook in group["hooks"] if isinstance(hook, dict) and hook.get("type") == "command" ) - if len(command_hooks) != 1: - raise StartupContextError(f"{event_name} must define exactly one trusted command hook.") - command_hook = command_hooks[0] + pinned_by_property: dict[str, list[dict[str, Any]]] = {} for property_name in ("command", "commandWindows"): - command = command_hook.get(property_name) - if not isinstance(command, str) or command.count(actual_hash) != 1: + matching_hooks = [ + hook + for hook in command_hooks + if isinstance(hook.get(property_name), str) + and hook[property_name].count(actual_hash) == 1 + ] + if len(matching_hooks) != 1: raise StartupContextError( - f"{event_name} {property_name} does not pin this startup script revision exactly once." + f"{event_name} {property_name} must pin this startup script revision exactly once." ) + pinned_by_property[property_name] = matching_hooks + if pinned_by_property["command"][0] is not pinned_by_property["commandWindows"][0]: + raise StartupContextError( + f"{event_name} command and commandWindows must pin the same startup hook." + ) def run_memory_tool(*arguments: str) -> subprocess.CompletedProcess[str]: diff --git a/scripts/artifactctl.py b/scripts/artifactctl.py new file mode 100644 index 0000000..3c56aa6 --- /dev/null +++ b/scripts/artifactctl.py @@ -0,0 +1,693 @@ +#!/usr/bin/env python3 +"""Manage Codex Agent's temporary user-delivery outbox.""" + +from __future__ import annotations + +import argparse +import hashlib +import hmac +import json +import os +import re +import shlex +import shutil +import stat +import subprocess +import sys +import uuid +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Callable, Iterable + + +DEFAULT_TTL = timedelta(days=7) +SLUG_PATTERN = re.compile(r"[^a-z0-9]+") +STATE_FILENAME = ".outbox-state.json" +LOCK_FILENAME = ".artifactctl.lock" +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_OUTPUT_ROOT = REPO_ROOT / "output" + + +class ArtifactError(RuntimeError): + pass + + +class OutboxBusyError(ArtifactError): + pass + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def iso_utc(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def safe_slug(value: str) -> str: + normalized = SLUG_PATTERN.sub("-", value.strip().lower()).strip("-") + if not normalized: + raise ArtifactError("Delivery slug must contain at least one letter or number.") + return normalized[:64] + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def is_link_or_reparse(path: Path) -> bool: + try: + details = path.lstat() + except FileNotFoundError: + return False + attributes = getattr(details, "st_file_attributes", 0) + return stat.S_ISLNK(details.st_mode) or bool(attributes & 0x400) + + +def verify_script_hash(expected: str | None) -> None: + if expected is None: + return + actual = file_sha256(Path(__file__)) + normalized = expected.strip().lower() + if re.fullmatch(r"[0-9a-f]{64}", normalized) is None or not hmac.compare_digest(normalized, actual): + raise ArtifactError("The artifact cleanup script does not match the trusted hook definition.") + + +def powershell_literal(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def scheduler_specification(output_root: Path) -> dict[str, object]: + script = Path(__file__).resolve() + root = output_root.resolve() + script_hash = file_sha256(script) + identifier_suffix = hashlib.sha256(os.fspath(REPO_ROOT).encode("utf-8")).hexdigest()[:12] + command_parts = [ + sys.executable, + os.fspath(script), + "sweep", + "--output-root", + os.fspath(root), + "--quiet", + "--expected-sha256", + script_hash, + ] + if os.name == "nt": + sweep_command = subprocess.list2cmdline(command_parts) + identifier = f"CodexAgentArtifactSweep-{identifier_suffix}" + powershell = shutil.which("powershell.exe") or "powershell.exe" + action_arguments = subprocess.list2cmdline(command_parts[1:]) + registration = "; ".join( + ( + "$action = New-ScheduledTaskAction " + f"-Execute {powershell_literal(sys.executable)} " + f"-Argument {powershell_literal(action_arguments)} " + f"-WorkingDirectory {powershell_literal(os.fspath(REPO_ROOT))}", + "$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) " + "-RepetitionInterval (New-TimeSpan -Hours 1) " + "-RepetitionDuration (New-TimeSpan -Days 3650)", + "$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable " + "-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries " + "-ExecutionTimeLimit (New-TimeSpan -Minutes 5) -MultipleInstances IgnoreNew", + "Register-ScheduledTask " + f"-TaskName {powershell_literal(identifier)} -Action $action -Trigger $trigger " + "-Settings $settings -Description 'Permanently delete expired Codex Agent delivery artifacts.' " + "-Force | Out-Null", + ) + ) + install_command = [ + powershell, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + registration, + ] + scheduler = "windows-task-scheduler" + else: + sweep_command = shlex.join(command_parts) + identifier = f"codex-agent-artifact-sweep-{identifier_suffix}" + install_command = ["crontab", "-"] + scheduler = "user-crontab" + return { + "scheduler": scheduler, + "identifier": identifier, + "frequency": "hourly", + "sweep_command": sweep_command, + "install_command": install_command, + } + + +def install_sweeper( + output_root: Path, + *, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, +) -> dict[str, object]: + ArtifactOutbox(output_root).activate() + specification = scheduler_specification(output_root) + if os.name == "nt": + arguments = [str(value) for value in specification["install_command"]] + process = runner(arguments, capture_output=True, encoding="utf-8", check=False) + if process.returncode != 0: + raise ArtifactError(f"Windows Task Scheduler installation failed: {process.stderr.strip()}") + return specification + + marker = f"# {specification['identifier']}" + existing = runner(["crontab", "-l"], capture_output=True, encoding="utf-8", check=False) + if existing.returncode not in (0, 1): + raise ArtifactError(f"Unable to read the user crontab: {existing.stderr.strip()}") + lines = [line for line in existing.stdout.splitlines() if marker not in line] + lines.append(f"17 * * * * {specification['sweep_command']} {marker}") + updated = "\n".join(lines) + "\n" + installed = runner( + ["crontab", "-"], + input=updated, + capture_output=True, + encoding="utf-8", + check=False, + ) + if installed.returncode != 0: + raise ArtifactError(f"User crontab installation failed: {installed.stderr.strip()}") + return specification + + +def uninstall_sweeper( + output_root: Path, + *, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, +) -> dict[str, object]: + specification = scheduler_specification(output_root) + if os.name == "nt": + arguments = [ + "schtasks.exe", + "/Delete", + "/F", + "/TN", + str(specification["identifier"]), + ] + process = runner(arguments, capture_output=True, encoding="utf-8", check=False) + if process.returncode != 0: + raise ArtifactError(f"Windows Task Scheduler removal failed: {process.stderr.strip()}") + return specification + + marker = f"# {specification['identifier']}" + existing = runner(["crontab", "-l"], capture_output=True, encoding="utf-8", check=False) + if existing.returncode == 1: + return specification + if existing.returncode != 0: + raise ArtifactError(f"Unable to read the user crontab: {existing.stderr.strip()}") + lines = [line for line in existing.stdout.splitlines() if marker not in line] + updated = "\n".join(lines) + ("\n" if lines else "") + removed = runner( + ["crontab", "-"], + input=updated, + capture_output=True, + encoding="utf-8", + check=False, + ) + if removed.returncode != 0: + raise ArtifactError(f"User crontab removal failed: {removed.stderr.strip()}") + return specification + + +@dataclass(frozen=True) +class Delivery: + path: Path + created_at: datetime + expires_at: datetime + + +@dataclass(frozen=True) +class SweepResult: + deleted_packages: tuple[Path, ...] + registered_unmanaged: tuple[Path, ...] + deleted_unmanaged: tuple[Path, ...] + skipped_locked: bool = False + + +@dataclass(frozen=True) +class ArtifactInspection: + source: Path + size_bytes: int + sha256: str + expires_at: datetime + + +class ArtifactOutbox: + """Store user-facing artifacts temporarily behind one lifecycle interface.""" + + def __init__(self, root: Path, *, clock: Callable[[], datetime] = utc_now) -> None: + requested_root = Path(root) + if is_link_or_reparse(requested_root): + raise ArtifactError(f"Outbox root must not be a link or reparse point: {requested_root}") + self.root = requested_root.resolve() + self.clock = clock + + def deliver( + self, + sources: Iterable[Path], + *, + slug: str, + ) -> Delivery: + requested_sources = [Path(source) for source in sources] + if not requested_sources: + raise ArtifactError("At least one delivery source is required.") + for source in requested_sources: + if is_link_or_reparse(source): + raise ArtifactError(f"Delivery source must not be a link or reparse point: {source}") + source_paths = [source.resolve() for source in requested_sources] + reserved_manifest_key = os.path.normcase(".delivery.json").casefold() + for source in source_paths: + if not source.is_file(): + raise ArtifactError(f"Delivery source must be a regular file: {source}") + if os.path.normcase(source.name).casefold() == reserved_manifest_key: + raise ArtifactError(f"Delivery source uses a reserved filename: {source.name}") + try: + source.relative_to(self.root) + except ValueError: + pass + else: + raise ArtifactError(f"Delivery source is already inside the outbox: {source}") + + self.activate() + now = self.clock().astimezone(timezone.utc) + lock_token = self._acquire_lock(now) + try: + package_id = f"{now.strftime('%Y%m%dT%H%M%SZ')}-{safe_slug(slug)}-{uuid.uuid4().hex[:8]}" + package = self.root / package_id + package.mkdir(parents=True, exist_ok=False) + try: + seen_names: set[str] = set() + files: list[dict[str, object]] = [] + for source in source_paths: + destination_key = os.path.normcase(source.name).casefold() + if destination_key in seen_names: + raise ArtifactError(f"Delivery sources have a duplicate filename: {source.name}") + seen_names.add(destination_key) + destination = package / source.name + shutil.copy2(source, destination) + files.append( + { + "path": source.name, + "size_bytes": destination.stat().st_size, + "sha256": file_sha256(destination), + } + ) + + expires_at = now + DEFAULT_TTL + manifest = { + "schema_version": 1, + "kind": "temporary_delivery", + "package_id": package_id, + "created_at": iso_utc(now), + "expires_at": iso_utc(expires_at), + "deletion_policy": "permanent", + "files": files, + } + (package / ".delivery.json").write_text( + json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + return Delivery(path=package, created_at=now, expires_at=expires_at) + except Exception: + shutil.rmtree(package, ignore_errors=True) + raise + finally: + self._release_lock(lock_token) + + def activate(self) -> None: + """Arm automatic deletion only when the pre-existing outbox is empty.""" + now = self.clock().astimezone(timezone.utc) + lock_token = self._acquire_lock(now) + try: + self._ensure_activated_locked(now) + finally: + self._release_lock(lock_token) + + def inspect(self, source: Path) -> ArtifactInspection: + resolved = Path(source).resolve(strict=True) + try: + relative = resolved.relative_to(self.root) + except ValueError as error: + raise ArtifactError(f"Retention inspection source is outside the outbox: {resolved}") from error + if len(relative.parts) < 2 or not resolved.is_file(): + raise ArtifactError(f"Retention inspection requires a delivered file: {resolved}") + package = self.root / relative.parts[0] + manifest_path = package / ".delivery.json" + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + expires_at = datetime.fromisoformat(str(manifest["expires_at"]).replace("Z", "+00:00")) + except (OSError, UnicodeError, json.JSONDecodeError, KeyError, ValueError) as error: + raise ArtifactError(f"Delivery manifest is invalid: {manifest_path}") from error + if manifest.get("kind") != "temporary_delivery" or manifest.get("package_id") != package.name: + raise ArtifactError(f"Delivery manifest does not identify its package: {manifest_path}") + manifest_relative = resolved.relative_to(package).as_posix() + if not any( + isinstance(record, dict) and record.get("path") == manifest_relative + for record in manifest.get("files", []) + ): + raise ArtifactError(f"File is not listed in its delivery manifest: {resolved}") + return ArtifactInspection( + source=resolved, + size_bytes=resolved.stat().st_size, + sha256=file_sha256(resolved), + expires_at=expires_at.astimezone(timezone.utc), + ) + + def sweep(self) -> SweepResult: + if not self.root.exists(): + return SweepResult(deleted_packages=(), registered_unmanaged=(), deleted_unmanaged=()) + if not self.root.is_dir(): + raise ArtifactError(f"Outbox root is not a directory: {self.root}") + + now = self.clock().astimezone(timezone.utc) + try: + lock_token = self._acquire_lock(now) + except OutboxBusyError: + return SweepResult( + deleted_packages=(), + registered_unmanaged=(), + deleted_unmanaged=(), + skipped_locked=True, + ) + try: + self._ensure_activated_locked(now) + return self._sweep_locked(now) + finally: + self._release_lock(lock_token) + + def _ensure_activated_locked(self, now: datetime) -> None: + state_path = self.root / STATE_FILENAME + if state_path.exists(): + self._load_state(state_path) + return + legacy_entries = [ + path + for path in self.root.iterdir() + if path.name not in {STATE_FILENAME, LOCK_FILENAME} + ] + if legacy_entries: + raise ArtifactError( + "The outbox contains legacy entries. Move and review them outside output/ " + "before activating automatic deletion." + ) + self._write_state( + state_path, + { + "schema_version": 1, + "activated_at": iso_utc(now), + "unmanaged": {}, + }, + ) + + def _sweep_locked(self, now: datetime) -> SweepResult: + deleted: list[Path] = [] + managed_names: set[str] = set() + for package in sorted(self.root.iterdir(), key=lambda path: path.name): + manifest_path = package / ".delivery.json" + if not package.is_dir() or not manifest_path.is_file(): + continue + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + expires_at = datetime.fromisoformat(str(manifest["expires_at"]).replace("Z", "+00:00")) + except (OSError, UnicodeError, json.JSONDecodeError, KeyError, ValueError): + continue + if manifest.get("kind") != "temporary_delivery" or manifest.get("package_id") != package.name: + continue + managed_names.add(package.name) + if expires_at.astimezone(timezone.utc) <= now: + shutil.rmtree(package) + deleted.append(package) + + state_path = self.root / STATE_FILENAME + state = self._load_state(state_path) + unmanaged_state = state["unmanaged"] + registered_unmanaged: list[Path] = [] + deleted_unmanaged: list[Path] = [] + current_names = { + path.name + for path in self.root.iterdir() + if path.name not in {STATE_FILENAME, LOCK_FILENAME} and path.name not in managed_names + } + for missing_name in set(unmanaged_state) - current_names: + unmanaged_state.pop(missing_name, None) + for name in sorted(current_names): + path = self.root / name + record = unmanaged_state.get(name) + identity = self._entry_identity(path) + if not isinstance(record, dict) or record.get("identity") != identity: + record = { + "first_seen_at": iso_utc(now), + "expires_at": iso_utc(now + DEFAULT_TTL), + "identity": identity, + } + unmanaged_state[name] = record + registered_unmanaged.append(path) + try: + expires_at = datetime.fromisoformat(str(record["expires_at"]).replace("Z", "+00:00")) + except (KeyError, TypeError, ValueError): + record["first_seen_at"] = iso_utc(now) + record["expires_at"] = iso_utc(now + DEFAULT_TTL) + expires_at = now + DEFAULT_TTL + if expires_at.astimezone(timezone.utc) <= now: + self._permanently_delete(path) + unmanaged_state.pop(name, None) + deleted_unmanaged.append(path) + self._write_state(state_path, state) + return SweepResult( + deleted_packages=tuple(deleted), + registered_unmanaged=tuple(registered_unmanaged), + deleted_unmanaged=tuple(deleted_unmanaged), + ) + + @staticmethod + def _entry_identity(path: Path) -> dict[str, int]: + details = path.lstat() + identity = { + "device": int(details.st_dev), + "inode": int(details.st_ino), + "file_type": int(stat.S_IFMT(details.st_mode)), + } + if details.st_ino == 0: + identity["created_ns_fallback"] = int(details.st_ctime_ns) + return identity + + @staticmethod + def _load_state(path: Path) -> dict[str, object]: + if not path.exists(): + return {"schema_version": 1, "unmanaged": {}} + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise ArtifactError(f"Outbox state is unreadable: {path}") from error + if ( + not isinstance(value, dict) + or value.get("schema_version") != 1 + or not isinstance(value.get("activated_at"), str) + or not isinstance(value.get("unmanaged"), dict) + ): + raise ArtifactError(f"Outbox state has an unsupported schema: {path}") + return value + + @staticmethod + def _write_state(path: Path, state: dict[str, object]) -> None: + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + temporary.replace(path) + + @staticmethod + def _permanently_delete(path: Path) -> None: + if path.is_symlink() or not path.is_dir(): + path.unlink() + else: + shutil.rmtree(path) + + @staticmethod + def _remove_stale_lock(path: Path, now: datetime) -> bool: + if is_link_or_reparse(path): + return False + try: + value = json.loads(path.read_text(encoding="utf-8")) + created_at = datetime.fromisoformat(str(value["created_at"]).replace("Z", "+00:00")) + except (OSError, UnicodeError, json.JSONDecodeError, KeyError, ValueError): + return False + if created_at.astimezone(timezone.utc) + timedelta(minutes=15) > now: + return False + path.unlink() + return True + + def _acquire_lock(self, now: datetime) -> str: + self.root.mkdir(parents=True, exist_ok=True) + if is_link_or_reparse(self.root): + raise ArtifactError(f"Outbox root must not be a link or reparse point: {self.root}") + path = self.root / LOCK_FILENAME + if path.exists() or is_link_or_reparse(path): + if not self._remove_stale_lock(path, now): + raise OutboxBusyError("The outbox is busy with another operation.") + token = uuid.uuid4().hex + value = json.dumps({"created_at": iso_utc(now), "token": token}) + try: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL) + except FileExistsError as error: + raise OutboxBusyError("The outbox is busy with another operation.") from error + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(value) + return token + + def _release_lock(self, token: str) -> None: + path = self.root / LOCK_FILENAME + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return + if value.get("token") == token: + path.unlink(missing_ok=True) + + +def add_output_root_argument(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--output-root", + type=Path, + default=DEFAULT_OUTPUT_ROOT, + help=f"Temporary delivery root (default: {DEFAULT_OUTPUT_ROOT})", + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Manage Codex Agent's temporary user-delivery outbox.") + commands = parser.add_subparsers(dest="command", required=True) + + deliver_parser = commands.add_parser("deliver", help="Copy files into a seven-day delivery package.") + add_output_root_argument(deliver_parser) + deliver_parser.add_argument("--slug", required=True, help="Short human-readable delivery name.") + deliver_parser.add_argument("--json", dest="json_output", action="store_true") + deliver_parser.add_argument("sources", nargs="+", type=Path) + + inspect_parser = commands.add_parser( + "inspect", + help="Report exact file evidence before proposing durable preservation.", + ) + add_output_root_argument(inspect_parser) + inspect_parser.add_argument("--json", dest="json_output", action="store_true") + inspect_parser.add_argument("source", type=Path) + + sweep_parser = commands.add_parser("sweep", help="Permanently delete expired outbox entries.") + add_output_root_argument(sweep_parser) + sweep_parser.add_argument("--json", dest="json_output", action="store_true") + sweep_parser.add_argument("--quiet", action="store_true") + sweep_parser.add_argument("--expected-sha256") + + install_parser = commands.add_parser( + "install-sweeper", + help="Install an hourly local scheduler for automatic expiry.", + ) + add_output_root_argument(install_parser) + install_parser.add_argument("--dry-run", action="store_true") + install_parser.add_argument("--json", dest="json_output", action="store_true") + + uninstall_parser = commands.add_parser( + "uninstall-sweeper", + help="Remove this checkout's automatic expiry schedule.", + ) + add_output_root_argument(uninstall_parser) + uninstall_parser.add_argument("--json", dest="json_output", action="store_true") + return parser + + +def run_command(args: argparse.Namespace) -> None: + if args.command == "deliver": + delivery = ArtifactOutbox(args.output_root).deliver( + args.sources, + slug=args.slug, + ) + result = { + "path": os.fspath(delivery.path), + "created_at": iso_utc(delivery.created_at), + "expires_at": iso_utc(delivery.expires_at), + "deletion_policy": "permanent", + } + if args.json_output: + print(json.dumps(result, indent=2, ensure_ascii=False)) + else: + print(f"Delivery package: {delivery.path}") + print(f"Expires at: {result['expires_at']}") + print("Deletion policy: permanent") + return + if args.command == "inspect": + inspection = ArtifactOutbox(args.output_root).inspect(args.source) + result = { + "source": os.fspath(inspection.source), + "size_bytes": inspection.size_bytes, + "sha256": inspection.sha256, + "expires_at": iso_utc(inspection.expires_at), + "preservation_requires_explicit_approval": True, + } + if args.json_output: + print(json.dumps(result, indent=2, ensure_ascii=False)) + else: + print(f"Source: {inspection.source}") + print(f"Size: {inspection.size_bytes} bytes") + print(f"SHA-256: {inspection.sha256}") + print(f"Outbox expires at: {result['expires_at']}") + print("Durable preservation requires explicit user approval.") + return + if args.command == "sweep": + verify_script_hash(args.expected_sha256) + result = ArtifactOutbox(args.output_root).sweep() + output = { + "deleted_packages": [os.fspath(path) for path in result.deleted_packages], + "registered_unmanaged": [os.fspath(path) for path in result.registered_unmanaged], + "deleted_unmanaged": [os.fspath(path) for path in result.deleted_unmanaged], + "skipped_locked": result.skipped_locked, + } + if args.quiet: + return + if args.json_output: + print(json.dumps(output, indent=2, ensure_ascii=False)) + else: + print(f"Deleted managed packages: {len(result.deleted_packages)}") + print(f"Registered unmanaged entries: {len(result.registered_unmanaged)}") + print(f"Deleted unmanaged entries: {len(result.deleted_unmanaged)}") + return + if args.command == "install-sweeper": + specification = ( + scheduler_specification(args.output_root) + if args.dry_run + else install_sweeper(args.output_root) + ) + if args.json_output: + print(json.dumps(specification, indent=2, ensure_ascii=False)) + else: + print(f"Scheduler: {specification['scheduler']}") + print(f"Identifier: {specification['identifier']}") + print("Frequency: hourly") + print(f"Sweep command: {specification['sweep_command']}") + return + if args.command == "uninstall-sweeper": + specification = uninstall_sweeper(args.output_root) + if args.json_output: + print(json.dumps(specification, indent=2, ensure_ascii=False)) + else: + print(f"Removed scheduler: {specification['identifier']}") + return + raise ArtifactError(f"Unsupported command: {args.command}") + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + try: + run_command(args) + return 0 + except (ArtifactError, OSError, UnicodeError) as error: + print(f"artifactctl: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prepublish-check.ps1 b/scripts/prepublish-check.ps1 index f8a61af..45d4d76 100644 --- a/scripts/prepublish-check.ps1 +++ b/scripts/prepublish-check.ps1 @@ -16,6 +16,7 @@ $criticalNonTemplatePaths = @( '.codex/hooks.json', '.agents/skills/codex-agent-init/SKILL.md', '.agents/skills/portable-agent-memory/SKILL.md', + 'scripts/artifactctl.py', 'scripts/agent-startup-context.py', 'scripts/agent-memory.py', 'scripts/agent-memory.ps1', @@ -23,6 +24,7 @@ $criticalNonTemplatePaths = @( 'tests/smoke.py', 'tests/smoke.ps1', 'tests/interop.ps1', + 'tests/artifactctl_test.py', 'tests/prepublish-smoke.ps1', '.github/workflows/smoke.yml' ) @@ -94,6 +96,9 @@ function Test-BoundaryIgnoreText { ) $activeLines = @($Content -split '\r?\n' | ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') }) + if ($activeLines -notcontains '/output/') { + Add-Failure "$Label .gitignore must exclude the temporary delivery outbox." + } if ($activeLines.Count -lt $requiredTail.Count) { Add-Failure "$Label .gitignore is missing the final publication-safety block." return @@ -121,6 +126,7 @@ function Test-HookPinInvariant { param( [string]$HookConfigText, [string]$StartupHash, + [string]$ArtifactHash, [string]$Label ) try { @@ -129,6 +135,10 @@ function Test-HookPinInvariant { if ($hashCount -ne 4) { Add-Failure "$Label lifecycle hooks must pin all Unix and Windows SessionStart/SubagentStart commands to the startup script SHA-256." } + $artifactHashCount = [regex]::Matches($HookConfigText, [regex]::Escape($ArtifactHash)).Count + if ($artifactHashCount -ne 2) { + Add-Failure "$Label SessionStart hook must pin the Unix and Windows artifact sweeper commands to the artifact script SHA-256." + } foreach ($requiredHook in @('SessionStart', 'SubagentStart')) { if ($HookConfigText -notmatch ('"' + [regex]::Escape($requiredHook) + '"')) { Add-Failure "$Label lifecycle configuration is missing required hook: $requiredHook" @@ -184,6 +194,9 @@ try { foreach ($file in $candidateFiles) { $normalized = $file.Replace('\', '/') + if ($normalized -eq 'output' -or $normalized -like 'output/*') { + Add-Failure "Temporary delivery files must not be published: $normalized" + } if ($normalized -like '.codex/*' -and $normalized -ne '.codex/hooks.json') { Add-Failure "Private or generated Codex state would be published: $normalized" } @@ -252,25 +265,31 @@ try { } if ((Test-Path -LiteralPath '.codex/hooks.json' -PathType Leaf) -and - (Test-Path -LiteralPath 'scripts/agent-startup-context.py' -PathType Leaf)) { + (Test-Path -LiteralPath 'scripts/agent-startup-context.py' -PathType Leaf) -and + (Test-Path -LiteralPath 'scripts/artifactctl.py' -PathType Leaf)) { $hookConfigText = Get-Content -LiteralPath '.codex/hooks.json' -Raw $startupHash = (Get-FileHash -LiteralPath 'scripts/agent-startup-context.py' -Algorithm SHA256).Hash.ToLowerInvariant() - Test-HookPinInvariant $hookConfigText $startupHash 'Working-tree' + $artifactHash = (Get-FileHash -LiteralPath 'scripts/artifactctl.py' -Algorithm SHA256).Hash.ToLowerInvariant() + Test-HookPinInvariant $hookConfigText $startupHash $artifactHash 'Working-tree' } if (($indexedFiles -contains '.codex/hooks.json') -and - ($indexedFiles -contains 'scripts/agent-startup-context.py')) { + ($indexedFiles -contains 'scripts/agent-startup-context.py') -and + ($indexedFiles -contains 'scripts/artifactctl.py')) { $indexHookLines = @(& git show ':.codex/hooks.json' 2>$null) $hookExitCode = $LASTEXITCODE $indexStartupLines = @(& git show ':scripts/agent-startup-context.py' 2>$null) $startupExitCode = $LASTEXITCODE - if ($hookExitCode -ne 0 -or $startupExitCode -ne 0) { + $indexArtifactLines = @(& git show ':scripts/artifactctl.py' 2>$null) + $artifactExitCode = $LASTEXITCODE + if ($hookExitCode -ne 0 -or $startupExitCode -ne 0 -or $artifactExitCode -ne 0) { Add-Failure 'Unable to inspect staged lifecycle hook sources.' } else { $indexHookText = ($indexHookLines -join "`n") + "`n" $indexStartupText = ($indexStartupLines -join "`n") + "`n" - Test-HookPinInvariant $indexHookText (Get-Sha256ForUtf8Text $indexStartupText) 'Staged/index' + $indexArtifactText = ($indexArtifactLines -join "`n") + "`n" + Test-HookPinInvariant $indexHookText (Get-Sha256ForUtf8Text $indexStartupText) (Get-Sha256ForUtf8Text $indexArtifactText) 'Staged/index' } } diff --git a/tests/artifactctl_test.py b/tests/artifactctl_test.py new file mode 100644 index 0000000..e83681a --- /dev/null +++ b/tests/artifactctl_test.py @@ -0,0 +1,484 @@ +#!/usr/bin/env python3 +"""Behavior tests for the temporary delivery outbox.""" + +from __future__ import annotations + +import json +import hashlib +import os +import subprocess +import sys +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +SOURCE_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(SOURCE_ROOT / "scripts")) + +from artifactctl import ArtifactError, ArtifactOutbox, install_sweeper, uninstall_sweeper # noqa: E402 + + +def run_artifactctl(*arguments: str, expected: int = 0) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment["PYTHONUTF8"] = "1" + process = subprocess.run( + [sys.executable, os.fspath(SOURCE_ROOT / "scripts" / "artifactctl.py"), *arguments], + cwd=SOURCE_ROOT, + capture_output=True, + encoding="utf-8", + env=environment, + check=False, + ) + if process.returncode != expected: + raise AssertionError( + f"Unexpected artifactctl exit {process.returncode} (expected {expected}) for {arguments}:\n" + f"stdout:\n{process.stdout}\nstderr:\n{process.stderr}" + ) + return process + + +class ArtifactOutboxTests(unittest.TestCase): + def test_deliver_creates_a_seven_day_temporary_package(self) -> None: + now = datetime(2026, 7, 16, 3, 0, tzinfo=timezone.utc) + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + root = Path(temporary) + source = root / "report.txt" + source.write_text("reviewable result", encoding="utf-8") + + delivery = ArtifactOutbox(root / "output", clock=lambda: now).deliver( + [source], + slug="review-report", + ) + + self.assertEqual(delivery.expires_at, now + timedelta(days=7)) + self.assertEqual((delivery.path / "report.txt").read_text(encoding="utf-8"), "reviewable result") + manifest = json.loads((delivery.path / ".delivery.json").read_text(encoding="utf-8")) + self.assertEqual(manifest["kind"], "temporary_delivery") + self.assertEqual(manifest["deletion_policy"], "permanent") + self.assertEqual(manifest["expires_at"], "2026-07-23T03:00:00Z") + + def test_sweep_permanently_deletes_only_expired_packages(self) -> None: + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + root = Path(temporary) + source = root / "result.txt" + source.write_text("temporary delivery", encoding="utf-8") + output = root / "output" + expired = ArtifactOutbox( + output, + clock=lambda: datetime(2026, 7, 1, tzinfo=timezone.utc), + ).deliver([source], slug="expired") + current = ArtifactOutbox( + output, + clock=lambda: datetime(2026, 7, 15, tzinfo=timezone.utc), + ).deliver([source], slug="current") + + result = ArtifactOutbox( + output, + clock=lambda: datetime(2026, 7, 16, tzinfo=timezone.utc), + ).sweep() + + self.assertFalse(expired.path.exists()) + self.assertTrue(current.path.exists()) + self.assertEqual(result.deleted_packages, (expired.path,)) + + def test_unmanaged_output_is_registered_then_deleted_after_seven_days(self) -> None: + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + output = Path(temporary) / "output" + ArtifactOutbox(output).activate() + unmanaged = output / "manually-created" + unmanaged.mkdir(parents=True) + (unmanaged / "draft.txt").write_text("not exempt from expiry", encoding="utf-8") + + discovered = ArtifactOutbox( + output, + clock=lambda: datetime(2026, 7, 16, tzinfo=timezone.utc), + ).sweep() + before_expiry = ArtifactOutbox( + output, + clock=lambda: datetime(2026, 7, 22, tzinfo=timezone.utc), + ).sweep() + expired = ArtifactOutbox( + output, + clock=lambda: datetime(2026, 7, 23, tzinfo=timezone.utc), + ).sweep() + + self.assertEqual(discovered.registered_unmanaged, (unmanaged,)) + self.assertTrue(unmanaged not in before_expiry.deleted_unmanaged) + self.assertFalse(unmanaged.exists()) + self.assertEqual(expired.deleted_unmanaged, (unmanaged,)) + + def test_replaced_unmanaged_entry_receives_a_new_seven_day_lifetime(self) -> None: + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + output = Path(temporary) / "output" + outbox = ArtifactOutbox( + output, + clock=lambda: datetime(2026, 7, 1, tzinfo=timezone.utc), + ) + outbox.activate() + unmanaged = output / "same-name.txt" + unmanaged.write_text("original", encoding="utf-8") + outbox.sweep() + + replacement = output / "replacement.txt" + replacement.write_text("replacement", encoding="utf-8") + unmanaged.unlink() + replacement.replace(unmanaged) + + result = ArtifactOutbox( + output, + clock=lambda: datetime(2026, 7, 8, tzinfo=timezone.utc), + ).sweep() + + self.assertTrue(unmanaged.exists()) + self.assertEqual(unmanaged.read_text(encoding="utf-8"), "replacement") + self.assertEqual(result.registered_unmanaged, (unmanaged,)) + self.assertEqual(result.deleted_unmanaged, ()) + + def test_inspect_returns_the_exact_retention_proposal_evidence(self) -> None: + now = datetime(2026, 7, 16, 3, 0, tzinfo=timezone.utc) + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + root = Path(temporary) + source = root / "corporate-asset.png" + source.write_bytes(b"synthetic-corporate-asset") + outbox = ArtifactOutbox(root / "output", clock=lambda: now) + delivery = outbox.deliver([source], slug="corporate-asset") + + inspection = outbox.inspect(delivery.path / source.name) + + self.assertEqual(inspection.source, delivery.path / source.name) + self.assertEqual(inspection.size_bytes, len(b"synthetic-corporate-asset")) + self.assertEqual(inspection.sha256, hashlib.sha256(b"synthetic-corporate-asset").hexdigest()) + self.assertEqual(inspection.expires_at, now + timedelta(days=7)) + + def test_cli_deliver_reports_the_path_and_exact_expiry_as_json(self) -> None: + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + root = Path(temporary) + source = root / "result.txt" + source.write_text("deliver me", encoding="utf-8") + + process = run_artifactctl( + "deliver", + "--output-root", + os.fspath(root / "output"), + "--slug", + "user-result", + "--json", + os.fspath(source), + ) + + result = json.loads(process.stdout) + self.assertEqual(Path(result["path"]).parent, root / "output") + self.assertEqual(result["deletion_policy"], "permanent") + self.assertRegex(result["expires_at"], r"Z$") + + def test_cli_deliver_does_not_allow_the_seven_day_expiry_to_be_overridden(self) -> None: + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + source = Path(temporary) / "result.txt" + source.write_text("fixed retention", encoding="utf-8") + + process = run_artifactctl( + "deliver", + "--output-root", + os.fspath(Path(temporary) / "output"), + "--slug", + "fixed-retention", + "--ttl-days", + "30", + os.fspath(source), + expected=2, + ) + + self.assertIn("unrecognized arguments", process.stderr) + + def test_cli_inspect_reports_evidence_needed_for_preservation_approval(self) -> None: + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + root = Path(temporary) + source = root / "reference.bin" + source.write_bytes(b"retention evidence") + delivered = json.loads( + run_artifactctl( + "deliver", + "--output-root", + os.fspath(root / "output"), + "--slug", + "reference", + "--json", + os.fspath(source), + ).stdout + ) + delivered_file = Path(delivered["path"]) / source.name + + inspection = json.loads( + run_artifactctl( + "inspect", + "--output-root", + os.fspath(root / "output"), + "--json", + os.fspath(delivered_file), + ).stdout + ) + + self.assertEqual(Path(inspection["source"]), delivered_file) + self.assertEqual(inspection["size_bytes"], len(b"retention evidence")) + self.assertEqual(inspection["sha256"], hashlib.sha256(b"retention evidence").hexdigest()) + self.assertEqual(inspection["preservation_requires_explicit_approval"], True) + + def test_cli_sweep_accepts_only_its_pinned_script_hash_for_hooks(self) -> None: + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + output = Path(temporary) / "output" + script = SOURCE_ROOT / "scripts" / "artifactctl.py" + expected_hash = hashlib.sha256(script.read_bytes()).hexdigest() + + accepted = run_artifactctl( + "sweep", + "--output-root", + os.fspath(output), + "--quiet", + "--expected-sha256", + expected_hash, + ) + rejected = run_artifactctl( + "sweep", + "--output-root", + os.fspath(output), + "--quiet", + "--expected-sha256", + "0" * 64, + expected=1, + ) + + self.assertEqual(accepted.stdout, "") + self.assertIn("does not match the trusted hook definition", rejected.stderr) + + def test_install_sweeper_dry_run_describes_an_hourly_local_schedule(self) -> None: + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + output = Path(temporary) / "output" + + specification = json.loads( + run_artifactctl( + "install-sweeper", + "--output-root", + os.fspath(output), + "--dry-run", + "--json", + ).stdout + ) + + expected_scheduler = "windows-task-scheduler" if os.name == "nt" else "user-crontab" + self.assertEqual(specification["scheduler"], expected_scheduler) + self.assertEqual(specification["frequency"], "hourly") + self.assertIn("sweep", specification["sweep_command"]) + self.assertIn(os.fspath(output), specification["sweep_command"]) + + def test_install_sweeper_uses_the_platform_user_scheduler(self) -> None: + calls: list[tuple[list[str], str | None]] = [] + + def fake_runner(arguments: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append((arguments, kwargs.get("input") if isinstance(kwargs.get("input"), str) else None)) + if arguments == ["crontab", "-l"]: + return subprocess.CompletedProcess(arguments, 1, "", "no crontab") + return subprocess.CompletedProcess(arguments, 0, "", "") + + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + specification = install_sweeper(Path(temporary) / "output", runner=fake_runner) + + if os.name == "nt": + self.assertIn("powershell", calls[0][0][0].lower()) + windows_command = " ".join(calls[0][0]) + self.assertIn("StartWhenAvailable", windows_command) + self.assertIn("AllowStartIfOnBatteries", windows_command) + else: + self.assertEqual(calls[0][0], ["crontab", "-l"]) + self.assertEqual(calls[1][0], ["crontab", "-"]) + self.assertIn(str(specification["identifier"]), calls[1][1] or "") + + def test_install_sweeper_refuses_to_enroll_legacy_output(self) -> None: + calls: list[list[str]] = [] + + def fake_runner(arguments: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append(arguments) + return subprocess.CompletedProcess(arguments, 0, "", "") + + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + output = Path(temporary) / "output" + output.mkdir() + (output / "legacy-result.txt").write_text("must be reviewed first", encoding="utf-8") + + with self.assertRaisesRegex(ArtifactError, "legacy entries"): + install_sweeper(output, runner=fake_runner) + + self.assertEqual(calls, []) + + def test_uninstall_sweeper_removes_only_its_own_schedule(self) -> None: + calls: list[tuple[list[str], str | None]] = [] + + def fake_runner(arguments: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append((arguments, kwargs.get("input") if isinstance(kwargs.get("input"), str) else None)) + if arguments == ["crontab", "-l"]: + return subprocess.CompletedProcess( + arguments, + 0, + "0 1 * * * backup\n17 * * * * stale # codex-agent-artifact-sweep-placeholder\n", + "", + ) + return subprocess.CompletedProcess(arguments, 0, "", "") + + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + output = Path(temporary) / "output" + specification = install_sweeper(output, runner=fake_runner) + calls.clear() + uninstall_sweeper(output, runner=fake_runner) + + if os.name == "nt": + self.assertIn("/Delete", calls[0][0]) + self.assertIn(str(specification["identifier"]), calls[0][0]) + else: + updated_crontab = calls[1][1] or "" + self.assertIn("backup", updated_crontab) + self.assertNotIn(str(specification["identifier"]), updated_crontab) + + def test_deliver_refuses_to_repackage_anything_already_in_the_outbox(self) -> None: + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + output = Path(temporary) / "output" + output.mkdir() + source = output / "unmanaged.txt" + source.write_text("already expiring", encoding="utf-8") + + with self.assertRaisesRegex(ArtifactError, "already inside the outbox"): + ArtifactOutbox(output).deliver([source], slug="nested") + + def test_deliver_refuses_case_colliding_destination_names(self) -> None: + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + root = Path(temporary) + first_dir = root / "first" + second_dir = root / "second" + first_dir.mkdir() + second_dir.mkdir() + first = first_dir / "Report.TXT" + second = second_dir / "report.txt" + first.write_text("first", encoding="utf-8") + second.write_text("second", encoding="utf-8") + + with self.assertRaisesRegex(ArtifactError, "duplicate filename"): + ArtifactOutbox(root / "output").deliver([first, second], slug="collision") + + def test_deliver_refuses_the_reserved_manifest_filename(self) -> None: + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + root = Path(temporary) + source = root / ".delivery.json" + source.write_text("must not replace the manifest", encoding="utf-8") + + with self.assertRaisesRegex(ArtifactError, "reserved filename"): + ArtifactOutbox(root / "output").deliver([source], slug="reserved") + + def test_sweep_refuses_to_enroll_legacy_output_before_activation(self) -> None: + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + output = Path(temporary) / "output" + output.mkdir() + legacy = output / "legacy-result.txt" + legacy.write_text("not yet an expiring delivery", encoding="utf-8") + + with self.assertRaisesRegex(ArtifactError, "legacy entries"): + ArtifactOutbox(output).sweep() + + self.assertTrue(legacy.exists()) + + def test_sweep_reports_a_non_object_state_as_an_artifact_error(self) -> None: + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + output = Path(temporary) / "output" + output.mkdir() + (output / ".outbox-state.json").write_text("[]\n", encoding="utf-8") + + with self.assertRaisesRegex(ArtifactError, "unsupported schema"): + ArtifactOutbox(output).sweep() + + def test_outbox_root_must_not_be_a_link_or_reparse_point(self) -> None: + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + root = Path(temporary) + target = root / "external-target" + target.mkdir() + linked_output = root / "output" + try: + linked_output.symlink_to(target, target_is_directory=True) + except OSError: + self.skipTest("This host does not permit test symlink creation.") + + with self.assertRaisesRegex(ArtifactError, "must not be a link or reparse point"): + ArtifactOutbox(linked_output) + + def test_sweep_skips_safely_when_another_outbox_operation_holds_the_lock(self) -> None: + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + root = Path(temporary) + source = root / "result.txt" + source.write_text("still in use", encoding="utf-8") + output = root / "output" + expired = ArtifactOutbox( + output, + clock=lambda: datetime(2026, 7, 1, tzinfo=timezone.utc), + ).deliver([source], slug="expired") + (output / ".artifactctl.lock").write_text( + json.dumps({"created_at": "2026-07-16T00:00:00Z"}), + encoding="utf-8", + ) + + result = ArtifactOutbox( + output, + clock=lambda: datetime(2026, 7, 16, 0, 5, tzinfo=timezone.utc), + ).sweep() + + self.assertTrue(result.skipped_locked) + self.assertTrue(expired.path.exists()) + + def test_deliver_refuses_to_race_another_outbox_operation(self) -> None: + now = datetime(2026, 7, 16, 0, 5, tzinfo=timezone.utc) + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + root = Path(temporary) + source = root / "result.txt" + source.write_text("delivery", encoding="utf-8") + output = root / "output" + output.mkdir() + (output / ".artifactctl.lock").write_text( + json.dumps({"created_at": "2026-07-16T00:00:00Z"}), + encoding="utf-8", + ) + + with self.assertRaisesRegex(ArtifactError, "outbox is busy"): + ArtifactOutbox(output, clock=lambda: now).deliver([source], slug="blocked") + + def test_deliver_refuses_linked_sources(self) -> None: + with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: + root = Path(temporary) + target = root / "target.txt" + target.write_text("linked content", encoding="utf-8") + linked_source = root / "linked.txt" + try: + linked_source.symlink_to(target) + except OSError: + self.skipTest("This host does not permit test symlink creation.") + + with self.assertRaisesRegex(ArtifactError, "must not be a link or reparse point"): + ArtifactOutbox(root / "output").deliver([linked_source], slug="linked") + + def test_session_start_hook_pins_the_automatic_sweeper(self) -> None: + script = SOURCE_ROOT / "scripts" / "artifactctl.py" + script_hash = hashlib.sha256(script.read_bytes()).hexdigest() + config = json.loads((SOURCE_ROOT / ".codex" / "hooks.json").read_text(encoding="utf-8")) + command_hooks = [ + hook + for group in config["hooks"]["SessionStart"] + for hook in group["hooks"] + if hook.get("type") == "command" + ] + artifact_hooks = [hook for hook in command_hooks if "artifactctl.py" in hook.get("command", "")] + + self.assertEqual(len(artifact_hooks), 1) + for property_name in ("command", "commandWindows"): + command = artifact_hooks[0][property_name] + self.assertEqual(command.count(script_hash), 1) + self.assertIn("sweep --quiet", command) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/prepublish-smoke.ps1 b/tests/prepublish-smoke.ps1 index 6f2a649..e10c431 100644 --- a/tests/prepublish-smoke.ps1 +++ b/tests/prepublish-smoke.ps1 @@ -49,18 +49,20 @@ try { New-Item -ItemType Directory -Path (Join-Path $testRepo '.agents/skills/portable-agent-memory') -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $testRepo 'templates') -Force | Out-Null Copy-Item -LiteralPath (Join-Path $sourceRoot 'scripts/prepublish-check.ps1') -Destination (Join-Path $testRepo 'scripts/prepublish-check.ps1') + Copy-Item -LiteralPath (Join-Path $sourceRoot 'scripts/artifactctl.py') -Destination (Join-Path $testRepo 'scripts/artifactctl.py') Copy-Item -LiteralPath (Join-Path $sourceRoot 'scripts/agent-startup-context.py') -Destination (Join-Path $testRepo 'scripts/agent-startup-context.py') Copy-Item -LiteralPath (Join-Path $sourceRoot 'scripts/agent-memory.py') -Destination (Join-Path $testRepo 'scripts/agent-memory.py') Copy-Item -LiteralPath (Join-Path $sourceRoot 'scripts/agent-memory.ps1') -Destination (Join-Path $testRepo 'scripts/agent-memory.ps1') Copy-Item -LiteralPath (Join-Path $sourceRoot 'tests/smoke.py') -Destination (Join-Path $testRepo 'tests/smoke.py') Copy-Item -LiteralPath (Join-Path $sourceRoot 'tests/smoke.ps1') -Destination (Join-Path $testRepo 'tests/smoke.ps1') Copy-Item -LiteralPath (Join-Path $sourceRoot 'tests/interop.ps1') -Destination (Join-Path $testRepo 'tests/interop.ps1') + Copy-Item -LiteralPath (Join-Path $sourceRoot 'tests/artifactctl_test.py') -Destination (Join-Path $testRepo 'tests/artifactctl_test.py') Copy-Item -LiteralPath (Join-Path $sourceRoot 'tests/prepublish-smoke.ps1') -Destination (Join-Path $testRepo 'tests/prepublish-smoke.ps1') Copy-Item -LiteralPath (Join-Path $sourceRoot '.github/workflows/smoke.yml') -Destination (Join-Path $testRepo '.github/workflows/smoke.yml') Copy-Item -LiteralPath (Join-Path $sourceRoot '.codex/hooks.json') -Destination (Join-Path $testRepo '.codex/hooks.json') Copy-Item -LiteralPath (Join-Path $sourceRoot 'AGENTS.md') -Destination (Join-Path $testRepo 'AGENTS.md') Copy-Item -LiteralPath (Join-Path $sourceRoot 'templates/vault') -Destination (Join-Path $testRepo 'templates/vault') -Recurse - $safeIgnore = ".env`n# Publication safety boundary`n/.agent-vault`n/.agent-vault/`n/.agent-memory.local.json`n/.agent-memory.local.*`n/.codex/*`n!/.codex/hooks.json`n/.codex-log/`n" + $safeIgnore = ".env`n/output/`n# Publication safety boundary`n/.agent-vault`n/.agent-vault/`n/.agent-memory.local.json`n/.agent-memory.local.*`n/.codex/*`n!/.codex/hooks.json`n/.codex-log/`n" [IO.File]::WriteAllText((Join-Path $testRepo '.gitignore'), $safeIgnore) [IO.File]::WriteAllText((Join-Path $testRepo '.agents/skills/codex-agent-init/SKILL.md'), "---`nname: codex-agent-init`ndescription: Safe test init skill.`n---`n`n# Test`n") [IO.File]::WriteAllText((Join-Path $testRepo '.agents/skills/portable-agent-memory/SKILL.md'), "---`nname: portable-agent-memory`ndescription: Safe test skill.`n---`n`n# Test`n") @@ -108,6 +110,20 @@ try { finally { Pop-Location } Remove-Item -LiteralPath (Join-Path $testRepo '.codex/private-state.json') + New-Item -ItemType Directory -Path (Join-Path $testRepo 'output') -Force | Out-Null + [IO.File]::WriteAllText((Join-Path $testRepo 'output/transient-delivery.txt'), 'temporary user delivery') + Push-Location $testRepo + try { Invoke-GitChecked @('add', '-f', '--', 'output/transient-delivery.txt') } + finally { Pop-Location } + $outputFailure = Invoke-PublishCheck 1 + if (-not (($outputFailure -join "`n").Contains('Temporary delivery files must not be published'))) { + throw "The publication check did not identify the staged output path as a temporary delivery." + } + Push-Location $testRepo + try { Invoke-GitChecked @('rm', '--cached', '-f', '--', 'output/transient-delivery.txt') } + finally { Pop-Location } + Remove-Item -LiteralPath (Join-Path $testRepo 'output') -Recurse -Force + [IO.File]::AppendAllText((Join-Path $testRepo '.gitignore'), "!/.agent-vault/**`n") $null = Invoke-PublishCheck 1 [IO.File]::WriteAllText((Join-Path $testRepo '.gitignore'), $safeIgnore) From 77dfdb1bf9cfe239c8f2a5c6b5731cc62bd60887 Mon Sep 17 00:00:00 2001 From: sint <17594145+jugol@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:28:59 +0900 Subject: [PATCH 2/5] Add script lifecycle rules --- AGENTS.md | 8 ++++++++ CONTRIBUTING.md | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 354bc7e..3e735e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,14 @@ When the trusted repository hook injects approved portable memory, apply it only - Activate cleanup only on an empty outbox. If an existing checkout has pre-policy `output/` entries, do not enroll them: inspect and move the exact legacy directory outside the repository first, then install the sweeper. The hash-pinned `SessionStart` handler fails closed while unreviewed legacy entries remain. - The hash-pinned `SessionStart` sweeper and the installed hourly local scheduler delete expired managed packages permanently. A machine that was off catches up at the next scheduler run or trusted session start. `scripts/artifactctl.py uninstall-sweeper` removes the device-local schedule when intentionally decommissioning a checkout. +## Script lifecycle + +- Before creating a script, classify it as durable repository tooling or a task-local helper. Put one-off helpers in an external temporary workspace, not `scripts/` and not the delivery `output/` outbox. +- Every new or materially rewritten script intended to remain under `scripts/` must start with a concise comment or docstring recording `Purpose` (why it exists), `Retention` (why it should remain reusable), and `Remove when` (an objective condition for future deletion). Place this metadata after any required shebang or language directive. +- At a meaningful checkpoint and before the final response, review every helper script created by the current task. If it is no longer required by a checked-in workflow, hook, test, documented operator command, or clearly justified reuse case, delete it together with task-created caches and auxiliary files. Do not retain it merely because it might be useful someday. +- Self-cleanup authority extends only to files created by the current task or clearly marked as Agent-temporary. Never delete a pre-existing, user-created, tracked, or ambiguously owned script based only on age, low reference count, or apparent obsolescence; report it as a cleanup candidate and obtain approval. +- When promoting a temporary helper to durable tooling, move it into `scripts/`, add the lifecycle header, and add the smallest appropriate test or documentation reference that proves why it remains. + ## At a meaningful checkpoint - Create a unique shared handoff only when a later task would otherwise need to rediscover unfinished or non-obvious state. Use the selected lifecycle runner's `new-handoff` command and the exact current lease ID. Keep the handoff compact, and change `status` from `draft` to `current` only when it represents actively resumable state. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bff785d..b65b449 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,13 @@ Python is the preferred cross-platform lifecycle runtime. Windows PowerShell 5.1 - When changing `scripts/artifactctl.py`, update both pinned SHA-256 occurrences in the `SessionStart` hook and refresh the device-local sweeper schedule. Never commit `output/`; it is a seven-day temporary delivery outbox. - New automatic promotion behavior must remain opt-in and reviewable. `SOUL.md`, approved lessons, and durable preferences are human-governed. +## Script lifecycle + +- Keep one-off helpers outside the repository. `scripts/` is for durable operator or lifecycle tooling, not task residue. +- Begin every new or materially rewritten durable script with a concise `Purpose`, `Retention`, and `Remove when` comment or docstring so later maintainers can judge whether it still belongs. +- Delete task-created temporary scripts, caches, and auxiliary files when their work is complete. Do not delete pre-existing or ambiguously owned scripts without explicit approval. +- A script promoted into `scripts/` should have the smallest useful test or documentation reference demonstrating its durable role. + ## Pull requests Explain the behavior change, the threat or failure mode it addresses, and the checks you ran. Keep platform-specific behavior explicit. From cdcc98798e6c487859247720c5d4268697ce88ea Mon Sep 17 00:00:00 2001 From: sint <17594145+jugol@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:46:49 +0900 Subject: [PATCH 3/5] Fix macOS artifact path assertions --- tests/artifactctl_test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/artifactctl_test.py b/tests/artifactctl_test.py index e83681a..6d2635a 100644 --- a/tests/artifactctl_test.py +++ b/tests/artifactctl_test.py @@ -104,10 +104,10 @@ def test_unmanaged_output_is_registered_then_deleted_after_seven_days(self) -> N clock=lambda: datetime(2026, 7, 23, tzinfo=timezone.utc), ).sweep() - self.assertEqual(discovered.registered_unmanaged, (unmanaged,)) + self.assertEqual(discovered.registered_unmanaged, (unmanaged.resolve(),)) self.assertTrue(unmanaged not in before_expiry.deleted_unmanaged) self.assertFalse(unmanaged.exists()) - self.assertEqual(expired.deleted_unmanaged, (unmanaged,)) + self.assertEqual(expired.deleted_unmanaged, ((output / unmanaged.name).resolve(),)) def test_replaced_unmanaged_entry_receives_a_new_seven_day_lifetime(self) -> None: with tempfile.TemporaryDirectory(prefix="codex-agent-artifact-") as temporary: @@ -133,7 +133,7 @@ def test_replaced_unmanaged_entry_receives_a_new_seven_day_lifetime(self) -> Non self.assertTrue(unmanaged.exists()) self.assertEqual(unmanaged.read_text(encoding="utf-8"), "replacement") - self.assertEqual(result.registered_unmanaged, (unmanaged,)) + self.assertEqual(result.registered_unmanaged, (unmanaged.resolve(),)) self.assertEqual(result.deleted_unmanaged, ()) def test_inspect_returns_the_exact_retention_proposal_evidence(self) -> None: @@ -169,7 +169,7 @@ def test_cli_deliver_reports_the_path_and_exact_expiry_as_json(self) -> None: ) result = json.loads(process.stdout) - self.assertEqual(Path(result["path"]).parent, root / "output") + self.assertEqual(Path(result["path"]).parent, (root / "output").resolve()) self.assertEqual(result["deletion_policy"], "permanent") self.assertRegex(result["expires_at"], r"Z$") From 2db6c40bf93c0a5b58f335cd74ebb6dff0fa9182 Mon Sep 17 00:00:00 2001 From: sint <17594145+jugol@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:49:20 +0900 Subject: [PATCH 4/5] Fix Windows scheduler path assertion --- tests/artifactctl_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/artifactctl_test.py b/tests/artifactctl_test.py index 6d2635a..c45aa9f 100644 --- a/tests/artifactctl_test.py +++ b/tests/artifactctl_test.py @@ -270,7 +270,7 @@ def test_install_sweeper_dry_run_describes_an_hourly_local_schedule(self) -> Non self.assertEqual(specification["scheduler"], expected_scheduler) self.assertEqual(specification["frequency"], "hourly") self.assertIn("sweep", specification["sweep_command"]) - self.assertIn(os.fspath(output), specification["sweep_command"]) + self.assertIn(os.fspath(output.resolve()), specification["sweep_command"]) def test_install_sweeper_uses_the_platform_user_scheduler(self) -> None: calls: list[tuple[list[str], str | None]] = [] From a945b067f7fa231f8441dc69c0eeb701f921eb0e Mon Sep 17 00:00:00 2001 From: sint <17594145+jugol@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:53:53 +0900 Subject: [PATCH 5/5] Stabilize prepublish failure output --- scripts/prepublish-check.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/prepublish-check.ps1 b/scripts/prepublish-check.ps1 index 45d4d76..c126285 100644 --- a/scripts/prepublish-check.ps1 +++ b/scripts/prepublish-check.ps1 @@ -356,7 +356,7 @@ finally { } if ($failures.Count -gt 0) { - Write-Error ("Public-boundary check failed:`n- " + ($failures -join "`n- ")) + [Console]::Error.WriteLine("Public-boundary check failed:`n- " + ($failures -join "`n- ")) exit 1 }