7.8 KiB
Persimmons Juice v0
Juice derives a Gitea PAT in the unlocked extension from an encrypted Master credential and writes only that derived PAT into a separate named repository vault. The product page sends source ID, target ID/name and selected scopes. Source passwords never pass through the product page, Juice backend or MCP. The extension's existing in-memory key and capture/fill crypto are unchanged.
Supported source origins are exactly https://teahub.xyz and
https://git.persimmons.app. Other origins display “Juice adapter not available
yet.” Extension host permissions and connect-src enumerate those providers;
externally_connectable remains only the Persimmons top frame. The service worker
checks the live owner connection, initialization and unlock before decrypting.
Provider calls use Basic auth, omit cookies, refuse redirects and have timeouts.
The product receives no PAT response. No new MCP surface exists.
Choices are read:repository, read:issue, and write:issue (which includes
issue reads). These are provider API-class-wide scopes, bounded by the source
account's authority, not permissions limited to the target repo or a single
source repository. No expiry is promised. all, admin, user, organization,
package and repository-write scopes are not accepted. The modal and every Juice
card state this scope boundary.
Backend operations are POST /api/juice/{list,prepare,write,remove,cleanup}.
Only the exact pinned extension Origin can prepare, submit a PAT or confirm
provider cleanup; the product page can list safe metadata or remove a target
secret. All operations require a live cookie session, matching X-Vault-Owner,
and a locally recorded named vault whose immutable repo ID, live owner and
privacy are checked against dedicated Gitea. There is no bearer fallback,
caller-selected provider host, secret-read operation or raw source input.
Actions secrets are named PERSIM_JUICE_<operation ID>. Safe operation metadata
uses the same name in Actions variables, which Gitea normalizes to uppercase.
The backend projects only its strict metadata schema. PATs enter only the
write-only secret PUT body, never metadata, sessions, logs, receipts or returns.
The dedicated runtime token adds write:repository via the existing independent
Persimmons launch/bootstrap workflow. Its versioned root-only token file keeps
bootstrap resumable; the previous bootstrap token is retained for rollback.
Actions workflow execution remains disabled.
An operation reserves metadata before creating persimmons-juice-<ID> on the
source provider. This deterministic name permits cleanup even if the create
response is lost. Write failure triggers best-effort source revocation and
target removal. Unconfirmed cleanup returns ANDON and leaves a visible ledger
entry. Worker loss leaves pending metadata for explicit Revoke; it does not
persist a plaintext PAT or automatically retry token creation.
Rotate first writes a new operation/secret, then removes and revokes the old operation. Interrupted rotation can leave two provider tokens, represented by separate cards; the result explicitly directs Revoke on the old card. Revoke always attempts target removal first. Without an available unlocked source, the target is absent but provider revocation remains unknown. Revoke can be retried after reconnecting/unlocking; revocation tombstones remain visible. Metadata describing a stored token records the last confirmed operation, not continuous provider health monitoring.
Inspected before implementation: live Teahub Gitea 1.27.3 OpenAPI, dedicated Gitea 1.27.3 authenticated API, and pinned source: API router, token create/delete, and Gitea API scope documentation.
Validation commands:
python persimmons/build.py
python -m unittest tests.test_persimmons_host tests.test_persimmons_gitea tests.test_persimmons_ux tests.test_juice_target
node --test persimmons-extension/tests/*.test.mjs
node tests/juice-provider-integration.mjs
node tests/persimmons-host-browser.cjs
The provider probe uses a disposable generated non-admin account, checks scoped
read/write enforcement, confirms Actions secrets cannot be read, and revokes its
PAT. Credentials stay in process memory and never enter output or files. The
browser harness retains capture/fill/lock/signout acceptance and exercises Gitea
capture, drag, target secret/metadata, rotation and revoke in a fresh profile.
Deployment is restricted to persimmons-launch.yml after local checks pass.
Accepted live receipt — 2026-10-01
Implemented in dfa10102d94f1c962bebc042c80a7de577430897; final deployed runtime
is a2ea1c770db8300c0da75db5894e6220ead8ccb4. Both releases used only the
independent Persimmons launch workflow. Final launch run 1022,
job 1024, completed successfully, including contract tests, provider authority,
deployment and HTTP acceptance. Before both publishes/deploys, explicit fetches
proved behind=0; dispatch receipts had HEAD=origin/main, ahead=0 and dirty=false.
No delegation occurred.
Local checks passed: 42 host/Gitea/UX/Juice Python tests, 8 encrypted-vault
and legacy Juice/MCP regression tests, and 24 extension tests across nine
files. The launch workflow repeated the host/Gitea/UX/Juice and extension suites.
The disposable provider integration confirmed Gitea 1.27.3, read:repository
access, denied repository writes, write-only secrets, uppercase metadata names,
and failed authentication after revocation. Its accepted provider fixture was
juice-probe-93b0fee3e1977e07, repo ID 10; provider PAT and target test secret were
removed. Disposable test users/private repositories remain as test evidence.
Final browser acceptance used Chromium 153.0.8010.12, extension 0.5.0,
and a fresh profile with no UI asset interception. User
browser-17b3e5a0b314d214 (non-admin ID 20) created private repo work-test,
immutable repo ID 19. The actual Gitea login form was captured into Master,
then a native mouse drag emitted dragstart/drop/dragend and opened the modal.
The target stayed open after success. Source credential plaintext was neither
sent to the product page nor exposed in metadata.
The extension worker independently verified two newly issued PATs could read the intended private repository and both returned 401 after their respective revocations. Only boolean counters left that verification closure. The real backend accepted two extension-only PAT writes. Target Actions secret listing exposed names only; secret GET was unavailable. Rotate removed/revoked the old operation and installed a working replacement. Revoke left zero provider PATs and zero target secrets. Safe revocation metadata remains in Actions variables. The accepted run removed its Master ciphertext, logged out, and removed its browser profile. The preserved WordPress synthetic capture/fill test passed with zero form submissions; lock/unlock and logout connection clearing also passed. The harness pregrants the exact optional Persimmons host permission and does not claim to automate Chromium's native permission prompt. Timed auto-lock was selected through the existing extension setting to keep the vault unlocked while interacting with the page.
Live /healthz returned the exact final runtime source above. Live artifact
bytes matched the local deterministic build:
- app.js SHA-256:
4fc7ab21660660f0d6a3e958c7c54c65bd6d941d12f753a035c7d89d2a074960 - extension.zip SHA-256:
fefa7eeda05543b5216316e8792d32c7358a0f57284ed231490219ef8dd8eb2d
This receipt-only follow-up does not change deployed runtime bytes. Provider scope limitations, lack of automatic expiry, and explicit partial-failure handling remain as documented above.