From b4a1923948c24eec22293263e54345e5e36004e6 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 27 Oct 2026 10:58:38 +0100 Subject: [PATCH 1/4] plan: scope TUI initial ZIP preparation --- .../initiatives/WS-CLI-001/WS-CLI-001-52.md | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 .commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md new file mode 100644 index 000000000..6a562f3f8 --- /dev/null +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md @@ -0,0 +1,121 @@ +# WS-CLI-001-52 — Prepare an initial ZIP in the contributor workbench + +- Initiative: `WS-CLI-001` +- Durable disposition: `Planned` +- Intended merge outcome: Let contributors explicitly prepare an initial ZIP, inspect intake feedback and replay the exact preparation without creating a Submission. + +## Intent + +After the merged Claim/Start slice, the human asked to move forward. The next +bounded step is packaging/intake, not a second submission implementation or a +claim that the complete TUI journey is delivered. + +## Current behavior + +Main `517fe54c4` contains deliberate TUI claim/start. The existing typed +`api.Client.PrepareSubmission` streams one regular file, validates the packet, +returns intake feedback, checks source changes and supports explicit caller-key +replay. `task prepare` is its current consumer. Mine rows retain assignment IDs; +WorkContext confirms current-actor assignment but does not expose that ID. + +## Bounded change + +### Allowed + +- Existing `cli/internal/tui/` model, rendering, request port and focused tests. +- Existing `cli/internal/api/submission.go` and its tests: an explicit optional + expected SHA-256 argument to the same preparation operation, checked against + its existing pre-send source hash. Update `command/submission.go` to pass no + expected hash, preserving explicit CLI behavior; affected command test fakes. +- Focused native HTTP/public-API preparation tests and existing terminal fixtures + only as needed, with unchanged strict defaults. Current CLI/design/roadmap and + initiative/index documentation. + +### Not allowed + +Backend/API wire/auth/policy/storage changes; immutable Submission creation; +revision uploads or inferred predecessor selection; management, Review or awards; +polling, automatic POST retries, file execution, recursive file picker, automatic +UUID generation, generic operation journal, compatibility paths, CI weakening, +new dependencies or user-file writes. + +## Design and decisions + +- From fresh Mine Overview for an observed `in_progress` task, offer Prepare. + On deliberate entry, freshly read exact WorkContext and require matching + actor/project/task, assigned-to-current-actor and no revision context. Bind the + selected Mine assignment ID; UI observations never replace backend guards. +- Use a compact staged form: regular ZIP path, exact optional summary and + attestation, caller-owned UUID saved outside the session. Blank optional text + means omitted; preserve printable ASCII text exactly and reject invalid input + rather than trimming or silently converting it. Explain current API limits. +- Inspect/hash the selected regular file asynchronously, bounded at 512MiB, with + cancellation checks and safe errors. No disk work on resize or row movement. + Confirmation shows exact actor/project/task/assignment, path, bytes, SHA-256, + packet text and key. Only explicit save acknowledgment and `y` send one POST. +- Extend the existing shared preparation client with an expected SHA-256 input + so a file changed after confirmation is rejected before dispatch. Retain its + existing post-send source verification and unknown-outcome semantics. Open + source handles inside commands, close on every path; no background descriptor + stored in render state. No simulated upload percentage. +- Freeze the complete packet and file fingerprint for send/replay. Unknown + outcomes prohibit replacement by a new packet/key; only explicit unchanged + replay is offered. A replaced/changed file is not a replay. A local pre-send + refusal must not erase an earlier uncertain server result. Pending work blocks + navigation/duplicate dispatch; quit cancels the client, not server effects. +- Reuse existing bounded action outcomes/navigation/security clearing, extending + operation-specific state only where upload inputs differ. No parallel generic + mutation framework. Preserve the delayed-read401 dispatch guard from -51. +- Display ready admission separately from all non-ready storage states; include + successful warnings and canonical blocking intake feedback. Every result says + no immutable Submission was requested. No success on malformed/transport + errors. Authorization denial clears protected text and disables replay while + retaining only minimal inert recovery identities; never render bearer/errors + unsanitized. Recover after restart through caller-saved exact inputs and CLI. +- A ready receipt exposes its admission and exact packet for an inert CLI + submission handoff, not an executable shell command or automatic submission. + Do not claim historical preparation browsing or persistent recovery. + +## Acceptance criteria + +- Initial in-progress Mine task can prepare a real ZIP and display ready, + non-ready and failed-intake results with findings; no Submission POST occurs. +- Cancel/resize/paste/navigation never sends implicitly. Corrected failed intake + may start a new deliberately confirmed operation; unknown result cannot. +- Exact task/assignment/packet/key/hash are retained for explicit replay. A file + modified after confirmation sends no POST; modifications during send cannot + produce a confirmed success. Stale responses and delayed auth failures cannot + reopen a write or restore protected content. +- Existing Claim/Start, reads, downloads, pagination and noninteractive CLI + contracts pass unchanged. Text fields accept literal q and pasted content as + text, never shortcut execution; bounded/plain/minimum rendering remains usable. +- Native built-binary HTTP tests assert ZIP bytes/headers, no automatic retry, + failure/non-ready/unknown/changed-file/revoked paths. A signed public API native + drill uses isolated PostgreSQL and existing artifact fixture to prove failed + intake, ready preparation, exact replay, revocation and zero Submission rows. + Storage fixture proof must not be labelled real S3; hosted MinIO proof remains + separately identified. Run relevant Go tests/vet/race and full hosted checks. + +## Risk and review routing + +- Risk class: L1 (file input, contributor mutation, uncertain outcomes). +- Plan review before implementation; architecture/reuse, security, QA/test-delta, + product/docs and CI-integrity if shared native fixture/selection is affected. +- Human focus: can a contributor identify the ZIP/packet, understand intake + findings and distinguish preparation from submission without hidden actions? +- No new human decision is required by this bounded scope; no merge authorized. + +## Evidence + +Existing API/client/CLI proof is discovery evidence, not TUI runtime proof. New +focused tests must discriminate changed-file dispatch and implicit duplicate +requests, and preserve the existing signed/native Claim/Start tests. Exact-head +commands and reviewer results belong in the PR, not this durable record. + +## Reconciliation + +Build on merged -51; no overlapping open TUI PR was found. Preserve main's hosted +checker and unrelated work. Next usable boundary: deliberate immutable Submission +creation from a ready preparation, then outcome/revision navigation and Evidence. +Caller-owned saved inputs remain a documented recovery limitation, not crash-safe +automatic recovery. Do not begin those next chunks automatically. From 329e4b560ea39c9bdbaba85342e1f22da0297c93 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 27 Oct 2026 11:00:42 +0100 Subject: [PATCH 2/4] plan: bind signed intake proof and source guard --- .../initiatives/WS-CLI-001/WS-CLI-001-52.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md index 6a562f3f8..3a3504979 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md @@ -55,7 +55,10 @@ new dependencies or user-file writes. packet text and key. Only explicit save acknowledgment and `y` send one POST. - Extend the existing shared preparation client with an expected SHA-256 input so a file changed after confirmation is rejected before dispatch. Retain its - existing post-send source verification and unknown-outcome semantics. Open + existing post-send source verification and unknown-outcome semantics. + The expected hash is checked inside `PrepareSubmission` after its own first + hash and before request creation, with a context cancellation check, not only + in the TUI before a later independent hash. Open source handles inside commands, close on every path; no background descriptor stored in render state. No simulated upload percentage. - Freeze the complete packet and file fingerprint for send/replay. Unknown @@ -112,6 +115,18 @@ focused tests must discriminate changed-file dispatch and implicit duplicate requests, and preserve the existing signed/native Claim/Start tests. Exact-head commands and reviewer results belong in the PR, not this durable record. +The signed native drill must construct an app from production Flow verifier +settings plus the fixture's exact ART namespace/local-store/scratch settings and +isolated database. Install only the fixture JWKS transport; leave DB/AUTH/TASK +owners unoverridden. If reused, name `LocalIntakeAdapter` explicitly as the +in-process guest-rule fixture, not sandbox execution. First send a canonical +failed-intake packet and assert feedback/zero Submission. Then use a fresh saved +key and valid ZIP/packet: pending verification -> direct invocation of the real +ART verifier -> exact native replay -> ready admission. Revoke through a +distinct signed manager and replay to prove fresh denial/clearing. POST +`put_attempt_id` is not GET preparation ID; the verifier consumes the former. +Do not invent a detail lookup or infer replay identity from the latest list row. + ## Reconciliation Build on merged -51; no overlapping open TUI PR was found. Preserve main's hosted From 3b65096315103ac909ea2d98662af586ff3c445c Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 27 Oct 2026 11:20:48 +0100 Subject: [PATCH 3/4] feat(cli): prepare initial ZIPs in contributor workbench --- .commitrail/INDEX.md | 2 +- .../initiatives/WS-CLI-001/OVERVIEW.md | 13 +- .../initiatives/WS-CLI-001/WS-CLI-001-52.md | 9 + cli/README.md | 31 +- cli/internal/api/submission.go | 10 +- cli/internal/api/submission_test.go | 54 +++ cli/internal/command/submission.go | 2 +- cli/internal/tui/actions.go | 77 +++- cli/internal/tui/model.go | 19 +- cli/internal/tui/model_test.go | 11 + cli/internal/tui/preparation.go | 376 ++++++++++++++++++ cli/internal/tui/preparation_source_unix.go | 14 + .../tui/preparation_source_unix_test.go | 28 ++ .../tui/preparation_source_windows.go | 25 ++ .../tui/preparation_source_windows_test.go | 15 + cli/internal/tui/preparation_test.go | 351 ++++++++++++++++ cli/internal/tui/requests.go | 16 +- cli/internal/tui/view.go | 13 +- cli/tests/integration/test_tui_http.py | 312 ++++++++++++++- cli/tests/integration/test_tui_public_api.py | 359 ++++++++++++++++- docs/design/cli-terminal-experience.md | 43 +- docs/roadmap_status.md | 30 +- 22 files changed, 1764 insertions(+), 46 deletions(-) create mode 100644 cli/internal/api/submission_test.go create mode 100644 cli/internal/tui/preparation.go create mode 100644 cli/internal/tui/preparation_source_unix.go create mode 100644 cli/internal/tui/preparation_source_unix_test.go create mode 100644 cli/internal/tui/preparation_source_windows.go create mode 100644 cli/internal/tui/preparation_source_windows_test.go create mode 100644 cli/internal/tui/preparation_test.go diff --git a/.commitrail/INDEX.md b/.commitrail/INDEX.md index 265cb518e..798207db8 100644 --- a/.commitrail/INDEX.md +++ b/.commitrail/INDEX.md @@ -11,7 +11,7 @@ for current product capability. | [WS-AGENT-001](initiatives/WS-AGENT-001/OVERVIEW.md) | Planned | Neutral document tools plus OpenAI Agents SDK and OmniCoreAgent original-guide qualifications are delivered through the same public API, durable worker, parser and MinIO path. Retain the exact external provider/account and selected-image AWS evidence, then prove the broader hosted Flow contribution journey before deployment claims. | | [WS-DB-002](initiatives/WS-DB-002/OVERVIEW.md) | Complete | Shared UUIDv7 record generation, native-UUID relationships and fresh v0.1 baseline; natural-owner retry custody and aligned CI/local setup | | [WS-MCP-002](initiatives/WS-MCP-002/OVERVIEW.md) | Planned | 108 tools through WS-MCP-002-12 cover access, guide setup, contributor workspace, contribution policies, checker/artifact operations and task execution/submissions; task operations/imports and assembled release proof remain. | -| [WS-CLI-001](initiatives/WS-CLI-001/OVERVIEW.md) | Planned | Public Go CLI setup, contributor/manager workflows, administrative and operational commands, Finance policies and contribution exports are detailed in the overview; -43 proves the connected local unpaid automated-acceptance journey. -44 prepares four native release archives and a gated manual publisher; -47 hardens its administrator-bypass guard. First publication and public-asset audit remain operational work. -46 adds the separate system-reader project catalogue; -48/-49 add contributor navigation, findings and verified guide downloads; -50 establishes the reading workbench. -51 bounds confirmed Claim/Start and direct collection navigation. TUI ZIP preparation/submission, automatic crash-safe recovery, contribution evidence, management, public human-review controls and hosted proof remain separate. Current revision context never substitutes for historical evidence. | +| [WS-CLI-001](initiatives/WS-CLI-001/OVERVIEW.md) | Planned | Public Go CLI setup, contributor/manager workflows, administrative and operational commands, Finance policies and contribution exports are detailed in the overview; -43 proves the connected local unpaid automated-acceptance journey. -44 prepares four native release archives and a gated manual publisher; -47 hardens its administrator-bypass guard. First publication and public-asset audit remain operational work. -46 adds the separate system-reader project catalogue; -48/-49 add contributor navigation, findings and verified guide downloads; -50 establishes the reading workbench. -51 adds confirmed Claim/Start and direct navigation; -52 adds initial ZIP preparation/feedback with exact reviewed inputs. TUI immutable Submission/revision writes, automatic crash-safe recovery, contribution evidence, management, public human-review controls and hosted proof remain separate. Current revision context never substitutes for historical evidence. | | [WS-ARCH-001](initiatives/WS-ARCH-001/OVERVIEW.md) | Planned | Public admission-backed initial/remediation Submission entry and supported false-policy guide activation are implemented over selected production delivery. New false/pass effects require LIVE REV; a known pre-effect non-live refusal has bounded retry, with exhausted delivery dead-lettered. The [bounded local public/worker drill](initiatives/WS-ARCH-001/planning/PLAN.md#first-complete-contributor-milestone) starts from public guide inputs with recorded inference; hosted operation and live human review remain. | | [WS-ART-001](initiatives/WS-ART-001/OVERVIEW.md) | Planned | Verified input/output custody, exact-cause replacement and public admission-backed ZIP/Submission entry are delivered. The bounded local public/worker drill passed; hosted operation and setup-fault handling remain separate. | | [WS-AUTH-001](initiatives/WS-AUTH-001/OVERVIEW.md) | Planned | Exact fixed-router custody, checker remediation and checker retry are delivered. The ART Operator backend prerequisite adds exact system-Operator artifact reads and independently guarded verification retry; WS-CLI-001-43 consumes all nine public ART operations, while hosted S3 proof remains separate. Public entry and supported false-guide activation use current exact AUTH/SQL custody; human decision authority remains later work. | diff --git a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md index 365246421..034f77470 100644 --- a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md @@ -67,7 +67,8 @@ ordered locked-guide selection with explicit verified single-document download. visible collections/sections, readable instructions and truthful page/evidence states; it changes no public operation or lifecycle authority. Contribution evidence remains an inert CLI handoff. Claim/start is bounded by --51 below; other product mutations, management screens and their recovery remain +-51 and initial ZIP preparation by -52 below; other product mutations, +management screens and their recovery remain separate TUI boundaries; this interface does not depend on first binary publication or system-wide discovery. @@ -89,7 +90,10 @@ the existing TUI and typed API client, not a second client or simulated demo. 4. Contribution evidence: inspect the contributor's returned records and awards inside the workbench, retaining the distinction from fulfillment. -Only the first boundary is specified for implementation now. Each following +The initial ZIP preparation boundary is specified by +[WS-CLI-001-52](WS-CLI-001-52.md): fresh assigned context, exact packet/file hash, +separate confirmation, intake feedback and explicit unchanged replay. Immutable +Submission creation and revision uploads are not part of that slice. Each following boundary needs inspected API/replay contracts, its own bounded record and native public-API proof before starting. A durable automatic recovery design is needed before replacing caller-owned saved keys with generated keys; the current CLI @@ -108,7 +112,7 @@ and separate guide-bound review/revision policy configuration. The commands the existing public writes with caller-owned replay keys and policy `If-Match`. The backend owns manager authorization for new writes, policy semantics, replay and activation; editing/configuring a guide does not approve or activate it. Other public API -client gaps and the broader TUI beyond -51 remain separate work. Binary distribution's +client gaps and the broader TUI beyond -52 remain separate work. Binary distribution's workflow is prepared by -44; a protected release run and public-asset audit remain operational work. @@ -461,7 +465,8 @@ CLIs. Keep the package independent of backend and MCP runtime dependencies. 26. **Optional TUI:** -48 supplies contributor navigation through existing typed reads; -49 adds findings pages and confirmed verified guide downloads; -50 redraws this same contributor boundary for readable work and evidence states. - In-TUI Evidence, product writes and management remain + -51 adds deliberate claim/start; -52 adds initial ZIP preparation. + In-TUI Evidence, immutable Submission/revision writes and management remain separate boundaries; Evidence first needs a shared typed single-page client port without changing complete CLI exports. Never require a TUI for agents or scripts. diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md index 3a3504979..18c8cc86f 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md @@ -51,6 +51,10 @@ new dependencies or user-file writes. rather than trimming or silently converting it. Explain current API limits. - Inspect/hash the selected regular file asynchronously, bounded at 512MiB, with cancellation checks and safe errors. No disk work on resize or row movement. + Local paths support valid Unicode without control characters; the API's ASCII + restriction applies to packet headers, not filenames. Use narrowly scoped + platform-specific opening where required to reject nonregular sources without + blocking the supported Linux, macOS or Windows terminal process. Confirmation shows exact actor/project/task/assignment, path, bytes, SHA-256, packet text and key. Only explicit save acknowledgment and `y` send one POST. - Extend the existing shared preparation client with an expected SHA-256 input @@ -107,6 +111,11 @@ new dependencies or user-file writes. - Human focus: can a contributor identify the ZIP/packet, understand intake findings and distinguish preparation from submission without hidden actions? - No new human decision is required by this bounded scope; no merge authorized. +- Size exception: the cohesive file-input operation needs model/API tests, + native terminal wire proof and signed PostgreSQL owner proof together. This + exceeds the preferred L1 diff size; no backend, schema or workflow changes + are included. Review those proof layers separately against the same candidate + rather than splitting the safety guards from the operation they protect. ## Evidence diff --git a/cli/README.md b/cli/README.md index 990bbcae9..392f05fdf 100644 --- a/cli/README.md +++ b/cli/README.md @@ -48,7 +48,7 @@ terms remain available separately. Workstream rechecks each public read. | Tab / Esc | Change section / go back or cancel | | Shift+Tab | Previous collection or detail section | | `1` / `2` / `3` | Go directly to Ready / Mine / Submissions from ordinary views | -| `a` in task Overview | Open Claim or Start when the freshly inspected task supports that action | +| `a` in task Overview | Open Claim, Start or initial ZIP preparation when the freshly inspected task supports that action | | `g` | Fetch the project picker again | | `n` / `r` | Fetch the next page / refresh the current view | | `/` | Filter loaded names/titles locally; Enter finishes and Esc cancels | @@ -73,7 +73,27 @@ explicit CLI with your saved inputs and the same API origin and actor. Do not substitute a fresh key or infer success from a refreshed list. Automatic crash-safe recovery is not implemented. -The workspace does not prepare or submit ZIPs, export records, poll, acquire +For an initial `in_progress` Mine task, `a` opens ZIP preparation after a fresh +work-context check. Enter the ZIP path, optional summary and attestation, and a +UUID saved outside this session. Review the exact task/assignment, packet, +path, byte count and SHA-256; acknowledge saving with `s`, then send with `y`. +Press Enter after each field; the key field starts local file inspection, not +the upload. Local paths can contain Unicode; control characters are rejected. +Summary and attestation must fit the API's printable-ASCII header contract; +blank fields are omitted, not silently populated. The file must be a nonempty +regular file of at most 512MiB. Workstream still decides whether its contents +meet the locked intake requirements. + +Preparation displays intake feedback, including nonblocking findings. A ready +admission is **not a Submission**. Non-ready storage states are shown as +returned, without polling. Explicit replay retains the exact key, assignment, +packet and file hash; changed bytes are refused before sending. Save all those +inputs for recovery outside the session. Never replace a key after an unknown +outcome. The returned admission and packet can be used with the separate +`task submit` command; the TUI does not execute that handoff. Revision ZIP +preparation remains an explicit CLI operation. + +The workspace does not create Submissions, export records, poll, acquire tokens or save local history. Guide metadata is not proof of downloaded-byte integrity. In Guide, select one document, enter an existing real directory and confirm its label, document/task IDs, path, UUID filename, byte count and hash. Verified bytes are @@ -395,9 +415,10 @@ and contribution evidence with PostgreSQL, MinIO and Celery; see [the journey evidence](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-43.md). That proof is not hosted deployment verification or proof of human review. The [capability ledger](../docs/roadmap_status.md) identifies those remaining -product boundaries. The interactive workspace exposes claim/start alongside -contributor reads and guide downloads; explicit commands remain the interface -for preparation, submission and the other supported mutations. +product boundaries. The interactive workspace exposes claim/start and initial +ZIP preparation alongside contributor reads and guide downloads. Explicit +commands remain the interface for revision preparation, immutable submission +and the other supported mutations. ### Download and install diff --git a/cli/internal/api/submission.go b/cli/internal/api/submission.go index 5102ef535..e7b31b2c4 100644 --- a/cli/internal/api/submission.go +++ b/cli/internal/api/submission.go @@ -81,7 +81,7 @@ func submissionFailure(err error, preparation bool) error { } // PrepareSubmission streams one regular ZIP once. A receipt is not a Submission. -func (c *Client) PrepareSubmission(ctx context.Context, task, key string, packet SubmissionPacket, acknowledgement *RevisionAcknowledgement, source *os.File) (Result[PreparationReceipt], error) { +func (c *Client) PrepareSubmission(ctx context.Context, task, key string, packet SubmissionPacket, acknowledgement *RevisionAcknowledgement, source *os.File, expectedSHA256 string) (Result[PreparationReceipt], error) { var result Result[PreparationReceipt] if err := packet.validate(task); err != nil { return result, err @@ -105,6 +105,14 @@ func (c *Client) PrepareSubmission(ctx context.Context, task, key string, packet if err != nil { return result, err } + // A reviewed fingerprint is checked against this operation's own pre-send + // hash, not an earlier independent read. Empty preserves explicit CLI input. + if expectedSHA256 != "" && expected != expectedSHA256 { + return result, errors.New("reviewed ZIP changed; no preparation request was sent") + } + if err := ctx.Err(); err != nil { + return result, err + } headers := http.Header{"X-Task-Assignment-Id": {packet.Assignment}} for name, value := range map[string]*string{ "X-Submission-Summary": packet.Summary, "X-Contributor-Attestation": packet.Attestation, diff --git a/cli/internal/api/submission_test.go b/cli/internal/api/submission_test.go new file mode 100644 index 000000000..9dbbb39c9 --- /dev/null +++ b/cli/internal/api/submission_test.go @@ -0,0 +1,54 @@ +package api + +import ( + "context" + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" +) + +func TestPreparationReviewedHashIsCheckedBeforePOST(t *testing.T) { + var posts atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + posts.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = fmt.Fprint(w, `{"put_attempt_id":"10000000-0000-7000-8000-000000000001","admission_id":null,"submission_bundle_preparation_status":"stored_pending_verification","replayed":false,"pre_submission_results":[]}`) + })) + defer server.Close() + c := &Client{origin: server.URL, token: "test-token", guideHTTP: server.Client()} + path := filepath.Join(t.TempDir(), "bundle.zip") + // The expected fingerprint belongs to earlier reviewed bytes. Removing the + // shared-operation guard would send these changed bytes and validate 202. + if err := os.WriteFile(path, []byte("changed bytes"), 0600); err != nil { + t.Fatal(err) + } + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + packet := SubmissionPacket{Assignment: "20000000-0000-7000-8000-000000000001"} + expected := fmt.Sprintf("sha256:%x", sha256.Sum256([]byte("reviewed bytes"))) + _, err = c.PrepareSubmission(context.Background(), "30000000-0000-7000-8000-000000000001", "40000000-0000-7000-8000-000000000001", packet, nil, f, expected) + if err == nil || !strings.Contains(err.Error(), "reviewed ZIP changed") || posts.Load() != 0 { + t.Fatal("reviewed bytes did not guard the shared POST", err, posts.Load()) + } + // Empty is intentional for explicit CLI callers; their behavior is retained. + _, err = c.PrepareSubmission(context.Background(), "30000000-0000-7000-8000-000000000001", "40000000-0000-7000-8000-000000000001", packet, nil, f, "") + if err != nil || posts.Load() != 1 { + t.Fatal("explicit CLI behavior changed", err, posts.Load()) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = c.PrepareSubmission(ctx, "30000000-0000-7000-8000-000000000001", "40000000-0000-7000-8000-000000000001", packet, nil, f, "") + if err != context.Canceled || posts.Load() != 1 { + t.Fatal("cancelled operation sent", err, posts.Load()) + } +} diff --git a/cli/internal/command/submission.go b/cli/internal/command/submission.go index 166638542..998d74a55 100644 --- a/cli/internal/command/submission.go +++ b/cli/internal/command/submission.go @@ -78,7 +78,7 @@ func addSubmissionCommands(root, task *cobra.Command, client func() (*api.Client return bad } defer file.Close() - result, err := c.PrepareSubmission(cmd.Context(), args[0], key, packet, acknowledgement, file) + result, err := c.PrepareSubmission(cmd.Context(), args[0], key, packet, acknowledgement, file, "") if err != nil { return err } diff --git a/cli/internal/tui/actions.go b/cli/internal/tui/actions.go index c2c29d5b4..55c6e1684 100644 --- a/cli/internal/tui/actions.go +++ b/cli/internal/tui/actions.go @@ -21,6 +21,11 @@ const ( writeConfirm writePending writeOutcome + writePrepareContext + writePreparePath + writePrepareSummary + writePrepareAttestation + writePrepareHash ) type writeOutcomeKind int @@ -33,7 +38,9 @@ const ( ) type writePacket struct { - action, actor, project, task, key string + action, actor, project, task, key string + assignment, path, summary, attestation, sha256 string + bytes int64 } type writeState struct { @@ -46,6 +53,9 @@ type writeState struct { replay bool replayDisabled bool sequence uint64 + input string + preparation *api.PreparationReceipt + feedback []string } type writeResponse struct { @@ -54,12 +64,19 @@ type writeResponse struct { sequence uint64 value any err error + notSent bool } var replayUUID = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) var replayUUIDCharacters = regexp.MustCompile(`(?i)^[0-9a-f-]+$`) func (m *Model) paste(value string) { + if m.preparationEntry() { + if !m.closed && !m.tooSmall() { + m.appendPreparationInput(value) + } + return + } if m.closed || m.tooSmall() || m.write.mode != writeKeyEntry { return } @@ -78,6 +95,9 @@ func (m *Model) actionName() string { if m.tab == mine && m.section == 0 && m.task != nil && m.task.Status == "claimed" { return "Start" } + if m.tab == mine && m.section == 0 && m.task != nil && m.task.Status == "in_progress" { + return "Prepare" + } return "" } @@ -85,17 +105,24 @@ func (m *Model) canOpenAction() bool { selected, ok := m.selectedRow() return m.detail && !m.loading && m.failure == "" && m.section == 0 && m.write.outcome != outcomeUnknown && m.actionName() != "" && ok && - !(m.write.outcome == outcomeConfirmed && sameID(m.write.packet.task, m.task.TaskID) && m.write.packet.action == strings.ToLower(m.actionName())) && + !(m.write.outcome == outcomeConfirmed && sameID(m.write.packet.task, m.task.TaskID) && m.write.packet.action == strings.ToLower(m.actionName()) && !m.preparationNeedsNewAttempt()) && sameID(selected.task, m.task.TaskID) && sameID(selected.project, m.task.ProjectID) && sameID(m.project, m.task.ProjectID) && m.actor != "" } -func (m *Model) openAction() { +func (m *Model) openAction() tea.Cmd { action := strings.ToLower(m.actionName()) m.write = writeState{mode: writeKeyEntry, packet: writePacket{ action: action, actor: m.actor, project: m.project, task: m.task.TaskID, }} m.viewport.GotoTop() + if action == "prepare" { + selected, _ := m.selectedRow() + m.write.packet.assignment = selected.id + m.write.mode = writePrepareContext + return m.begin(contextRead) + } + return nil } func (m *Model) canReplay() bool { @@ -104,6 +131,9 @@ func (m *Model) canReplay() bool { } func (m *Model) writeKey(key tea.KeyPressMsg) tea.Cmd { + if m.preparationEntry() { + return m.preparationKey(key) + } k := key.String() switch m.write.mode { case writeKeyEntry: @@ -114,6 +144,9 @@ func (m *Model) writeKey(key tea.KeyPressMsg) tea.Cmd { if replayUUID.MatchString(m.write.keyInput) { m.write.packet.key = m.write.keyInput m.write.keyInput, m.write.notice = "", "" + if m.write.packet.action == "prepare" { + return m.beginPreparationHash() + } m.write.mode = writeConfirm } else { m.write.notice = "Enter a caller-owned UUID saved outside this session." @@ -175,7 +208,8 @@ func (m *Model) beginWrite() tea.Cmd { packet := m.write.packet if m.closed || m.ctx.Err() != nil || m.blocked || m.write.replayDisabled || m.loading || m.tooSmall() || m.write.mode != writeConfirm || !m.write.acknowledged || - (packet.action != "claim" && packet.action != "start") || + (packet.action != "claim" && packet.action != "start" && packet.action != "prepare") || + (packet.action == "prepare" && (!replayUUID.MatchString(packet.assignment) || packet.sha256 == "" || packet.path == "")) || !replayUUID.MatchString(packet.key) || !sameID(packet.actor, m.actor) { if m.write.mode == writeConfirm { m.write.mode, m.write.acknowledged, m.write.replay = writeOutcome, false, false @@ -192,6 +226,9 @@ func (m *Model) beginWrite() tea.Cmd { client := m.client return func() (message tea.Msg) { if ctx.Err() != nil { + if packet.action == "prepare" { + return writeResponse{packet: packet, generation: generation, sequence: sequence, notSent: true} + } return writeResponse{packet: packet, generation: generation, sequence: sequence, err: &api.Failure{Code: "request_failed", OutcomeUnknown: true}} } @@ -207,10 +244,12 @@ func (m *Model) beginWrite() tea.Cmd { var result api.Result[api.ClaimedTask] result, err = client.ClaimTask(ctx, packet.task, packet.key, nil) value = result.Value - } else { + } else if packet.action == "start" { var result api.Result[api.MutationTask] result, err = client.StartTask(ctx, packet.task, packet.key, nil) value = result.Value + } else { + return prepareWrite(ctx, client, packet, generation, sequence) } return writeResponse{packet: packet, generation: generation, sequence: sequence, value: value, err: err} } @@ -228,6 +267,13 @@ func (m *Model) receiveWrite(msg writeResponse) { m.write.mode = writeOutcome m.write.acknowledged = false packet := msg.packet + if packet.action == "prepare" && msg.notSent { + m.write.notice = "Not sent: ZIP unavailable, changed or cancelled before dispatch. No POST was dispatched." + if m.write.outcome == outcomeUnknown { + m.write.notice += " Earlier outcome remains unknown; restore the original ZIP bytes for exact replay." + } + return + } if msg.err == nil { valid := false switch v := msg.value.(type) { @@ -239,6 +285,14 @@ func (m *Model) receiveWrite(msg writeResponse) { case api.MutationTask: valid = packet.action == "start" && sameID(v.ID, packet.task) && sameID(v.ProjectID, packet.project) && v.Status == "in_progress" + case api.PreparationReceipt: + // The shared client owns receipt/status/feedback validation. The + // response identity above binds that receipt to this frozen packet. + valid = packet.action == "prepare" + if valid { + m.write.preparation = &v + m.write.feedback = preparationFeedbackLines(v.Feedback) + } } if valid { m.write.outcome, m.write.notice = outcomeConfirmed, "Validated receipt. A later read is a separate observation." @@ -255,6 +309,9 @@ func (m *Model) receiveWrite(msg writeResponse) { m.write.outcome = outcomeDenied } m.write.notice = "Request denied: " + m.clean(failure.Code) + fmt.Sprintf(" (HTTP %d).", failure.Status) + if packet.action == "prepare" && failure.Status == 422 && failure.Code == "pre_submission_checker_failed" { + m.write.feedback = blockedPreparationFeedback(failure.Details) + } if m.write.outcome == outcomeUnknown { m.write.notice += " Earlier outcome remains unknown." } @@ -273,6 +330,7 @@ func (m *Model) receiveWrite(msg writeResponse) { m.clearPage() m.projectName = "" m.write.replayDisabled = true + m.clearPreparationText() if failure.Status == 401 { m.blocked = true m.actor, m.project = "", "" @@ -285,6 +343,9 @@ func (m *Model) writeText() string { w := m.write p := w.packet d := detailWriter{m: m} + if p.action == "prepare" { + return m.preparationText() + } switch w.mode { case writeKeyEntry: d.heading("Caller-owned replay key") @@ -367,6 +428,12 @@ func (m *Model) writeSummary() string { case outcomeNone: return "Not sent" case outcomeConfirmed: + if m.write.packet.action == "prepare" { + if m.write.preparation != nil && m.write.preparation.Status == "ready" { + return "Preparation ready (not a Submission)" + } + return "Preparation confirmed; not ready" + } if m.write.packet.action == "claim" { return "Claim confirmed" } diff --git a/cli/internal/tui/model.go b/cli/internal/tui/model.go index b0768a1b0..46d9ddc76 100644 --- a/cli/internal/tui/model.go +++ b/cli/internal/tui/model.go @@ -109,6 +109,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case writeResponse: m.receiveWrite(msg) return m, nil + case preparationHashResponse: + m.receivePreparationHash(msg) + return m, nil case tea.PasteMsg: m.paste(msg.Content) return m, nil @@ -175,16 +178,23 @@ func (m *Model) selectedRow() (row, bool) { func (m *Model) key(key tea.KeyPressMsg) tea.Cmd { k := key.String() - if k == "ctrl+c" || (!m.filtering && m.guideMode != guideDestination && m.write.mode != writeKeyEntry && k == "q") { + if k == "ctrl+c" || (!m.filtering && m.guideMode != guideDestination && m.write.mode != writeKeyEntry && !m.preparationEntry() && k == "q") { m.closed = true m.invalidate() m.stop() return tea.Quit } - if m.write.mode == writePending { + if m.write.mode == writePending || m.write.mode == writePrepareContext { return nil } - if m.write.mode == writeKeyEntry || m.write.mode == writeConfirm || m.write.mode == writeOutcome { + if m.write.mode == writePrepareHash { + if k == "esc" { + m.invalidate() + m.write = writeState{} + } + return nil + } + if m.write.mode == writeKeyEntry || m.write.mode == writeConfirm || m.write.mode == writeOutcome || m.preparationEntry() { if m.tooSmall() { return nil } @@ -278,8 +288,7 @@ func (m *Model) key(key tea.KeyPressMsg) tea.Cmd { return nil } if k == "a" && m.canOpenAction() { - m.openAction() - return nil + return m.openAction() } if !m.picker && (k == "1" || k == "2" || k == "3") { target := tab(int(k[0] - '1')) diff --git a/cli/internal/tui/model_test.go b/cli/internal/tui/model_test.go index 95b5098d2..0e90356f1 100644 --- a/cli/internal/tui/model_test.go +++ b/cli/internal/tui/model_test.go @@ -2,8 +2,10 @@ package tui import ( "context" + "encoding/json" "errors" "io" + "os" "strings" "testing" @@ -33,9 +35,18 @@ type fakeClient struct { download func(context.Context, string, api.TaskGuideDocument, io.Writer) error claim func(context.Context, string, string, *string) (api.Result[api.ClaimedTask], error) start func(context.Context, string, string, *string) (api.Result[api.MutationTask], error) + prepare func(context.Context, string, string, api.SubmissionPacket, *api.RevisionAcknowledgement, *os.File, string) (api.Result[api.PreparationReceipt], error) assignmentQuery, submissionQuery api.PageQuery } +func (f *fakeClient) PrepareSubmission(ctx context.Context, task, key string, packet api.SubmissionPacket, ack *api.RevisionAcknowledgement, file *os.File, digest string) (api.Result[api.PreparationReceipt], error) { + f.calls = append(f.calls, "prepare") + if f.prepare != nil { + return f.prepare(ctx, task, key, packet, ack, file, digest) + } + return api.Result[api.PreparationReceipt]{Value: api.PreparationReceipt{PutAttemptID: runID, AdmissionID: new(submissionID), Status: "ready", Feedback: []json.RawMessage{}}}, nil +} + func (f *fakeClient) Profile(ctx context.Context) (api.Result[api.Profile], error) { f.calls = append(f.calls, "profile") if f.profile != nil { diff --git a/cli/internal/tui/preparation.go b/cli/internal/tui/preparation.go new file mode 100644 index 000000000..bab261333 --- /dev/null +++ b/cli/internal/tui/preparation.go @@ -0,0 +1,376 @@ +package tui + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + tea "charm.land/bubbletea/v2" + "github.com/Flow-Research/workstream/cli/internal/api" +) + +func (m *Model) preparationEntry() bool { + return m.write.mode == writePreparePath || m.write.mode == writePrepareSummary || m.write.mode == writePrepareAttestation +} + +func printableASCII(value string) bool { + for _, b := range []byte(value) { + if b < 32 || b > 126 { + return false + } + } + return true +} + +func (m *Model) appendPreparationInput(value string) { + limit := 64 * 1024 + valid := printableASCII(value) + if m.write.mode == writePreparePath { + limit = 4096 + valid = utf8.ValidString(value) + for _, r := range value { + if !unicode.IsPrint(r) || unicode.Is(unicode.Cf, r) || r == '\u2028' || r == '\u2029' { + valid = false + } + } + } + if !valid || len(m.write.input)+len(value) > limit { + m.write.notice = "Input rejected: use printable text within the displayed limit (ASCII for packet fields); no control characters or multiline paste." + return + } + m.write.input += value + m.write.notice = "" +} + +func (m *Model) preparationKey(key tea.KeyPressMsg) tea.Cmd { + switch key.String() { + case "esc": + m.write = writeState{} + case "backspace": + if len(m.write.input) > 0 { + _, size := utf8.DecodeLastRuneInString(m.write.input) + m.write.input = m.write.input[:len(m.write.input)-size] + } + case "enter": + value := m.write.input + if m.write.mode == writePreparePath { + if value == "" { + m.write.notice = "Enter one regular ZIP path, at most 4096 UTF-8 bytes." + return nil + } + m.write.packet.path, m.write.mode = value, writePrepareSummary + } else { + if strings.TrimSpace(value) != value { + m.write.notice = "Header-edge whitespace is not supported; edit it explicitly. Text is never trimmed." + return nil + } + if m.write.mode == writePrepareSummary { + m.write.packet.summary, m.write.mode = value, writePrepareAttestation + } else { + m.write.packet.attestation, m.write.mode = value, writeKeyEntry + } + } + m.write.input, m.write.notice = "", "" + m.viewport.GotoTop() + default: + if key.Text != "" { + m.appendPreparationInput(key.Text) + } else { + return m.scroll(key.String()) + } + } + return nil +} + +type preparationHashResponse struct { + packet writePacket + generation uint64 + digest string + bytes int64 + err error +} + +// Opening and hashing belong to commands; render state never owns a descriptor. +func openPreparationZIP(path string) (*os.File, error) { + f, err := openPreparationSource(path) + if err != nil { + return nil, errors.New("ZIP unavailable") + } + info, err := f.Stat() + if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > api.MaxGuideDocumentBytes { + _ = f.Close() + return nil, errors.New("ZIP must be a nonempty regular file of at most 512MiB") + } + return f, nil +} + +func hashPreparationZIP(ctx context.Context, path string) (string, int64, error) { + if err := ctx.Err(); err != nil { + return "", 0, err + } + f, err := openPreparationZIP(path) + if err != nil { + return "", 0, err + } + defer f.Close() + initial, err := f.Stat() + if err != nil { + return "", 0, err + } + digest := sha256.New() + buf := make([]byte, 64*1024) + r := io.NewSectionReader(f, 0, initial.Size()+1) + var count int64 + for { + if err := ctx.Err(); err != nil { + return "", 0, err + } + n, err := r.Read(buf) + count += int64(n) + if count > initial.Size() { + return "", 0, errors.New("ZIP changed") + } + _, _ = digest.Write(buf[:n]) + if err == io.EOF { + break + } + if err != nil { + return "", 0, err + } + } + final, err := f.Stat() + if err != nil || count != initial.Size() || final.Size() != initial.Size() || !final.ModTime().Equal(initial.ModTime()) { + return "", 0, errors.New("ZIP changed") + } + return "sha256:" + hex.EncodeToString(digest.Sum(nil)), count, ctx.Err() +} + +func (m *Model) beginPreparationHash() tea.Cmd { + m.invalidate() + m.write.mode = writePrepareHash + p, generation := m.write.packet, m.generation + ctx, cancel := context.WithCancel(m.ctx) + m.cancel = cancel + return func() tea.Msg { + digest, size, err := hashPreparationZIP(ctx, p.path) + return preparationHashResponse{packet: p, generation: generation, digest: digest, bytes: size, err: err} + } +} + +func (m *Model) receivePreparationHash(msg preparationHashResponse) { + if m.write.mode != writePrepareHash || msg.generation != m.generation || msg.packet != m.write.packet { + return + } + if m.cancel != nil { + m.cancel() + m.cancel = nil + } + if msg.err != nil { + m.write.mode, m.write.input = writePreparePath, m.write.packet.path + m.write.notice = "Not sent: ZIP unavailable, changed or cancelled. Choose a nonempty regular file of at most 512MiB." + return + } + m.write.packet.sha256, m.write.packet.bytes = msg.digest, msg.bytes + m.write.mode = writeConfirm + m.viewport.GotoTop() +} + +func (p writePacket) submissionPacket() api.SubmissionPacket { + packet := api.SubmissionPacket{Assignment: p.assignment} + if p.summary != "" { + value := p.summary + packet.Summary = &value + } + if p.attestation != "" { + value := p.attestation + packet.Attestation = &value + } + return packet +} + +func prepareWrite(ctx context.Context, client Client, p writePacket, generation, sequence uint64) writeResponse { + result := writeResponse{packet: p, generation: generation, sequence: sequence} + if ctx.Err() != nil { + result.notSent = true + return result + } + f, err := openPreparationZIP(p.path) + if err != nil { + result.notSent = true + return result + } + defer f.Close() + receipt, err := client.PrepareSubmission(ctx, p.task, p.key, p.submissionPacket(), nil, f, p.sha256) + result.value, result.err = receipt.Value, err + var failure *api.Failure + // Ordinary errors are validation/hash failures before request dispatch. The + // shared client marks transport and malformed responses OutcomeUnknown. + result.notSent = err != nil && !errors.As(err, &failure) + return result +} + +func blockedPreparationFeedback(raw json.RawMessage) []string { + var details struct { + Results []json.RawMessage `json:"results"` + } + if json.Unmarshal(raw, &details) != nil { + return nil + } + return preparationFeedbackLines(details.Results) +} + +func preparationFeedbackLines(results []json.RawMessage) []string { + lines := api.IntakeFeedbackLines(results) + for _, raw := range results { + var check struct { + Source, Status string + ID string `json:"check_id"` + Failure string `json:"failure_code"` + } + if json.Unmarshal(raw, &check) == nil && check.Source == "workstream_default" && check.Status == "failed" { + lines = append(lines, "failed "+check.ID+": "+check.Failure) + } + } + return lines +} + +func (m *Model) clearPreparationText() { + if m.write.packet.action != "prepare" { + return + } + p := &m.write.packet + p.path, p.summary, p.attestation, p.sha256, p.bytes = "", "", "", "", 0 + m.write.input, m.write.keyInput = "", "" + m.write.preparation, m.write.feedback = nil, nil +} + +func (m *Model) preparationNeedsNewAttempt() bool { + if m.write.packet.action != "prepare" || m.write.outcome != outcomeConfirmed || m.write.preparation == nil || m.write.replayDisabled { + return false + } + switch m.write.preparation.Status { + case "integrity_mismatch", "missing", "conflict", "stale": + return true + } + return false +} + +func packetText(value string) string { + if value == "" { + return "(omitted)" + } + return strconv.Quote(value) +} + +func (m *Model) preparationText() string { + w, p := m.write, m.write.packet + d := detailWriter{m: m} + switch w.mode { + case writePrepareContext: + d.heading("Checking initial preparation context") + d.note("Fresh WorkContext must confirm your in-progress assignment without revision context. No POST was sent.") + case writePreparePath, writePrepareSummary, writePrepareAttestation: + label := "ZIP path" + if w.mode == writePrepareSummary { + label = "Summary" + } + if w.mode == writePrepareAttestation { + label = "Attestation" + } + d.heading(label) + d.line(label, w.input) + if w.mode == writePreparePath { + d.note("One readable, nonempty regular ZIP, at most 512MiB; path limit 4096 UTF-8 bytes, no control characters. No expansion or file execution.") + } else { + d.note("Optional exact printable ASCII, at most 64KiB; blank is omitted. No header-edge whitespace. Text is never trimmed.") + } + d.note("Enter continues; Esc cancels. q is text; Ctrl+C quits. No POST was sent.") + case writeKeyEntry: + d.heading("Caller-owned replay key") + d.note("Save a UUID and exact ZIP/packet outside this session. No key or recovery inputs are persisted here.") + d.line("UUID", w.keyInput) + d.note("Enter hashes the ZIP for review; Esc cancels. No POST was sent.") + case writePrepareHash: + d.heading("Hashing ZIP") + d.line("ZIP path", strconv.Quote(p.path)) + d.note("Local inspection only; Esc cancels. No POST was sent.") + case writeConfirm, writePending, writeOutcome: + if w.mode == writeConfirm { + d.heading("Confirm prepare") + } + if w.mode == writePending { + d.heading("Sending prepare") + } + if w.mode == writeOutcome { + d.heading(m.writeSummary()) + } + d.line("Actor", p.actor) + d.line("Project", p.project) + d.line("Task", p.task) + d.line("Assignment", p.assignment) + if !w.replayDisabled { + d.line("ZIP path", strconv.Quote(p.path)) + d.line("Bytes", strconv.FormatInt(p.bytes, 10)) + d.line("SHA-256", p.sha256) + d.line("Summary", packetText(p.summary)) + d.line("Attestation", packetText(p.attestation)) + d.line("Predecessor", "(omitted; initial preparation)") + } + d.line("Replay key", p.key) + if w.mode == writeConfirm { + if w.replay { + d.note("Exact unchanged replay only. Earlier outcome remains until a validated receipt; no automatic retry.") + } + d.note("Save this key and exact inputs outside the session. The workbench cannot verify your copy.") + if w.acknowledged { + d.note("Save warning acknowledged. Press y to send one POST; Esc cancels.") + } else { + d.note("Press s to acknowledge saving, then y to send one POST. Esc cancels.") + } + } + if w.mode == writePending { + d.note("One POST is pending. Quit cancels the client request, not possible server effects.") + } + if w.mode == writeOutcome { + d.note(m.clean(w.notice)) + if w.preparation != nil { + d.line("Preparation status", w.preparation.Status) + d.line("Put attempt", w.preparation.PutAttemptID) + if w.preparation.AdmissionID != nil { + d.line("Ready admission", *w.preparation.AdmissionID) + d.note("Inert CLI handoff: workstream task submit; supply this Task, Assignment, Ready admission and exact Summary/Attestation with --assignment, --admission, --summary and --attestation (omit blank optional fields). No command is executed here.") + } else { + d.note("Not ready: no admission is available for submission.") + if m.preparationNeedsNewAttempt() { + d.note("This terminal preparation cannot become ready by changing its replay packet. Esc returns; refresh the task, then a starts a new deliberate preparation with a new saved UUID. Exact replay only observes the prior attempt.") + } else { + d.note("Exact replay observes current server state; it does not guarantee storage recovery. If the state persists, use operator support rather than changing the replay packet.") + } + } + } + for _, line := range w.feedback { + d.line("Intake feedback", line) + } + if m.canReplay() { + d.note("p reviews explicit same-packet replay; no automatic retry.") + } + if w.replayDisabled { + d.note("Protected preparation text cleared; in-session replay disabled. Recover only with externally saved exact inputs after authority is restored.") + } + d.note("r separately refreshes the read; Esc returns. A read cannot prove rollback.") + } + } + d.note("No immutable Submission was requested. Preparation is intake, not acceptance.") + if w.notice != "" && w.mode != writeOutcome { + d.note(m.clean(w.notice)) + } + return d.String() +} diff --git a/cli/internal/tui/preparation_source_unix.go b/cli/internal/tui/preparation_source_unix.go new file mode 100644 index 000000000..65928ea11 --- /dev/null +++ b/cli/internal/tui/preparation_source_unix.go @@ -0,0 +1,14 @@ +//go:build unix + +package tui + +import ( + "os" + "syscall" +) + +func openPreparationSource(path string) (*os.File, error) { + // A path replaced by a FIFO must not block before the common regular-file + // check. O_NONBLOCK does not change ordinary regular-file reads. + return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) +} diff --git a/cli/internal/tui/preparation_source_unix_test.go b/cli/internal/tui/preparation_source_unix_test.go new file mode 100644 index 000000000..f70cd6440 --- /dev/null +++ b/cli/internal/tui/preparation_source_unix_test.go @@ -0,0 +1,28 @@ +//go:build unix + +package tui + +import ( + "context" + "path/filepath" + "syscall" + "testing" + "time" +) + +func TestPreparationFIFOIsRejectedWithoutWaitingForWriter(t *testing.T) { + path := filepath.Join(t.TempDir(), "bundle.zip") + if err := syscall.Mkfifo(path, 0600); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { _, _, err := hashPreparationZIP(context.Background(), path); done <- err }() + select { + case err := <-done: + if err == nil { + t.Fatal("FIFO accepted as regular ZIP") + } + case <-time.After(time.Second): + t.Fatal("FIFO open blocked local cancellation") + } +} diff --git a/cli/internal/tui/preparation_source_windows.go b/cli/internal/tui/preparation_source_windows.go new file mode 100644 index 000000000..7b1e9e423 --- /dev/null +++ b/cli/internal/tui/preparation_source_windows.go @@ -0,0 +1,25 @@ +package tui + +import ( + "errors" + "os" + "path/filepath" + "strings" +) + +func openPreparationSource(path string) (*os.File, error) { + // Accept ordinary filesystem paths, not Win32 device namespaces or named + // pipe shares. Reject reserved device basenames before opening a handle. + normalized := strings.ReplaceAll(path, "/", `\`) + if strings.HasPrefix(normalized, `\\.\`) || strings.HasPrefix(normalized, `\\?\`) || + strings.HasPrefix(normalized, `\??\`) || !filepath.IsLocal(filepath.Base(normalized)) { + return nil, errors.New("ZIP requires an ordinary filesystem path") + } + if strings.HasPrefix(normalized, `\\`) { + parts := strings.Split(strings.TrimPrefix(normalized, `\\`), `\`) + if len(parts) > 1 && strings.EqualFold(parts[1], "pipe") { + return nil, errors.New("ZIP requires an ordinary filesystem path") + } + } + return os.Open(path) +} diff --git a/cli/internal/tui/preparation_source_windows_test.go b/cli/internal/tui/preparation_source_windows_test.go new file mode 100644 index 000000000..e9f8c4093 --- /dev/null +++ b/cli/internal/tui/preparation_source_windows_test.go @@ -0,0 +1,15 @@ +package tui + +import "testing" + +func TestPreparationWindowsRejectsDeviceAndPipePathsBeforeOpen(t *testing.T) { + for _, path := range []string{`NUL`, `CONIN$`, `C:\data\CON`, `\\.\pipe\workstream`, `\\?\pipe\workstream`, `\\server\pipe\workstream`} { + f, err := openPreparationSource(path) + if err == nil || f != nil { + if f != nil { + _ = f.Close() + } + t.Fatalf("device or pipe path opened: %q", path) + } + } +} diff --git a/cli/internal/tui/preparation_test.go b/cli/internal/tui/preparation_test.go new file mode 100644 index 000000000..10bed1b0c --- /dev/null +++ b/cli/internal/tui/preparation_test.go @@ -0,0 +1,351 @@ +package tui + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/Flow-Research/workstream/cli/internal/api" +) + +const preparationAssignmentID = "90000000-0000-7000-8000-000000000001" + +func preparationDetail(t *testing.T, f *fakeClient) *Model { + t.Helper() + f.task = func(context.Context, string) (api.Result[api.ContributorTaskDetail], error) { + v := taskDetail() + v.Status = "in_progress" + return api.Result[api.ContributorTaskDetail]{Value: v}, nil + } + if f.context == nil { + f.context = func(context.Context, string) (api.Result[api.WorkContext], error) { + v, _ := f.task(context.Background(), taskID) + return api.Result[api.WorkContext]{Value: api.WorkContext{ + Task: v.Value, Project: api.ContextProject{ID: projectID}, Guide: api.ContextGuide{ProjectID: projectID}, + ContributionPolicyVersionID: policyID, Lifecycle: api.ContextLifecycle{AssignedToCurrentActor: true}, + }}, nil + } + } + m := boot(t, f) + press(t, m, "enter") + press(t, m, "2") + // Keep actor and assignment distinct so accidental actor-as-assignment + // substitution cannot satisfy the preparation wire assertion. + m.rows[0].id = preparationAssignmentID + press(t, m, "enter") + if !m.canOpenAction() || m.actionName() != "Prepare" { + t.Fatal("initial Mine task did not offer preparation") + } + return m +} + +func preparationFile(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "bundle.zip") + if err := os.WriteFile(path, []byte("ZIP bytes for testing"), 0600); err != nil { + t.Fatal(err) + } + return path +} + +func confirmPreparation(t *testing.T, m *Model, path, summary, attestation string) { + t.Helper() + press(t, m, "a") + for _, text := range []string{path, summary, attestation, replayKey} { + if text != "" { + m.Update(tea.PasteMsg{Content: text}) + } + press(t, m, "enter") + } + if m.write.mode != writeConfirm || m.write.packet.sha256 == "" { + t.Fatal("preparation did not reach exact hashed review", m.body()) + } +} + +func TestPreparationStagesExactPacketAndOnePOST(t *testing.T) { + f := &fakeClient{} + m := preparationDetail(t, f) + path := preparationFile(t) + var retained *os.File + f.prepare = func(_ context.Context, task, key string, packet api.SubmissionPacket, ack *api.RevisionAcknowledgement, source *os.File, digest string) (api.Result[api.PreparationReceipt], error) { + retained = source + data, _ := io.ReadAll(source) + if task != taskID || key != replayKey || packet.Assignment != preparationAssignmentID || packet.Predecessor != nil || ack != nil || + packet.Summary == nil || *packet.Summary != "literal q summary" || packet.Attestation == nil || *packet.Attestation != "Exact q attestation" || + string(data) != "ZIP bytes for testing" || digest != m.write.packet.sha256 { + t.Fatal("packet or reviewed fingerprint changed", packet, digest) + } + return api.Result[api.PreparationReceipt]{Value: api.PreparationReceipt{PutAttemptID: runID, AdmissionID: new(submissionID), Status: "ready", Feedback: []json.RawMessage{json.RawMessage(`{"source":"workstream_default","status":"warning","warning_code":"archive_warning"}`)}}}, nil + } + confirmPreparation(t, m, path, "literal q summary", "Exact q attestation") + if strings.Count(strings.Join(f.calls, ","), "context") != 1 { + t.Fatal("preparation did not freshly read context") + } + before := len(f.calls) + for _, k := range []string{"y", "enter", "a", "1", "2", "3", "tab", "r"} { + press(t, m, k) + if len(f.calls) != before { + t.Fatal("implicit POST from", k) + } + } + m.Update(tea.PasteMsg{Content: "sy"}) + if m.write.acknowledged { + t.Fatal("paste acknowledged save") + } + press(t, m, "s") + _, cmd := m.Update(keyMessage("y")) + for _, k := range []string{"y", "p", "r", "esc", "2", "tab"} { + if _, duplicate := m.Update(keyMessage(k)); duplicate != nil { + t.Fatal("pending duplicate", k) + } + } + apply(t, m, cmd) + if m.write.outcome != outcomeConfirmed || m.write.preparation == nil || strings.Count(strings.Join(f.calls, ","), "prepare") != 1 || + !strings.Contains(m.body(), "Preparation ready") || !strings.Contains(m.body(), "archive_warning") || !strings.Contains(m.body(), "No immutable Submission was requested") { + t.Fatal("validated preparation receipt missing", m.body()) + } + if _, err := retained.Stat(); err == nil { + t.Fatal("command retained an open file") + } +} + +func TestPreparationRejectsUnassignedAndRevisionContext(t *testing.T) { + for _, revision := range []bool{false, true} { + f := &fakeClient{context: func(context.Context, string) (api.Result[api.WorkContext], error) { + v := taskDetail() + v.Status = "in_progress" + value := api.WorkContext{Task: v, Project: api.ContextProject{ID: projectID}, Guide: api.ContextGuide{ProjectID: projectID}, ContributionPolicyVersionID: policyID} + if revision { + value.Lifecycle.AssignedToCurrentActor = true + value.RevisionContext = &api.RevisionContext{} + } + return api.Result[api.WorkContext]{Value: value}, nil + }} + m := preparationDetail(t, f) + press(t, m, "a") + if m.write.mode != writeIdle || !strings.Contains(m.failure, "without revision context") || strings.Contains(strings.Join(f.calls, ","), "prepare") { + t.Fatal("unsafe initial context entered form", m.failure) + } + } +} + +func TestPreparationTextPasteAndCancellationBoundaries(t *testing.T) { + f := &fakeClient{} + m := preparationDetail(t, f) + press(t, m, "a") + for _, mode := range []writeMode{writePreparePath, writePrepareSummary, writePrepareAttestation} { + m.write.mode, m.write.input = mode, "" + press(t, m, "q") + m.Update(tea.PasteMsg{Content: " literal sy"}) + if m.closed || m.write.input != "q literal sy" { + t.Fatal("field shortcuts executed", mode) + } + for _, hostile := range []string{"\ny", "\x1b[31m", "é", "\x00", strings.Repeat("a", 64*1024+1)} { + if mode == writePreparePath && hostile == "é" { + continue + } + m.Update(tea.PasteMsg{Content: hostile}) + if m.write.input != "q literal sy" || m.write.notice == "" { + t.Fatal("hostile/oversize paste was accepted") + } + } + } + m.write.mode, m.write.input = writePrepareSummary, " edge " + press(t, m, "enter") + if m.write.mode != writePrepareSummary || m.write.input != " edge " { + t.Fatal("text silently trimmed") + } + press(t, m, "esc") + path := preparationFile(t) + press(t, m, "a") + for _, value := range []string{path, "", ""} { + if value != "" { + m.Update(tea.PasteMsg{Content: value}) + } + press(t, m, "enter") + } + press(t, m, "q") + if m.closed || m.write.keyInput != "q" { + t.Fatal("q quit key entry") + } + m.write.keyInput = replayKey + _, hash := m.Update(keyMessage("enter")) + if hash == nil || m.write.mode != writePrepareHash { + t.Fatal("hash was not asynchronous") + } + press(t, m, "esc") + apply(t, m, hash) + if m.write.mode != writeIdle || strings.Contains(strings.Join(f.calls, ","), "prepare") { + t.Fatal("cancelled hash restored confirmation") + } + confirmPreparation(t, m, path, "", "") + press(t, m, "s") + _, send := m.Update(keyMessage("y")) + m.cancel() + apply(t, m, send) + if m.write.outcome != outcomeNone || strings.Contains(strings.Join(f.calls, ","), "prepare") || !strings.Contains(m.write.notice, "No POST") { + t.Fatal("cancelled-before-dispatch reached client") + } +} + +func TestPreparationUnicodePathPreservesBytesAndRuneBackspace(t *testing.T) { + f := &fakeClient{} + m := preparationDetail(t, f) + path := filepath.Join(t.TempDir(), "贡献-e\u0301.zip") + if err := os.WriteFile(path, []byte("ZIP bytes"), 0600); err != nil { + t.Fatal(err) + } + press(t, m, "a") + m.Update(tea.PasteMsg{Content: "贡献"}) + m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace}) + if m.write.input != "贡" { + t.Fatal("path backspace split a UTF-8 rune", m.write.input) + } + press(t, m, "esc") + confirmPreparation(t, m, path, "", "") + if m.write.packet.path != path || !strings.Contains(m.body(), "贡献-e\u0301.zip") { + t.Fatal("Unicode path was normalized or rejected", m.body()) + } +} + +func TestPreparationUnknownReplayKeepsOriginalFingerprintAndUncertainty(t *testing.T) { + f := &fakeClient{} + m := preparationDetail(t, f) + path := preparationFile(t) + confirmPreparation(t, m, path, "original", "") + original := m.write.packet + f.prepare = func(context.Context, string, string, api.SubmissionPacket, *api.RevisionAcknowledgement, *os.File, string) (api.Result[api.PreparationReceipt], error) { + return api.Result[api.PreparationReceipt]{}, &api.Failure{Code: "request_failed", OutcomeUnknown: true} + } + press(t, m, "s") + press(t, m, "y") + press(t, m, "esc") + press(t, m, "a") + if m.write.packet != original || m.write.mode != writeIdle || m.canOpenAction() { + t.Fatal("unknown result allowed replacement") + } + f.prepare = func(_ context.Context, _, _ string, _ api.SubmissionPacket, _ *api.RevisionAcknowledgement, _ *os.File, digest string) (api.Result[api.PreparationReceipt], error) { + if digest != original.sha256 { + t.Fatal("replay rehashed/rebound changed source") + } + return api.Result[api.PreparationReceipt]{}, errors.New("local changed-file guard") + } + press(t, m, "p") + press(t, m, "s") + press(t, m, "y") + if m.write.outcome != outcomeUnknown || m.write.packet != original || !strings.Contains(m.write.notice, "Earlier outcome remains unknown") { + t.Fatal("local refusal erased uncertainty", m.write.notice) + } +} + +func TestPreparationFailedIntakeAllowsCorrectedOperationAndNonreadyIsNotReady(t *testing.T) { + f := &fakeClient{prepare: func(context.Context, string, string, api.SubmissionPacket, *api.RevisionAcknowledgement, *os.File, string) (api.Result[api.PreparationReceipt], error) { + return api.Result[api.PreparationReceipt]{}, &api.Failure{Status: 422, Code: "pre_submission_checker_failed", Details: json.RawMessage(`{"status":"failed","eligible_to_submit":false,"results":[{"source":"workstream_default","status":"failed","check_id":"manifest","failure_code":"manifest_missing","finding_count":1}]}`)} + }} + m := preparationDetail(t, f) + confirmPreparation(t, m, preparationFile(t), "", "") + press(t, m, "s") + press(t, m, "y") + if m.write.outcome != outcomeDenied || !strings.Contains(m.body(), "manifest_missing") || m.canReplay() { + t.Fatal("failed intake was not canonical blocking feedback", m.body()) + } + press(t, m, "esc") + press(t, m, "a") + if m.write.mode != writePreparePath || m.write.outcome != outcomeNone { + t.Fatal("known failed intake blocked corrected operation") + } + press(t, m, "esc") + f.prepare = func(context.Context, string, string, api.SubmissionPacket, *api.RevisionAcknowledgement, *os.File, string) (api.Result[api.PreparationReceipt], error) { + return api.Result[api.PreparationReceipt]{Value: api.PreparationReceipt{PutAttemptID: runID, Status: "stored_pending_verification", Feedback: []json.RawMessage{}}}, nil + } + confirmPreparation(t, m, preparationFile(t), "", "") + press(t, m, "s") + press(t, m, "y") + if !strings.Contains(m.body(), "Preparation confirmed; not ready") || strings.Contains(m.body(), "Ready admission:") || !m.canReplay() { + t.Fatal("nonready receipt exposed admission", m.body()) + } +} + +func TestPreparationTerminalReceiptAllowsNewOperationButNotUnknownReplacement(t *testing.T) { + for _, status := range []string{"integrity_mismatch", "missing", "conflict", "stale"} { + t.Run(status, func(t *testing.T) { + f := &fakeClient{prepare: func(context.Context, string, string, api.SubmissionPacket, *api.RevisionAcknowledgement, *os.File, string) (api.Result[api.PreparationReceipt], error) { + return api.Result[api.PreparationReceipt]{Value: api.PreparationReceipt{PutAttemptID: runID, Status: status, Feedback: []json.RawMessage{}}}, nil + }} + m := preparationDetail(t, f) + confirmPreparation(t, m, preparationFile(t), "", "") + press(t, m, "s") + press(t, m, "y") + if !strings.Contains(m.body(), "new saved UUID") { + t.Fatal("terminal receipt lacked recovery guidance") + } + press(t, m, "esc") + if !m.canOpenAction() { + t.Fatal("terminal receipt trapped user in observation-only replay") + } + m.write.outcome = outcomeUnknown + if m.canOpenAction() || m.preparationNeedsNewAttempt() { + t.Fatal("terminal status erased later uncertainty") + } + m.write.outcome = outcomeConfirmed + press(t, m, "a") + if m.write.mode != writePreparePath || m.write.packet.key != "" { + t.Fatal("new operation did not require fresh deliberate inputs") + } + }) + } +} + +func TestPreparationAuthFailureClearsProtectedTextAndRejectsLateResponse(t *testing.T) { + f := &fakeClient{} + m := preparationDetail(t, f) + confirmPreparation(t, m, preparationFile(t), "private summary", "private attestation") + press(t, m, "s") + _, send := m.Update(keyMessage("y")) + late := send().(writeResponse) + denial := late + denial.value, denial.err = nil, &api.Failure{Status: 403, Code: "permission_denied"} + m.Update(denial) + m.Update(late) + if m.write.preparation != nil || m.write.packet.path != "" || m.write.packet.summary != "" || m.write.packet.sha256 != "" || m.canReplay() || + strings.Contains(m.body(), "private summary") || !strings.Contains(m.body(), "Protected preparation text cleared") { + t.Fatal("late receipt restored protected preparation", m.body()) + } + // A current delayed read denial after opening replay must expire confirmation + // just as it does for Claim/Start; it cannot leave a y-dispatch path behind. + m = preparationDetail(t, &fakeClient{}) + confirmPreparation(t, m, preparationFile(t), "private summary", "") + m.failed(&api.Failure{Status: 401, Code: "session_unavailable"}, false) + press(t, m, "s") + press(t, m, "y") + if !m.blocked || m.write.mode == writePending || m.write.packet.summary != "" { + t.Fatal("delayed 401 left preparation writable") + } +} + +func TestPreparationConfirmationResponsiveScrollableExactReview(t *testing.T) { + for _, size := range [][2]int{{120, 40}, {80, 24}, {40, 10}} { + m := preparationDetail(t, &fakeClient{}) + confirmPreparation(t, m, preparationFile(t), strings.Repeat("long-summary-", 30), "literal q") + m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) + var seen strings.Builder + for i := 0; i < 140; i++ { + seen.WriteString(m.View().Content) + m.scroll("down") + } + for _, want := range []string{"Confirm prepare", "Actor:", "Assignment:", "SHA-256:", "Summary:", "Attestation:", "Replay key:", "s saved", "y send", "q quit"} { + if !strings.Contains(seen.String(), want) { + t.Fatalf("%dx%d inaccessible %q", size[0], size[1], want) + } + } + if m.write.acknowledged || m.write.outcome != outcomeNone { + t.Fatal("scroll/resize dispatched") + } + } +} diff --git a/cli/internal/tui/requests.go b/cli/internal/tui/requests.go index 26abbb53a..a89606c0d 100644 --- a/cli/internal/tui/requests.go +++ b/cli/internal/tui/requests.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "os" "strings" "sync" "time" @@ -29,6 +30,7 @@ type Client interface { DownloadGuideDocument(context.Context, string, api.TaskGuideDocument, io.Writer) error ClaimTask(context.Context, string, string, *string) (api.Result[api.ClaimedTask], error) StartTask(context.Context, string, string, *string) (api.Result[api.MutationTask], error) + PrepareSubmission(context.Context, string, string, api.SubmissionPacket, *api.RevisionAcknowledgement, *os.File, string) (api.Result[api.PreparationReceipt], error) } var _ Client = (*api.Client)(nil) @@ -345,6 +347,17 @@ func (m *Model) receive(msg response) tea.Cmd { return bad() } m.context = &value + if m.write.mode == writePrepareContext { + p := m.write.packet + if !sameID(p.actor, m.actor) || !sameID(p.project, r.project) || !sameID(p.task, r.task) || + !sameID(p.assignment, r.resource) || value.Task.Status != "in_progress" || + !value.Lifecycle.AssignedToCurrentActor || value.RevisionContext != nil { + m.write = writeState{} + m.failure = "Unavailable: initial preparation requires your in-progress assignment without revision context." + return nil + } + m.write.mode = writePreparePath + } m.guideSelected = max(0, min(m.guideSelected, len(value.GuideDocuments)-1)) case api.SubmissionRequirements: if r.kind != intakeRead || !sameID(value.TaskID, r.task) || !sameID(value.ProjectID, r.project) || @@ -378,7 +391,7 @@ func (m *Model) failed(err error, actorRead bool) { m.invalidate() m.clearPage() m.projectName = "" - if m.write.mode == writeConfirm || m.write.mode == writeKeyEntry { + if m.write.mode == writeConfirm || m.write.mode == writeKeyEntry || m.preparationEntry() || m.write.mode == writePrepareContext { if m.write.outcome == outcomeNone { m.write = writeState{} } else { @@ -389,6 +402,7 @@ func (m *Model) failed(err error, actorRead bool) { if errors.As(err, &failure) { if failure.Status == 401 || failure.Status == 403 || failure.Status == 404 { m.write.replayDisabled = true + m.clearPreparationText() } if failure.Status == 401 || (actorRead && failure.Status == 403) { m.blocked = true diff --git a/cli/internal/tui/view.go b/cli/internal/tui/view.go index 74fbd2123..e9b5667bc 100644 --- a/cli/internal/tui/view.go +++ b/cli/internal/tui/view.go @@ -99,6 +99,15 @@ func (m *Model) renderFooter() string { } func (m *Model) footerText() string { + if m.preparationEntry() { + return "Enter next Esc cancel Ctrl+C quit" + } + if m.write.mode == writePrepareContext { + return "Checking initial preparation context... q quit" + } + if m.write.mode == writePrepareHash { + return "Hashing ZIP... Esc cancel q quit" + } if m.write.mode == writeKeyEntry { return "Enter review Esc cancel Ctrl+C quit" } @@ -303,7 +312,7 @@ func (m *Model) View() tea.View { } if m.tooSmall() { guidance := "Resize to at least 40x10. q quit" - if m.filtering || m.guideMode == guideDestination || m.write.mode == writeKeyEntry { + if m.filtering || m.guideMode == guideDestination || m.write.mode == writeKeyEntry || m.preparationEntry() { guidance = "Resize to at least 40x10. Ctrl+C quit" } v := tea.NewView(lipgloss.Wrap(guidance, max(1, m.width), "")) @@ -373,7 +382,7 @@ func (m *Model) body() string { return m.writeText() } if m.help { - return "KEYBOARD HELP\n\nNavigate\nArrows select a row/document or scroll detail. 1 Ready, 2 Mine, 3 Submissions select collections. Tab advances and Shift+Tab reverses collections or visible detail sections. Esc returns.\n\nInspect\nEnter reads one exact selected resource. r explicitly refreshes; g opens a fresh project picker. / filters only the loaded page.\n\nActions\na on an inspected Ready task or claimed Mine Overview opens Claim or Start. Enter reviews your externally saved UUID; s acknowledges the save warning and y sends one POST. p explicitly replays the exact prior packet when offered. No automatic retry or chained start.\n\nPages\nn requests exactly one next list or findings page. No polling or project-wide count is implied.\n\nGuide downloads\nd enters a destination for the selected guide original; Enter reviews and y confirms. Esc cancels without downloading.\n\nExit\nc shows inert CLI arguments. q or Ctrl+C quits. ? or Esc closes help.\n\nChecker evidence is not acceptance. A read is not a reservation or authority." + return "KEYBOARD HELP\n\nNavigate\nArrows select a row/document or scroll detail. 1 Ready, 2 Mine, 3 Submissions select collections. Tab advances and Shift+Tab reverses collections or visible detail sections. Esc returns.\n\nInspect\nEnter reads one exact selected resource. r explicitly refreshes; g opens a fresh project picker. / filters only the loaded page.\n\nActions\na opens Claim on inspected Ready, Start on claimed Mine Overview, or initial ZIP Prepare on in-progress Mine Overview. Prepare freshly checks WorkContext, then collects ZIP path, exact optional Summary and Attestation, and your externally saved UUID. Enter advances each field and hashes the ZIP for exact review. s acknowledges the save warning and y sends one POST. p explicitly replays the exact prior packet when offered. No automatic retry, chained start or immutable Submission creation.\n\nPages\nn requests exactly one next list or findings page. No polling or project-wide count is implied.\n\nGuide downloads\nd enters a destination for the selected guide original; Enter reviews and y confirms. Esc cancels without downloading.\n\nExit\nc shows inert CLI arguments. q quits outside text fields; Ctrl+C always quits. ? or Esc closes help.\n\nChecker evidence is not acceptance. A read is not a reservation or authority." } if m.handoff { return m.handoffText() diff --git a/cli/tests/integration/test_tui_http.py b/cli/tests/integration/test_tui_http.py index 2ff301776..a37a832e4 100644 --- a/cli/tests/integration/test_tui_http.py +++ b/cli/tests/integration/test_tui_http.py @@ -2,16 +2,23 @@ from copy import deepcopy import hashlib +from io import BytesIO import os import re import subprocess import time from threading import Event from urllib.parse import parse_qs, urlsplit +from zipfile import ZipFile import pytest -from test_contributor_reads_http import ASSIGNMENT_ROW, PROJECT_ROW, SUBMISSION_ROW +from test_contributor_reads_http import ( + ASSIGNMENT as MINE_ASSIGNMENT, + ASSIGNMENT_ROW, + PROJECT_ROW, + SUBMISSION_ROW, +) from test_contributor_task_http import CONTRIBUTOR, READY from test_contributor_task_mutations import ( KEY as CLAIM_KEY, @@ -21,6 +28,7 @@ from test_http_boundary import ACTOR, PROFILE, PROJECT, TOKEN from test_submission_http import CHECKER as SUBMISSION_CHECKER from test_submission_http import HISTORY, RUN, SUBMISSION +from test_submission_http import ADMISSION, PUT, preparation from test_task_context_http import CONTEXT, REQUIREMENTS from test_task_http_boundary import TASK from tui_terminal_support import Terminal, environment, executable, socket_peer @@ -144,6 +152,76 @@ def __call__(self, method, target, authorization): return self.replies.pop(0) +class PreparationWrites(Reads): + """Only the initial preparation route is writable; no Submission route exists.""" + + def __init__(self, *replies): + super().__init__() + self.task["status"] = "in_progress" + self.mine = page( + ASSIGNMENT_ROW + | { + "title": "Assigned terminal task", + "task_status": "in_progress", + "latest_submission": None, + "revision": None, + "next_action": "prepare_submission", + } + ) + self.context["lifecycle"] = { + "assigned_to_current_actor": True, + "next_actions": [], + } + self.replies = list(replies) + + def __call__(self, method, target, authorization): + if method == "GET": + return super().__call__(method, target, authorization) + assert method == "POST" + assert authorization == "Bearer " + TOKEN + assert target == TASK_PATH + "/submission-bundle-preparations" + assert self.replies, "unexpected duplicate preparation POST" + return self.replies.pop(0) + + +def preparation_zip(path): + output = BytesIO() + with ZipFile(output, "w") as archive: + archive.writestr("answer.md", "Exact contributor evidence.\n") + path.write_bytes(output.getvalue()) + return output.getvalue() + + +def preparation_detail(terminal): + open_ready(terminal) + terminal.send("2") + terminal.wait("Assigned terminal task") + terminal.send("\r") + terminal.wait(SECRET) + + +def preparation_confirm( + terminal, path, key=CLAIM_KEY, summary="q", attestation="Exact attestation" +): + terminal.send("a") + for label, value in ( + ("ZIP path", str(path)), + ("Summary", summary), + ("Attestation", attestation), + ("Caller-owned replay key", key), + ): + terminal.wait(label) + if value: + terminal.send(value) + terminal.send("\r") + terminal.wait("Confirm prepare") + + +def preparation_dispatch(terminal): + terminal.send("s") + terminal.send("y") + + def action_detail(terminal, action): open_ready(terminal) if action == "start": @@ -242,6 +320,238 @@ def reveal_detail(terminal, text): ) +def test_initial_zip_preparation_sends_exact_packet_then_explicit_ready_replay( + tmp_path, +): + path = tmp_path / "work.zip" + archive = preparation_zip(path) + peer = PreparationWrites((202, preparation()), (202, preparation("ready"))) + posts = [] + with ( + socket_peer( + peer, + capture_post=lambda route, headers, body: posts.append( + (route, headers, body) + ), + ) as (origin, requests), + Terminal(origin, TOKEN, "--plain", rows=50) as terminal, + ): + preparation_detail(terminal) + preparation_confirm(terminal, path) + for identity in (TASK, PROJECT, ACTOR, MINE_ASSIGNMENT, CLAIM_KEY): + assert identity in terminal.screen + assert "sha256:" + hashlib.sha256(archive).hexdigest() in terminal.screen + assert str(len(archive)) in terminal.screen + # Neither completing the staged form nor an unacknowledged y sends. + terminal.send("y") + terminal.pump(0.2) + assert posts == [] + terminal.resize(30, 8) + terminal.wait("Resize") + terminal.send("sy") + terminal.pump(0.2) + assert posts == [] + terminal.resize(120, 50) + terminal.wait("Confirm prepare") + preparation_dispatch(terminal) + terminal.wait("stored_pending_verification") + terminal.wait("submission_summary_missing") + terminal.pump(0.2) + assert len(posts) == 1, "pending verification triggered an implicit replay" + terminal.send("p") + terminal.wait("Confirm prepare") + preparation_dispatch(terminal) + terminal.wait(ADMISSION) + terminal.wait("ready") + terminal.wait("No immutable Submission") + assert len(posts) == 2 + for route, headers, body in posts: + assert route == TASK_PATH + "/submission-bundle-preparations" + assert body == archive + assert headers["Content-Type"] == "application/zip" + assert headers["Content-Length"] == str(len(archive)) + assert headers["Authorization"] == "Bearer " + TOKEN + assert headers["X-Task-Assignment-Id"] == MINE_ASSIGNMENT + assert headers["Idempotency-Key"] == CLAIM_KEY + assert headers["X-Submission-Summary"] == "q" + assert headers["X-Contributor-Attestation"] == "Exact attestation" + assert "X-Predecessor-Submission-Id" not in headers + assert sum(method == "POST" for method, _, _ in requests) == 2 + terminal.quit() + assert TOKEN.encode() not in terminal.raw + + +def test_preparation_failed_intake_displays_canonical_blocking_feedback(tmp_path): + path = tmp_path / "work.zip" + preparation_zip(path) + peer = PreparationWrites( + ( + 422, + { + "error": { + "code": "pre_submission_checker_failed", + "message": "blocked", + "details": { + "status": "failed", + "eligible_to_submit": False, + "results": [ + { + "source": "project_external_checker", + "requirement_id": "quality", + "registry_entry_id": RUN, + "registry_entry_digest": "sha256:" + "a" * 64, + "status": "failed", + "findings": [ + { + "check_id": "packet.summary", + "code": "required_field_missing", + "level": "error", + "message": "Supply the required summary.", + "path": None, + "line": None, + } + ], + } + ], + }, + "correlation_id": RUN, + "retryable": False, + } + }, + ) + ) + posts = [] + with ( + socket_peer( + peer, + capture_post=lambda route, headers, body: posts.append( + (route, headers, body) + ), + ) as (origin, _), + Terminal(origin, TOKEN, "--plain", rows=50) as terminal, + ): + preparation_detail(terminal) + preparation_confirm(terminal, path, summary="") + preparation_dispatch(terminal) + terminal.wait("pre_submission_checker_failed") + terminal.wait("Intake feedback: error packet.summary") + terminal.wait("Supply the required summary.") + terminal.wait("No immutable Submission") + terminal.pump(0.2) + assert len(posts) == 1 + assert "X-Submission-Summary" not in posts[0][1] + terminal.quit() + + +def test_preparation_unknown_result_is_not_automatically_retried(tmp_path): + path = tmp_path / "work.zip" + archive = preparation_zip(path) + peer = PreparationWrites((202, b"{"), (202, preparation("ready"))) + posts = [] + with ( + socket_peer( + peer, + capture_post=lambda route, headers, body: posts.append( + (route, headers, body) + ), + ) as (origin, _), + Terminal(origin, TOKEN, "--plain", rows=50) as terminal, + ): + preparation_detail(terminal) + preparation_confirm(terminal, path) + preparation_dispatch(terminal) + terminal.wait("Outcome unknown") + terminal.send("ay") + terminal.pump(0.3) + assert len(posts) == 1, "unknown outcome dispatched an implicit replacement" + assert "Outcome unknown" in terminal.screen + terminal.send("\x1b") + terminal.wait(SECRET) + terminal.send("a") + terminal.pump(0.2) + assert "Prior operation: Outcome unknown" in terminal.screen + assert "No expansion or file execution" not in terminal.screen + terminal.send("p") + terminal.wait("Confirm prepare") + assert CLAIM_KEY in terminal.screen + path.write_bytes(archive + b"changed before replay") + preparation_dispatch(terminal) + terminal.wait("Earlier outcome remains") + assert "Outcome unknown" in terminal.screen + assert len(posts) == 1, "changed ZIP replaced an uncertain packet" + path.write_bytes(archive) + terminal.send("p") + terminal.wait("Confirm prepare") + preparation_dispatch(terminal) + terminal.wait(ADMISSION) + assert len(posts) == 2 + assert [body for _, _, body in posts] == [archive, archive] + assert [headers["Idempotency-Key"] for _, headers, _ in posts] == [ + CLAIM_KEY, + CLAIM_KEY, + ] + terminal.quit() + + +def test_preparation_file_changed_after_confirmation_sends_no_post(tmp_path): + path = tmp_path / "work.zip" + archive = preparation_zip(path) + peer = PreparationWrites() + with ( + socket_peer(peer) as (origin, requests), + Terminal(origin, TOKEN, "--plain", rows=50) as terminal, + ): + preparation_detail(terminal) + preparation_confirm(terminal, path) + path.write_bytes(archive + b"changed after confirmation") + preparation_dispatch(terminal) + terminal.wait("No POST was dispatched.") + terminal.pump(0.2) + assert all(method == "GET" for method, _, _ in requests) + assert "ready admission" not in terminal.screen.lower() + terminal.quit() + + +def test_preparation_replay_denial_clears_packet_and_disables_replay(tmp_path): + path = tmp_path / "work.zip" + preparation_zip(path) + peer = PreparationWrites((202, preparation("ready")), denial()) + posts = [] + with ( + socket_peer( + peer, + capture_post=lambda route, headers, body: posts.append( + (route, headers, body) + ), + ) as (origin, _), + Terminal(origin, TOKEN, "--plain", rows=50) as terminal, + ): + preparation_detail(terminal) + preparation_confirm(terminal, path, summary="PROTECTED_PACKET_SUMMARY") + preparation_dispatch(terminal) + terminal.wait(ADMISSION) + terminal.send("p") + terminal.wait("Confirm prepare") + preparation_dispatch(terminal) + terminal.wait("Protected preparation text cleared") + for protected in ( + SECRET, + str(path), + "PROTECTED_PACKET_SUMMARY", + "Exact attestation", + PUT, + ADMISSION, + "submission_summary_missing", + ): + assert protected not in terminal.screen + terminal.send("psy") + terminal.pump(0.2) + assert len(posts) == 2 + assert TOKEN.encode() not in terminal.raw + assert b"\x1b]52;" not in terminal.raw + terminal.quit() + + @pytest.mark.parametrize("light", [None, False, True]) @pytest.mark.parametrize("rows", [10, 24]) def test_minimum_terminal_navigation_and_light_background(light, rows): diff --git a/cli/tests/integration/test_tui_public_api.py b/cli/tests/integration/test_tui_public_api.py index 4101d2ccb..f7ca4367b 100644 --- a/cli/tests/integration/test_tui_public_api.py +++ b/cli/tests/integration/test_tui_public_api.py @@ -1,16 +1,22 @@ """Built native PTY/ConPTY -> real socket/Flow verifier -> isolated PostgreSQL. Canonical fixtures arrange an approved guide, started work and a retained -immutable attempt. The original test remains GET-only; the separate action -test proves claim/start through the native UI. Neither is a public intake or -external artifact-provider journey. No AUTH/TASK port is replaced; only the -signed fixture issuer's JWKS transport is local. +immutable attempt. The original test remains GET-only; separate tests prove +claim/start and initial public intake through the native UI. Intake uses the +fixture's local ART provider and explicit in-process guest rules, not S3 or +sandbox-execution proof. No DB/AUTH/TASK owner is replaced; the signed fixture +issuer's JWKS transport is local. """ import asyncio +from io import BytesIO +import json from types import SimpleNamespace +from unittest.mock import patch from uuid import uuid4 +from zipfile import ZipFile +from httpx import ASGITransport, AsyncClient import pytest import pytest_asyncio from sqlalchemy import func, select @@ -22,9 +28,12 @@ from test_submission_public_api import socket_api from tui_terminal_support import Terminal from app.api.deps.authorization import compose_hidden_submission_creation_command +from app.adapters.auth.flow import FlowAuthVerifier from app.core.identifiers import new_record_id +from app.main import create_app +from app.modules.artifacts.models import ArtifactPutAttempt, SubmissionBundleAdmission from app.modules.authorization.models import ProjectRoleGrant -from app.modules.checkers.models import CheckerRun +from app.modules.checkers.models import CheckerRun, SelectedPreSubmitRun from app.modules.contributions.records.models import ContributionRecord from app.modules.tasks.models import Submission, TaskAssignment, WorkstreamTask from tests.authentication.fixtures import ( @@ -32,7 +41,11 @@ clear_settings_cache as clear_settings_cache, rsa_signing_material as rsa_signing_material, ) -from tests.authentication.support import issue_asymmetric_token +from tests.authentication.support import ( + issue_asymmetric_token, + jwks_transport, + production_verifier_settings, +) from tests.authorization.admin_access.fixtures import ( admin_access as _admin_access, signed_access as _signed_access, @@ -44,12 +57,346 @@ pagination_cursor_hmac_secret as pagination_cursor_hmac_secret, ) from tests.tasks.test_submission_lineage import _prepared_packet +from tests.tasks.submission_lineage_support import _verify_put_attempt +from tests.trusted_intake_fixtures import LocalIntakeAdapter signed_access = pytest_asyncio.fixture(_signed_access.__wrapped__) admin_access = pytest_asyncio.fixture(_admin_access.__wrapped__) auth_database_env = _auth_database_env +@pytest.mark.asyncio +async def test_tui_signed_initial_intake_pending_verification_replay_and_revocation( + tmp_path, auth_database_env, admin_access, rsa_signing_material +): + """Native UI and real owners; local ART and in-process guest rules, not S3.""" + signed = admin_access.signed + async with _prepared_packet(auth_database_env, tmp_path) as h: + subject = f"human-{h.context.actor_profile_id}" + owner = SignedActor( + h.context.actor_profile_id, + subject, + issue_asymmetric_token( + signed.private_key, + claims={"sub": subject, "jti": str(uuid4()), "roles": []}, + ), + ) + manager = await project_manager(admin_access, h.project_id) + # Keep this fixture's exact ART namespace, store and scratch configuration. + # Only replace authentication settings with the production Flow verifier + # settings; do not inherit admin_access's disabled artifact backend. + flow = production_verifier_settings(database_url=auth_database_env) + settings = h.settings.model_copy( + update={ + name: getattr(flow, name) + for name in type(flow).model_fields + if name.startswith(("auth_", "token_")) + } + | {"database_url": auth_database_env} + ) + app = create_app(settings) + jwks_requests = [] + app.state.auth_verifier = FlowAuthVerifier( + settings, + jwks_transport=jwks_transport(rsa_signing_material[1], jwks_requests), + ) + assert app.state.auth_configuration_valid + assert not app.dependency_overrides, "DB/AUTH/TASK owners must remain real" + assert settings.auth_provider == "flow" + assert settings.artifact_local_root == h.settings.artifact_local_root + assert settings.artifact_scratch_root == h.settings.artifact_scratch_root + + project, task = str(h.project_id), str(h.task_id) + route = f"/api/v1/tasks/{task}/submission-bundle-preparations" + corrected = tmp_path / "corrected.zip" + corrected.write_bytes(h.archive_bytes) + blocked_zip = tmp_path / "blocked.zip" + archive = BytesIO(h.archive_bytes) + with ZipFile(archive, "a") as contents: + # Canonical default-checker fixture, not a real private key. Missing + # summary/evidence alone must not be invented as a blocking default + # check: absent summary is a warning on this locked policy branch. + contents.writestr( + "synthetic-private-key.txt", + b"-----BEGIN OPENSSH PRIVATE KEY-----\nencoded", + ) + blocked_zip.write_bytes(archive.getvalue()) + failed_key, corrected_key = str(uuid4()), str(uuid4()) + models = ( + SelectedPreSubmitRun, + ArtifactPutAttempt, + SubmissionBundleAdmission, + Submission, + ContributionRecord, + ) + + async def counts(): + async with h.factory() as session: + result = { + model.__tablename__: await session.scalar( + select(func.count()).select_from(model) + ) + for model in models + } + assert result[Submission.__tablename__] == 0 + assert result[ContributionRecord.__tablename__] == 0 + return result + + # _prepared_packet already created one PRE run and verified ART packet. + # Test deltas, not a fictitious empty preparation database. + baseline = await counts() + requests = [] + posts = [] + + async def observed(scope, receive, send): + if scope["type"] != "http": + return await app(scope, receive, send) + requests.append((scope["method"], scope["path"])) + if scope["method"] != "POST": + return await app(scope, receive, send) + record = { + "path": scope["path"], + "headers": dict(scope["headers"]), + "body": bytearray(), + "response_body": bytearray(), + } + posts.append(record) + + async def observed_receive(): + message = await receive() + if message["type"] == "http.request": + record["body"].extend(message.get("body", b"")) + assert len(record["body"]) <= 1024 * 1024 + return message + + async def observed_send(message): + if message["type"] == "http.response.start": + record["status"] = message["status"] + elif message["type"] == "http.response.body": + record["response_body"].extend(message.get("body", b"")) + assert len(record["response_body"]) <= 1024 * 1024 + if not message.get("more_body", False): + record["response"] = json.loads(record["response_body"]) + await send(message) + + await app(scope, observed_receive, observed_send) + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://testserver" + ) as client: + project_response = await client.get( + "/api/v1/actors/me/projects", headers=owner.headers + ) + assert project_response.status_code == 200, project_response.text + [project_row] = project_response.json()["items"] + task_response = await client.get( + f"/api/v1/tasks/{task}", headers=owner.headers + ) + assert task_response.status_code == 200, task_response.text + task_body = task_response.json() + assert task_body["status"] == "in_progress" + + # This explicitly named fixture executes guest rules in-process. + # The production signed verifier, public route, PRE and ART owners, + # PostgreSQL and local provider remain real, with no dependency swap. + with patch( + "app.adapters.artifacts.trusted_submission_intake", + return_value=LocalIntakeAdapter(), + ): + async with socket_api(observed) as origin: + with Terminal( + origin, owner.token, "--plain", columns=180, rows=60 + ) as terminal: + + async def wait(text): + return await asyncio.to_thread(terminal.wait, text, 60) + + async def confirm(path, key, summary): + terminal.send("a") + for label, value in ( + ("ZIP path", str(path)), + ("Summary", summary), + ( + "Attestation", + h.preparation_request.contributor_attestation, + ), + ("Caller-owned replay key", key), + ): + await wait(label) + if value: + terminal.send(value) + terminal.send("\r") + await wait("Confirm prepare") + for identity in ( + str(owner.id), + project, + task, + str(h.assignment_id), + key, + ): + assert identity in terminal.screen + terminal.send("s") + terminal.send("y") + + async def replay(): + terminal.send("p") + await wait("Confirm prepare") + terminal.send("s") + terminal.send("y") + + await wait(project_row["name"]) + terminal.send("\r") + await wait("[Ready]") + terminal.send("2") + await wait(task_body["title"]) + terminal.send("\r") + await wait(task_body["description"]) + await confirm(blocked_zip, failed_key, "") + await wait("pre_submission_checker_failed") + await wait("No immutable Submission") + assert len(posts) == 1 + failed = posts[0] + assert failed["status"] == 422 + failure = failed["response"]["error"] + assert failure["code"] == "pre_submission_checker_failed" + details = failure["details"] + assert details["eligible_to_submit"] is False + assert details["results"] + [blocking] = [ + result + for result in details["results"] + if result["status"] == "failed" + ] + assert blocking == { + "source": "workstream_default", + "check_id": "high_confidence_secrets_absent", + "status": "failed", + "failure_code": "high_confidence_secret_detected", + "finding_count": 1, + } + await wait("high_confidence_secret_detected") + assert "synthetic-private-key.txt" not in terminal.screen + assert "BEGIN OPENSSH PRIVATE KEY" not in terminal.screen + failed_counts = await counts() + assert failed_counts == baseline | { + SelectedPreSubmitRun.__tablename__: baseline[ + SelectedPreSubmitRun.__tablename__ + ] + + 1 + } + # A known failed intake can return to Overview and open + # a new, deliberately confirmed corrected packet/key. + terminal.send("\x1b") + await wait(task_body["description"]) + await confirm( + corrected, corrected_key, h.preparation_request.summary + ) + await wait("stored_pending_verification") + assert len(posts) == 2 + pending = posts[1]["response"] + assert posts[1]["status"] == 202 + assert pending["submission_bundle_preparation_status"] == ( + "stored_pending_verification" + ) + assert pending["admission_id"] is None + pending_counts = await counts() + assert pending_counts == failed_counts | { + SelectedPreSubmitRun.__tablename__: failed_counts[ + SelectedPreSubmitRun.__tablename__ + ] + + 1, + ArtifactPutAttempt.__tablename__: failed_counts[ + ArtifactPutAttempt.__tablename__ + ] + + 1, + } + # Consume POST's put_attempt_id, never a GET preparation ID. + admission = await _verify_put_attempt( + h.factory, + h.store, + h.namespace, + h.settings, + pending["put_attempt_id"], + ) + await replay() + await wait(str(admission)) + await wait("ready") + assert len(posts) == 3 + assert posts[2]["response"] == pending | { + "submission_bundle_preparation_status": "ready", + "admission_id": str(admission), + "replayed": True, + } + ready_counts = await counts() + assert ready_counts == pending_counts | { + SubmissionBundleAdmission.__tablename__: pending_counts[ + SubmissionBundleAdmission.__tablename__ + ] + + 1 + } + async with h.factory() as session: + grant_id = await session.scalar( + select(ProjectRoleGrant.id).where( + ProjectRoleGrant.actor_profile_id == str(owner.id), + ProjectRoleGrant.project_id == project, + ProjectRoleGrant.role == "submitter", + ProjectRoleGrant.status == "active", + ) + ) + assert grant_id is not None + revoked = await signed.client.post( + f"/api/v1/projects/{project}/role-grants/{grant_id}/revoke", + headers=manager.headers | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Prove fresh preparation replay denial"}, + ) + assert revoked.status_code == 200, revoked.text + await replay() + await wait("Protected preparation text cleared") + assert len(posts) == 4 and posts[3]["status"] == 404 + for protected in ( + task_body["description"], + str(corrected), + h.preparation_request.summary, + h.preparation_request.contributor_attestation, + pending["put_attempt_id"], + str(admission), + ): + assert protected not in terminal.screen + terminal.send("psy") + await asyncio.to_thread(terminal.pump, 0.3) + assert len(posts) == 4, "denial did not disable replay" + await asyncio.to_thread(terminal.quit) + assert owner.token.encode() not in terminal.raw + assert await counts() == ready_counts + + assert jwks_requests, "signed proof never used the real Flow JWKS verifier" + assert all( + method == "GET" or (method == "POST" and path == route) + for method, path in requests + ) + assert [post["path"] for post in posts] == [route] * 4 + assert bytes(posts[0]["body"]) == blocked_zip.read_bytes() + assert posts[0]["headers"][b"idempotency-key"] == failed_key.encode() + assert b"x-submission-summary" not in posts[0]["headers"] + # This observer records what the real owner consumed. A denied request + # may correctly stop before reading bytes; pending/ready exact replay + # must both consume the original archive. + assert [bytes(post["body"]) for post in posts[1:3]] == [h.archive_bytes] * 2 + for post in posts[1:]: + assert post["headers"][b"idempotency-key"] == corrected_key.encode() + assert post["headers"][b"x-task-assignment-id"] == ( + str(h.assignment_id).encode() + ) + assert post["headers"][b"x-submission-summary"] == ( + h.preparation_request.summary.encode() + ) + assert post["headers"][b"x-contributor-attestation"] == ( + h.preparation_request.contributor_attestation.encode() + ) + assert post["headers"][b"content-type"] == b"application/zip" + assert await counts() == ready_counts + + @pytest.mark.asyncio async def test_tui_real_signed_owner_positive_reads_then_fresh_grant_revocation( tmp_path, auth_database_env, admin_access diff --git a/docs/design/cli-terminal-experience.md b/docs/design/cli-terminal-experience.md index a2a611fc4..ea960f49a 100644 --- a/docs/design/cli-terminal-experience.md +++ b/docs/design/cli-terminal-experience.md @@ -1,7 +1,7 @@ # Workstream contributor terminal workbench This is the presentation contract for the opt-in `workstream tui` contributor -workbench after [WS-CLI-001-51](../../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-51.md), +workbench after [WS-CLI-001-52](../../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md), extending the presentation established by [WS-CLI-001-50](../../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md). It describes the intended merged interface, not a new API, deployed service, published binary, or completed product journey. The existing read and download @@ -21,8 +21,9 @@ offers `Overview` and paged `Findings`. Contribution Evidence is an inert CLI handoff, not a fourth fetched collection. The project picker uses `me projects`; the separate system-reader `project list` does not feed this workbench. -The workbench exposes deliberate exact-task Claim and Start. It exposes no -preparation, Submission, Review, policy, management or payment mutation. Its only +The workbench exposes deliberate exact-task Claim, Start and initial ZIP +preparation. It exposes no immutable Submission, revision-upload, Review, +policy, management or payment mutation. Its only local file write is an explicitly confirmed, verified single-guide-document download. It does not poll, automatically retry, cache stale authority, store credentials or history, acquire tokens, launch a shell or viewer, or copy a @@ -72,6 +73,38 @@ are earned facts, not payment or fulfillment proof. The default locked ReviewPolicy requires human review; supported false-policy acceptance is an authorized backend operation, never a checker-owned or fabricated Review. +## Initial ZIP preparation + +On freshly inspected Mine Overview with `in_progress` task status, `a` requests +fresh work context before opening preparation. Require exact actor/project/task +binding, current-actor assignment and no revision context. The exact assignment +comes from the selected Mine row, not a guessed field in work context. Backend +authorization still owns every request and replay. + +The staged form collects a ZIP path, optional summary, optional attestation and +externally saved caller UUID. Blank optional values are omitted; nonblank values +remain exact printable ASCII under the existing header API. Do not silently +trim, normalize or generate attestation text. Local asynchronous file inspection +shows path, bytes and SHA-256; it is not an intake check. Final confirmation +names actor, project, task, assignment, exact packet and key. `s` acknowledges +external saving and `y` dispatches one upload through the shared typed client. +No progress percentage, automatic polling or automatic Submission follows. + +The shared client compares the confirmed hash with its own pre-send hash and +retains its post-send source verification. A changed file is not an exact +replay. Pending requests hold their exact target; selection, resize and repeated +keys cannot replace it. Cancellation ends the client request, not server effects. +Unknown results retain their original packet/key/hash; an unsent local refusal +or a later denied replay cannot erase that earlier uncertainty. Authority loss +clears private packet/feedback text and disables in-session replay. + +Show successful warning/coverage feedback and canonical blocking intake +findings. A `ready` receipt exposes its admission; all other storage statuses +remain explicitly not ready. Every result explains that no immutable Submission +was requested. The ready result provides inert exact CLI handoff values, not a +shell command to execute. A put-attempt ID is not a preparation-history ID. +History browsing, revision uploads and automatic restart recovery are not added. + ## Reading and navigation The header identifies `WORKSTREAM / Contribute` and the selected project name. @@ -209,7 +242,7 @@ percentage. ## Future boundary, not current behavior The broader [WS-CLI-001 initiative](../../.commitrail/initiatives/WS-CLI-001/OVERVIEW.md) -retains separate in-TUI Contribution Evidence, preparation/submission and management +retains separate in-TUI Contribution Evidence, immutable submission/revision and management boundaries. Evidence first needs a shared typed single-page contribution port; the complete explicit CLI export is not a TUI page source. Human Review and fulfillment are separate product gaps, not empty workbench screens. A later @@ -225,6 +258,6 @@ retry or inference from an empty history page. Claim/start keys, preparation's ZIP/packet/key, Submission's admission/packet, guide correction receipts, and Operator retry/reconciliation selectors are not interchangeable. Design and prove each flow at its own boundary; do not add a generic write journal or -silent handoff. Claim/start currently use externally saved caller-owned keys, +silent handoff. Claim/start and initial preparation use externally saved caller-owned keys, matching the explicit CLI, not automatic custody. The historical design decisions and separate operation records remain in the [initiative overview](../../.commitrail/initiatives/WS-CLI-001/OVERVIEW.md). diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 0d802d4dc..bb1e630b2 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -70,8 +70,11 @@ redraws that same local capability with visible navigation, readable instruction and truthful loaded-page/evidence states, without new API or product operations. [WS-CLI-001-51](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-51.md) adds direct collection navigation and separately confirmed Claim/Start through the existing -public API, with caller-owned saved keys and explicit replay. Contribution -Evidence remains an explicit CLI handoff; ZIP preparation/Submission, other +public API, with caller-owned saved keys and explicit replay. +[WS-CLI-001-52](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md) adds +initial ZIP preparation with exact file/packet confirmation and intake feedback, +not immutable Submission creation. Contribution +Evidence remains an explicit CLI handoff; Submission/revision writes, other TUI mutations and management remain separate. Neither this interface nor release automation establishes hosted operation or a published binary. @@ -471,8 +474,11 @@ paged Findings. Evidence remains an inert CLI handoff, not automatic export. WS-CLI-001-51 adds direct collection navigation and separate, explicitly confirmed claim/start through the existing typed public writes. Caller-owned externally saved UUID keys support explicit unchanged replay; unknown outcomes -never trigger retry or imply rollback. Automatic crash recovery, ZIP preparation, -Submission writes, polling and management forms remain outside this bounded +never trigger retry or imply rollback. WS-CLI-001-52 adds separately confirmed +initial ZIP preparation and feedback through that same client, with a reviewed +file hash checked before dispatch and explicit unchanged replay. Preparation is +not Submission. Automatic crash recovery, revision uploads, Submission writes, +polling and management forms remain outside this bounded workbench. Explicit text/JSON commands stay usable without a terminal. The [Operator checker CLI](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-29.md) @@ -610,7 +616,7 @@ cannot be reused as post-submission review-gate evidence. See the | Review decision and revision | **Checker revision implemented; human decisions planned** | Review/revision policy identities and mutation authority, with `requires_second_review` fixed false through typed and database boundaries; REV-04A immutable Review storage; REV-04B source storage; REV-04C hidden source-neutral FinalAcceptance/TASK/CON participant; ARCH-04E2-B source AUTH and complete database/audit/outbox closure for hidden automated outcomes. PILOT-08 supplies required project-policy N>=0, assignment-scoped attempts, immediate exhaustion/release, atomic complete-context rebase and acknowledgement, setup-blocked clock pause/resume, PM attention/repair, and durable timeout rejection/reclaim. The CLI now makes the existing exact manager repair POST available with caller-owned key and source, preserving backend eligibility and replay authority | Genuine human decision authority/composition and revision integration remain #554 work under the same intended policy; hosted operation remains unproven | | Contribution and compensation truth | **Hidden shared participant plus public policy administration** | ContributionPolicyVersion persistence; lifecycle-audit participant; adapter bindings and project-points units (internal Finance commands); public Finance policy administration; REV-04B shared acceptance source storage; CON-03C immutable ContributionRecord/CompensationAward storage; obsolete guide-keyed payment storage and task-local payment fields removed; CON-07 source-neutral submitter participant and complete frozen award sets; REV-04C composes it with FinalAcceptance/TASK effects in the caller transaction; 04F2 checker remediation and terminal infrastructure retry create no contribution or award. The bounded local public drill produced exactly one false-policy FinalAcceptance, submitter ContributionRecord and project-points award, with no Review | ARCH-04E2-B supplies exact routing authority/evidence, complete-set database closure and audit/outbox under REV-12A4A. Selected production request/completion delivery, 04F3 exact-cause replacement and the 04F4 owner with supported public terminal recovery are implemented; supported false-guide activation is implemented. Only actual Reviews create reviewer records. Obligation/root/ordinal custody remains deferred until fulfillment activation; no public recognition or fulfillment route is live | | Contribution evidence reads | **Public fixed-membership REST and complete CLI export** | PILOT-10A exposes current human self-read and one-project JSON/CSV export for covering Project Managers or exact-project Finance Authorities. Each page has fresh authority and one repeatable-read response transaction; selections through 100,000 contributions retain page-one membership behind signed scope/filter/format/limit-bound cursors, with complete zero/one/two immutable awards, exact decimals, recorded-time filters and formula-neutralized CSV. The CLI follows JSON or CSV continuation internally with unchanged filters, validates cross-page order/identity, and publishes only the complete JSON result or one private no-overwrite CSV | No recognition mutation, fulfillment, payout, deployed-service or published-binary claim | -| CLI and terminal interface | **Public CLI clients and contributor workbench with deliberate claim/start** | Explicit commands retain text/JSON/noninteractive behavior. [WS-CLI-001-48](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-48.md) supplies scoped contributor reads; [WS-CLI-001-49](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-49.md) adds verified guide downloads/findings; [WS-CLI-001-50](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md) improves presentation. [WS-CLI-001-51](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-51.md) adds direct navigation and exact confirmed claim/start with caller-owned saved keys, explicit replay and honest unknown outcomes | ZIP preparation/Submission, automatic crash recovery and management remain separate TUI boundaries. Contribution Evidence remains an inert CLI handoff pending a typed single-page port. Protected publication and hosted journey proof remain separate operational claims | +| CLI and terminal interface | **Public CLI clients and contributor workbench with deliberate claim/start/intake** | Explicit commands retain text/JSON/noninteractive behavior. [WS-CLI-001-48](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-48.md) supplies scoped reads; [WS-CLI-001-49](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-49.md) adds verified downloads/findings; [WS-CLI-001-50](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md) improves presentation. [WS-CLI-001-51](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-51.md) adds direct navigation and confirmed claim/start; [WS-CLI-001-52](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md) adds initial ZIP preparation/feedback with reviewed file hashes and exact packet replay. Caller-owned saved inputs and honest unknown outcomes remain explicit | Immutable Submission/revision writes, automatic crash recovery and management remain separate TUI boundaries. Contribution Evidence remains an inert CLI handoff pending a typed single-page port. Protected publication and hosted journey proof remain separate operational claims | | Fulfillment, reconciliation, and audit | **Planned** | Shared audit foundations, provider-neutral adapter convention, AUTH-OUTBOX-02 live dispatcher authority, retained phase audit decisions, Celery delivery/recovery scans and CON-02B custody | Feature-specific handlers and authority, conditional award fulfillment, callbacks, idempotent recovery, reconciliation, bounded operational reads, and release controls | | Runtime diagnostics | **Implemented foundation; release configuration prepared** | Closed structured logging, explicit API/Celery tracing, bounded metrics, safe correlation and optional typed OTLP export; hosted composition accepts only an optional HTTPS collector origin and keeps exporter outage outside product correctness | Restrict broker publishers; configure and secure collector/log access, egress, encryption and finite deletion; prove privacy, correlation and collector outage during the deployed release drill; no deployed monitoring claim | | Frontend and pilot | **Local runtime foundation; release configuration prepared; project inputs prepared** | An original [Terminal Systems guide, example and five task briefs](../pilots/terminal_systems/README.md) provide inputs for fresh project onboarding. The fixed five-task sample, its checker, private runtime continuation and sample-only proof workflow are retired. Checkout-isolated Docker Compose API, prefork Celery, Beat, PostgreSQL, Redis and MinIO remain available, alongside digest-only hosted backend composition and the existing native Smol image service. The generic image command tests project samples and publishes a verified digest to ECR without registering or activating it. The opt-in [local agent-loop overlay](engineering/local-agent-loop.md) adds that service, a separately built networkless proof checker and operator scripts; Docker Desktop (Apple Silicon) proof covers registration, checker execution and lifecycle activation, not this project’s requirements. Setup supports inference and explicitly manager-authored rules through the same governed proposal path. Inference is limited to PRE/POST specifications, with strict JSON and bounded correction; PM policy choices remain separate. The retained real Terminal Systems proposal preserves null project limits on the unchanged guide; its independent review predates the PRE/POST-only scope, with ten explicit implementation/runtime gaps and no bindings in that retained proposal. The [project-owned PRE image](../pilots/terminal_systems/checkers/README.md) now implements layout and metadata intake with local rule and networkless container samples; it has no task-name allowlist or shared-runner project code. The project-owned POST implementation consumes generic model/trial capabilities and exact approved predecessor inputs; its qualification boundaries are recorded with the checker implementation. The independent offline gVisor feasibility spike remains historical mechanism evidence | Obtain a fresh PRE/POST-only correction of the retained proposal, reconcile the implemented PRE/POST images and qualify the exact runtime/material path; register the required images, obtain corrected policy bindings and separate approvals, then activate, import, screen and release tasks. Then prove the real contribution journey, runtime limits and interruption recovery on the intended local/hosted stack. Guide assets, image builds and former sample results are not live-project evidence. Hosted identity/AWS configuration, isolation, diagnostics, backup/restore and broader macOS runtime proof remain; the agent-loop overlay still needs approved guide bindings and a persistent-stack FinalAcceptance drill | @@ -960,6 +966,9 @@ cannot be reused as post-submission review-gate evidence. See the adds exact claim/start and direct collection navigation. Saved caller-owned keys, explicit confirmation and same-packet replay preserve existing backend authority; no automatic write or claim-to-start chain is introduced. + [WS-CLI-001-52](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md) + adds initial ZIP preparation with reviewed bytes/packet, actual intake feedback + and explicit replay. A ready admission is not an immutable Submission. Contribution Evidence remains a manual CLI handoff; broader TUI work remains separate. Further public commands require their own usable API contracts; binary-release automation is prepared, while protected first publication @@ -1401,7 +1410,9 @@ Delivered foundations (bounded local journey proven; no deployment claim) same typed reads/download; no new API, management or mutation capability WS-CLI-001-51 existing public claim/start -> explicit TUI confirmation + caller-owned key direct Ready/Mine/Submissions navigation; unknown -> explicit unchanged replay - later ZIP/Submission need per-operation recovery; Evidence needs typed single-page port + WS-CLI-001-52 existing initial ZIP intake -> exact file/packet confirmation + feedback + explicit unchanged preparation replay; no automatic immutable Submission + later Submission/revision need per-operation recovery; Evidence needs typed single-page port task/assignment/Submission lineage + public ZIP intake/admission-backed creation ARCH-03D exact approved historical guide through durable intake handoff ARCH-04B hidden verified Submission input @@ -1533,9 +1544,10 @@ and award history never changes. ## Remaining Release Gates Client scope remains distinct from product completion: the contributor TUI -presents existing reads/downloads plus separate deliberate claim/start with -externally saved replay keys. It does not complete human review, payment or -hosted proof. In-TUI Contribution Evidence, ZIP preparation/Submission and +presents existing reads/downloads plus separate deliberate claim/start and +initial ZIP preparation with externally saved replay inputs. It does not complete +human review, payment or hosted proof. In-TUI Contribution Evidence, +immutable Submission/revision writes and automatic crash recovery remain separate interface boundaries, not new backend lifecycle prerequisites. CLI binary distribution still requires protected publication and independent public-asset/install verification. From 918549f1c14fdc362680211ef0b3eb15ede80e19 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 27 Oct 2026 11:36:12 +0100 Subject: [PATCH 4/4] fix(cli): bind preparation receipts to streamed bytes --- .../initiatives/WS-CLI-001/OVERVIEW.md | 4 +- .../initiatives/WS-CLI-001/WS-CLI-001-52.md | 7 + cli/README.md | 5 +- cli/internal/api/submission.go | 53 +++++- cli/internal/api/submission_test.go | 167 +++++++++++++++++- cli/internal/tui/actions.go | 3 + cli/internal/tui/preparation_test.go | 23 +++ cli/tests/integration/test_tui_http.py | 60 ++++++- cli/tests/integration/test_tui_public_api.py | 2 +- docs/design/cli-terminal-experience.md | 7 +- 10 files changed, 319 insertions(+), 12 deletions(-) diff --git a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md index 034f77470..145d62ff8 100644 --- a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md @@ -81,8 +81,8 @@ the existing TUI and typed API client, not a second client or simulated demo. 1. [WS-CLI-001-51](WS-CLI-001-51.md): direct collection navigation and deliberate claim/start using existing public writes, exact confirmation, caller-owned - replay keys and uncertain-outcome recovery. This is the current bounded work. -2. ZIP preparation: explicit local package selection, summary/attestation, + replay keys and uncertain-outcome recovery. +2. [WS-CLI-001-52](WS-CLI-001-52.md): explicit local ZIP selection, summary/attestation, actual intake feedback and exact replay. Preparation is not Submission. 3. Submission and continuation: deliberate immutable creation, current outcome, checker remediation and only API-supported human revision responses. No diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md index 18c8cc86f..16f56d3f2 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-52.md @@ -60,6 +60,11 @@ new dependencies or user-file writes. - Extend the existing shared preparation client with an expected SHA-256 input so a file changed after confirmation is rejected before dispatch. Retain its existing post-send source verification and unknown-outcome semantics. + Verify the exact count and digest of bytes consumed by the request stream + before confirming a successful receipt. Before/after file hashes alone miss + a transient write restored before the final check. Synchronize body reading, + closure and digest inspection; incomplete successful uploads remain unknown. + A known denial may legitimately occur before reading the body. The expected hash is checked inside `PrepareSubmission` after its own first hash and before request creation, with a context cancellation check, not only in the TUI before a later independent hash. Open @@ -79,6 +84,8 @@ new dependencies or user-file writes. errors. Authorization denial clears protected text and disables replay while retaining only minimal inert recovery identities; never render bearer/errors unsanitized. Recover after restart through caller-saved exact inputs and CLI. + Clearing a previously confirmed ready receipt must not relabel it not ready; + report confirmed-but-cleared evidence without exposing the protected details. - A ready receipt exposes its admission and exact packet for an inert CLI submission handoff, not an executable shell command or automatic submission. Do not claim historical preparation browsing or persistent recovery. diff --git a/cli/README.md b/cli/README.md index 392f05fdf..12029e5d5 100644 --- a/cli/README.md +++ b/cli/README.md @@ -88,8 +88,9 @@ Preparation displays intake feedback, including nonblocking findings. A ready admission is **not a Submission**. Non-ready storage states are shown as returned, without polling. Explicit replay retains the exact key, assignment, packet and file hash; changed bytes are refused before sending. Save all those -inputs for recovery outside the session. Never replace a key after an unknown -outcome. The returned admission and packet can be used with the separate +inputs for recovery outside the session. The client also checks the bytes +actually streamed before confirming a successful receipt. Never replace a key +after an unknown outcome. The returned admission and packet can be used with the separate `task submit` command; the TUI does not execute that handoff. Revision ZIP preparation remains an explicit CLI operation. diff --git a/cli/internal/api/submission.go b/cli/internal/api/submission.go index e7b31b2c4..48d9bda82 100644 --- a/cli/internal/api/submission.go +++ b/cli/internal/api/submission.go @@ -2,8 +2,11 @@ package api import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" + "hash" "io" "math/big" "mime" @@ -12,6 +15,7 @@ import ( "os" "regexp" "strings" + "sync" ) // SubmissionPacket is shared unchanged between explicit preparation and creation. @@ -80,6 +84,48 @@ func submissionFailure(err error, preparation bool) error { return err } +// preparationBody binds the receipt to bytes actually consumed by the HTTP +// transport, including a transient file change restored before the final hash. +// RoundTripper may read/close the request asynchronously after returning a +// response. Sealing shares their lock and prevents any later source reads. +type preparationBody struct { + mu sync.Mutex + source io.Reader + digest hash.Hash + bytes int64 + closed bool + failed bool +} + +func (b *preparationBody) Read(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return 0, io.ErrClosedPipe + } + n, err := b.source.Read(p) + _, _ = b.digest.Write(p[:n]) + b.bytes += int64(n) + if err != nil && err != io.EOF { + b.failed = true + } + return n, err +} + +func (b *preparationBody) Close() error { + b.mu.Lock() + defer b.mu.Unlock() + b.closed = true + return nil +} + +func (b *preparationBody) matches(expected string, size int64) bool { + b.mu.Lock() + defer b.mu.Unlock() + b.closed = true + return !b.failed && b.bytes == size && "sha256:"+hex.EncodeToString(b.digest.Sum(nil)) == expected +} + // PrepareSubmission streams one regular ZIP once. A receipt is not a Submission. func (c *Client) PrepareSubmission(ctx context.Context, task, key string, packet SubmissionPacket, acknowledgement *RevisionAcknowledgement, source *os.File, expectedSHA256 string) (Result[PreparationReceipt], error) { var result Result[PreparationReceipt] @@ -126,9 +172,11 @@ func (c *Client) PrepareSubmission(ctx context.Context, task, key string, packet headers.Set("X-Revision-Preparation-Id", acknowledgement.PreparationID) headers.Set("X-Revision-Preparation-Sha256", acknowledgement.PreparationSHA256) } + body := &preparationBody{source: io.NewSectionReader(source, 0, initial.Size()), digest: sha256.New()} + defer body.Close() response, err := c.openBodyRequest(ctx, c.guideHTTP, http.MethodPost, "/api/v1/tasks/"+url.PathEscape(task)+"/submission-bundle-preparations", "", - io.NewSectionReader(source, 0, initial.Size()), initial.Size(), key, "application/json", "application/zip", headers) + body, initial.Size(), key, "application/json", "application/zip", headers) if err != nil { return result, submissionFailure(err, true) } @@ -177,6 +225,9 @@ func (c *Client) PrepareSubmission(ctx context.Context, task, key string, packet default: return result, submissionFailure(invalid, true) } + if !body.matches(expected, initial.Size()) { + return result, submissionFailure(&Failure{Code: "submission_source_changed", OutcomeUnknown: true}, true) + } finalHash, hashErr := uploadSourceHash(source, initial.Size()) final, statErr := source.Stat() if hashErr != nil || statErr != nil || final.Size() != initial.Size() || !final.ModTime().Equal(initial.ModTime()) || finalHash != expected { diff --git a/cli/internal/api/submission_test.go b/cli/internal/api/submission_test.go index 9dbbb39c9..4d05e54c9 100644 --- a/cli/internal/api/submission_test.go +++ b/cli/internal/api/submission_test.go @@ -3,7 +3,9 @@ package api import ( "context" "crypto/sha256" + "errors" "fmt" + "io" "net/http" "net/http/httptest" "os" @@ -13,13 +15,22 @@ import ( "testing" ) +type preparationTransport func(*http.Request) (*http.Response, error) + +func (f preparationTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +const preparationAcceptedJSON = `{"put_attempt_id":"10000000-0000-7000-8000-000000000001","admission_id":null,"submission_bundle_preparation_status":"stored_pending_verification","replayed":false,"pre_submission_results":[]}` + func TestPreparationReviewedHashIsCheckedBeforePOST(t *testing.T) { var posts atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { posts.Add(1) + if _, err := io.Copy(io.Discard, r.Body); err != nil { + t.Error(err) + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusAccepted) - _, _ = fmt.Fprint(w, `{"put_attempt_id":"10000000-0000-7000-8000-000000000001","admission_id":null,"submission_bundle_preparation_status":"stored_pending_verification","replayed":false,"pre_submission_results":[]}`) + _, _ = fmt.Fprint(w, preparationAcceptedJSON) })) defer server.Close() c := &Client{origin: server.URL, token: "test-token", guideHTTP: server.Client()} @@ -52,3 +63,157 @@ func TestPreparationReviewedHashIsCheckedBeforePOST(t *testing.T) { t.Fatal("cancelled operation sent", err, posts.Load()) } } + +func TestPreparationRejectsTransientStreamMutationRestoredBeforeFinalHash(t *testing.T) { + for _, reviewed := range []bool{false, true} { + t.Run(fmt.Sprintf("reviewed=%t", reviewed), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "bundle.zip") + original, changed := []byte("reviewed original"), []byte("unreviewed bytes!") + if len(original) != len(changed) { + t.Fatal("regression requires same-size bytes") + } + if err := os.WriteFile(path, original, 0600); err != nil { + t.Fatal(err) + } + source, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer source.Close() + before, err := source.Stat() + if err != nil { + t.Fatal(err) + } + var consumed []byte + calls := 0 + transport := preparationTransport(func(r *http.Request) (*http.Response, error) { + calls++ + if err := os.WriteFile(path, changed, 0600); err != nil { + t.Fatal(err) + } + consumed, err = io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + _ = r.Body.Close() + if err := os.WriteFile(path, original, 0600); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, before.ModTime(), before.ModTime()); err != nil { + t.Fatal(err) + } + return &http.Response{StatusCode: 202, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(preparationAcceptedJSON))}, nil + }) + c := &Client{origin: "https://example.test", token: "test-token", guideHTTP: &http.Client{Transport: transport}} + expected := "" + if reviewed { + expected = fmt.Sprintf("sha256:%x", sha256.Sum256(original)) + } + result, err := c.PrepareSubmission(context.Background(), "30000000-0000-7000-8000-000000000001", "40000000-0000-7000-8000-000000000001", SubmissionPacket{Assignment: "20000000-0000-7000-8000-000000000001"}, nil, source, expected) + var failure *Failure + if calls != 1 || string(consumed) != string(changed) || !errors.As(err, &failure) || !failure.OutcomeUnknown || failure.Code != "submission_source_changed" || result.Value.PutAttemptID != "" { + t.Fatal("transient unreviewed stream was confirmed", calls, string(consumed), result, err) + } + }) + } +} + +func TestPreparationAcceptedReceiptRequiresCompleteConsumedBody(t *testing.T) { + for _, mode := range []string{"none", "partial", "complete"} { + t.Run(mode, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "bundle.zip") + if err := os.WriteFile(path, []byte("reviewed ZIP bytes"), 0600); err != nil { + t.Fatal(err) + } + source, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer source.Close() + transport := preparationTransport(func(r *http.Request) (*http.Response, error) { + if mode == "complete" { + _, _ = io.Copy(io.Discard, r.Body) + } + if mode == "partial" { + _, _ = io.ReadFull(r.Body, make([]byte, 1)) + } + _ = r.Body.Close() + return &http.Response{StatusCode: 202, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(preparationAcceptedJSON))}, nil + }) + c := &Client{origin: "https://example.test", token: "test-token", guideHTTP: &http.Client{Transport: transport}} + result, err := c.PrepareSubmission(context.Background(), "30000000-0000-7000-8000-000000000001", "40000000-0000-7000-8000-000000000001", SubmissionPacket{Assignment: "20000000-0000-7000-8000-000000000001"}, nil, source, "") + if mode == "complete" { + if err != nil || result.Value.Status != "stored_pending_verification" { + t.Fatal("complete matching stream refused", err) + } + } else { + var failure *Failure + if !errors.As(err, &failure) || !failure.OutcomeUnknown || result.Value.PutAttemptID != "" { + t.Fatal("incomplete stream confirmed", result, err) + } + } + }) + } +} + +func TestPreparationEarlyDenialDoesNotRequireConsumedBody(t *testing.T) { + for _, status := range []int{401, 403, 404, 422} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "bundle.zip") + if err := os.WriteFile(path, []byte("reviewed ZIP bytes"), 0600); err != nil { + t.Fatal(err) + } + source, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer source.Close() + transport := preparationTransport(func(r *http.Request) (*http.Response, error) { + body := `{"error":{"code":"permission_denied","message":"Denied","details":{},"correlation_id":"50000000-0000-7000-8000-000000000001","retryable":false}}` + if status == 422 { + body = `{"error":{"code":"pre_submission_checker_failed","message":"Intake failed","details":{"status":"failed","eligible_to_submit":false,"results":[{"source":"workstream_default","status":"failed","check_id":"manifest","failure_code":"manifest_missing","finding_count":1}]},"correlation_id":"50000000-0000-7000-8000-000000000001","retryable":false}}` + } + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil + }) + c := &Client{origin: "https://example.test", token: "test-token", guideHTTP: &http.Client{Transport: transport}} + _, err = c.PrepareSubmission(context.Background(), "30000000-0000-7000-8000-000000000001", "40000000-0000-7000-8000-000000000001", SubmissionPacket{Assignment: "20000000-0000-7000-8000-000000000001"}, nil, source, "") + var failure *Failure + if !errors.As(err, &failure) || failure.OutcomeUnknown || failure.Status != status || (status == 422 && len(failure.Details) == 0) { + t.Fatal("known early refusal became uncertain", err) + } + }) + } +} + +func TestPreparationSealsBodyBeforeReturningToCallerWithAsyncTransport(t *testing.T) { + path := filepath.Join(t.TempDir(), "bundle.zip") + if err := os.WriteFile(path, []byte("reviewed ZIP bytes"), 0600); err != nil { + t.Fatal(err) + } + source, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer source.Close() + release, done := make(chan struct{}), make(chan error, 1) + transport := preparationTransport(func(r *http.Request) (*http.Response, error) { + go func() { + <-release + _, readErr := r.Body.Read(make([]byte, 1)) + _ = r.Body.Close() + done <- readErr + }() + return &http.Response{StatusCode: 202, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(preparationAcceptedJSON))}, nil + }) + c := &Client{origin: "https://example.test", token: "test-token", guideHTTP: &http.Client{Transport: transport}} + _, err = c.PrepareSubmission(context.Background(), "30000000-0000-7000-8000-000000000001", "40000000-0000-7000-8000-000000000001", SubmissionPacket{Assignment: "20000000-0000-7000-8000-000000000001"}, nil, source, "") + _ = source.Close() // The owner may close immediately after PrepareSubmission. + close(release) + if readErr := <-done; readErr != io.ErrClosedPipe { + t.Fatal("late transport read reached caller-owned source", readErr) + } + var failure *Failure + if !errors.As(err, &failure) || !failure.OutcomeUnknown { + t.Fatal("unconsumed early 202 confirmed", err) + } +} diff --git a/cli/internal/tui/actions.go b/cli/internal/tui/actions.go index 55c6e1684..aa6bf182d 100644 --- a/cli/internal/tui/actions.go +++ b/cli/internal/tui/actions.go @@ -429,6 +429,9 @@ func (m *Model) writeSummary() string { return "Not sent" case outcomeConfirmed: if m.write.packet.action == "prepare" { + if m.write.preparation == nil { + return "Preparation receipt confirmed; protected details cleared" + } if m.write.preparation != nil && m.write.preparation.Status == "ready" { return "Preparation ready (not a Submission)" } diff --git a/cli/internal/tui/preparation_test.go b/cli/internal/tui/preparation_test.go index 10bed1b0c..46731c46b 100644 --- a/cli/internal/tui/preparation_test.go +++ b/cli/internal/tui/preparation_test.go @@ -329,6 +329,29 @@ func TestPreparationAuthFailureClearsProtectedTextAndRejectsLateResponse(t *test } } +func TestPreparationConfirmedReceiptClearingDoesNotInventNotReady(t *testing.T) { + for _, status := range []int{401, 403, 404} { + f := &fakeClient{} + m := preparationDetail(t, f) + confirmPreparation(t, m, preparationFile(t), "private summary", "private attestation") + press(t, m, "s") + press(t, m, "y") + if m.write.preparation == nil || m.write.preparation.Status != "ready" { + t.Fatal("fixture lacks prior ready receipt") + } + f.prepare = func(context.Context, string, string, api.SubmissionPacket, *api.RevisionAcknowledgement, *os.File, string) (api.Result[api.PreparationReceipt], error) { + return api.Result[api.PreparationReceipt]{}, &api.Failure{Status: status, Code: "permission_denied"} + } + press(t, m, "p") + press(t, m, "s") + press(t, m, "y") + if m.write.outcome != outcomeConfirmed || m.write.preparation != nil || m.canReplay() || strings.Contains(m.body(), "not ready") || + !strings.Contains(m.body(), "Preparation receipt confirmed; protected details cleared") || strings.Contains(m.body(), "private summary") { + t.Fatal("cleared receipt invented a non-ready storage outcome", status, m.body()) + } + } +} + func TestPreparationConfirmationResponsiveScrollableExactReview(t *testing.T) { for _, size := range [][2]int{{120, 40}, {80, 24}, {40, 10}} { m := preparationDetail(t, &fakeClient{}) diff --git a/cli/tests/integration/test_tui_http.py b/cli/tests/integration/test_tui_http.py index a37a832e4..df4d82a30 100644 --- a/cli/tests/integration/test_tui_http.py +++ b/cli/tests/integration/test_tui_http.py @@ -204,8 +204,12 @@ def preparation_confirm( terminal, path, key=CLAIM_KEY, summary="q", attestation="Exact attestation" ): terminal.send("a") + fill_preparation_form(terminal, path, key, summary, attestation) + + +def fill_preparation_form(terminal, path, key, summary, attestation): for label, value in ( - ("ZIP path", str(path)), + ("One readable, nonempty regular ZIP", str(path)), ("Summary", summary), ("Attestation", attestation), ("Caller-owned replay key", key), @@ -381,7 +385,9 @@ def test_initial_zip_preparation_sends_exact_packet_then_explicit_ready_replay( assert TOKEN.encode() not in terminal.raw -def test_preparation_failed_intake_displays_canonical_blocking_feedback(tmp_path): +def test_preparation_failed_intake_then_corrected_packet_waits_for_fresh_context( + tmp_path, +): path = tmp_path / "work.zip" preparation_zip(path) peer = PreparationWrites( @@ -418,7 +424,8 @@ def test_preparation_failed_intake_displays_canonical_blocking_feedback(tmp_path "retryable": False, } }, - ) + ), + (202, preparation()), ) posts = [] with ( @@ -440,6 +447,53 @@ def test_preparation_failed_intake_displays_canonical_blocking_feedback(tmp_path terminal.pump(0.2) assert len(posts) == 1 assert "X-Submission-Summary" not in posts[0][1] + entered, release = Event(), Event() + + def delayed_context(query): + assert query == {} + entered.set() + assert release.wait(10), ( + "test did not release corrected preparation context" + ) + return 200, peer.context + + peer.overrides[TASK_PATH + "/work-context"] = delayed_context + try: + terminal.send("\x1b") + terminal.wait(SECRET) + terminal.send("a") + assert entered.wait(5), ( + "corrected operation did not fetch fresh WorkContext" + ) + terminal.wait("Checking initial preparation context") + assert "One readable, nonempty regular ZIP" not in terminal.screen + # Input while authority context is pending is not a form entry and + # cannot be saved for later dispatch or trigger a second POST. + terminal.send("ignored-before-fresh-context\rsy") + terminal.pump(0.2) + assert len(posts) == 1 + release.set() + fill_preparation_form( + terminal, + path, + START_KEY, + "Corrected required summary", + "Exact attestation", + ) + assert "ignored-before-fresh-context" not in terminal.screen + assert len(posts) == 1, ( + "corrected form sent without deliberate confirmation" + ) + preparation_dispatch(terminal) + terminal.wait("stored_pending_verification") + assert len(posts) == 2 + assert posts[1][2] == path.read_bytes() + assert posts[1][1]["Idempotency-Key"] == START_KEY + assert posts[1][1]["X-Task-Assignment-Id"] == MINE_ASSIGNMENT + assert posts[1][1]["X-Submission-Summary"] == "Corrected required summary" + assert posts[1][1]["X-Contributor-Attestation"] == "Exact attestation" + finally: + release.set() terminal.quit() diff --git a/cli/tests/integration/test_tui_public_api.py b/cli/tests/integration/test_tui_public_api.py index f7ca4367b..0235bdad3 100644 --- a/cli/tests/integration/test_tui_public_api.py +++ b/cli/tests/integration/test_tui_public_api.py @@ -214,7 +214,7 @@ async def wait(text): async def confirm(path, key, summary): terminal.send("a") for label, value in ( - ("ZIP path", str(path)), + ("One readable, nonempty regular ZIP", str(path)), ("Summary", summary), ( "Attestation", diff --git a/docs/design/cli-terminal-experience.md b/docs/design/cli-terminal-experience.md index ea960f49a..fc4458c55 100644 --- a/docs/design/cli-terminal-experience.md +++ b/docs/design/cli-terminal-experience.md @@ -90,13 +90,16 @@ names actor, project, task, assignment, exact packet and key. `s` acknowledges external saving and `y` dispatches one upload through the shared typed client. No progress percentage, automatic polling or automatic Submission follows. -The shared client compares the confirmed hash with its own pre-send hash and -retains its post-send source verification. A changed file is not an exact +The shared client compares the confirmed hash with its own pre-send hash, +verifies the actual request stream's complete byte count and digest before +confirming success, and retains its post-send source verification. A changed file is not an exact replay. Pending requests hold their exact target; selection, resize and repeated keys cannot replace it. Cancellation ends the client request, not server effects. Unknown results retain their original packet/key/hash; an unsent local refusal or a later denied replay cannot erase that earlier uncertainty. Authority loss clears private packet/feedback text and disables in-session replay. +Cleared evidence is labelled unavailable, not converted into a new non-ready +observation when a ready receipt had already been confirmed. Show successful warning/coverage feedback and canonical blocking intake findings. A `ready` receipt exposes its admission; all other storage statuses