Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
102 commits
Select commit Hold shift + click to select a range
f29a4d7
feat(store): deduplicate admitted prompts by inbox identity
dnlrsls Sep 26, 2026
1d6fc86
feat(sync): retain admitted prompt identity across replicas
dnlrsls Sep 26, 2026
8ccfd20
fix(store): prevent deleted inbox prompt replay across restore
dnlrsls Sep 26, 2026
7607cf9
chore(review): establish prompt identity integration tracker
dnlrsls Sep 26, 2026
9b693b8
chore(review): anchor local identity to tracker
dnlrsls Sep 26, 2026
8f547a5
chore(review): anchor sync identity to local slice
dnlrsls Sep 26, 2026
e3477bf
chore(review): anchor deletion protection to sync slice
dnlrsls Sep 26, 2026
36b630d
test(store): check prompt sync cursor close error
dnlrsls Sep 26, 2026
066d0b6
chore(review): carry sync lint correction into deletion slice
dnlrsls Sep 26, 2026
5a92b84
test(server): require precise wrong-project prompt rejection
dnlrsls Sep 26, 2026
7ab5aa3
Merge commit '5a92b843d3ffc2022c32c20b522572455dba3a92' into dnlrsls/…
dnlrsls Sep 26, 2026
2bcec0c
Merge commit '7ab5aa35' into dnlrsls/issue-1458-f3-integration-rescue
dnlrsls Sep 26, 2026
e73173b
fix(store): retain tombstone identity on repeated pull delete
dnlrsls Sep 27, 2026
4350477
fix(store): preserve established prompt inbox identity on pull
dnlrsls Sep 27, 2026
ebf3509
chore(store): merge reviewed prompt identity sync correction
dnlrsls Sep 27, 2026
b56ed3a
fix(store): retain project scope on sparse prompt deletion
dnlrsls Sep 27, 2026
b44a88f
test(server): assert initial prompt inbox response identity
dnlrsls Sep 27, 2026
c2b056b
Merge F1 prompt response assertion into F2 foundation
dnlrsls Sep 27, 2026
6e870d1
fix(store): retain inbox identity during legacy prompt repair
dnlrsls Sep 27, 2026
77ecbd3
fix(store): adopt imported inbox ID on legacy prompt
dnlrsls Sep 27, 2026
bd95756
Merge F2 prompt inbox identity corrections into F3 foundation
dnlrsls Sep 27, 2026
72f84cc
fix(store): journal deletes for imported prompt tombstones
dnlrsls Sep 27, 2026
2c27fd4
test(store): cover legacy prompt inbox migration
dnlrsls Sep 27, 2026
da77c73
Merge F1 legacy inbox migration regression into F2
dnlrsls Sep 27, 2026
ed3e050
fix(store): reject cross-project prompt identity adoption
dnlrsls Sep 27, 2026
c505471
Merge F2 project-safe prompt import into F3
dnlrsls Sep 27, 2026
c1a4425
fix(store): reset prompt insert result on retry
dnlrsls Sep 27, 2026
d167859
Merge F1 retry-safe prompt result into F2
dnlrsls Sep 27, 2026
fd8b864
Merge F2 retry-safe prompt result into F3
dnlrsls Sep 27, 2026
be4157d
test(store): check competing prompt DB close error
dnlrsls Sep 27, 2026
0850928
Merge F1 retry test lint correction into F2
dnlrsls Sep 27, 2026
7477a85
Merge F2 retry test lint correction into F3
dnlrsls Sep 27, 2026
7ae13f9
fix(store): reject inbox tombstones without session identity
dnlrsls Sep 27, 2026
3f7e24f
docs(store): clarify imported inbox tombstone identity
dnlrsls Sep 27, 2026
f936e02
fix(store): tombstone pulled prompt deletes with live identity
dnlrsls Sep 27, 2026
835322b
Merge pull request #1465 from dnlrsls/feat/prompt-inbox-foundation-local
dnlrsls Sep 27, 2026
a5f4410
Merge pull request #1466 from dnlrsls/feat/prompt-inbox-foundation-sync
dnlrsls Sep 27, 2026
79a5d0f
Merge pull request #1469 from dnlrsls/feat/prompt-inbox-foundation-de…
dnlrsls Sep 27, 2026
e1d871e
fix(store): preserve prompt deletion ownership across sparse sync
dnlrsls Sep 27, 2026
bec5f23
fix(store): export resolved prompt tombstone ownership
dnlrsls Sep 27, 2026
37b7f07
Merge pull request #1492 from dnlrsls/fix/prompt-inbox-tracker-tombst…
dnlrsls Sep 27, 2026
0807ec2
fix(store): quarantine invalid pulled prompt delete identity
dnlrsls Sep 27, 2026
880f6b4
Merge pull request #1495 from dnlrsls/fix/prompt-inbox-pull-quarantine
dnlrsls Sep 27, 2026
5e1a4bf
fix(store): preserve established prompt tombstone identity
dnlrsls Sep 27, 2026
70e5a45
Merge pull request #1496 from dnlrsls/fix/prompt-tombstone-identity-r…
dnlrsls Sep 27, 2026
515d03c
fix(store): retain prompt tombstone project ownership
dnlrsls Sep 27, 2026
c592682
Merge pull request #1504 from dnlrsls/fix/prompt-tombstone-project-ow…
dnlrsls Sep 28, 2026
7788b92
fix(store): guard prompt owner on deletes and sync adoption
dnlrsls Sep 28, 2026
4caf891
test(store): cover inherited ownership and pulled conflict quarantine
dnlrsls Sep 28, 2026
ca66256
fix(store): journal adopted prompt identity after in-flight upserts
dnlrsls Sep 28, 2026
5061efe
fix(store): normalize adopted project and reject spoofed delete pairs
dnlrsls Sep 28, 2026
890b96c
Merge pull request #1505 from dnlrsls/fix/prompt-identity-owner-guards
dnlrsls Sep 28, 2026
379b870
fix(store): retain tombstone project on repeated sparse delete
dnlrsls Sep 28, 2026
313f526
Merge pull request #1514 from dnlrsls/fix/prompt-sparse-delete-owner
dnlrsls Sep 28, 2026
77520f2
docs(sync): define authenticated prompt inbox provenance
dnlrsls Sep 28, 2026
b2eb348
Merge pull request #1515 from dnlrsls/docs/prompt-pair-provenance
dnlrsls Sep 28, 2026
eb6766b
feat(cloudstore): persist explicit session ownership claims
dnlrsls Sep 28, 2026
ca79717
Merge pull request #1516 from dnlrsls/feat/prompt-session-authority-s…
dnlrsls Sep 28, 2026
d214eda
feat(cloud): register explicit session authority over HTTP
dnlrsls Sep 28, 2026
22e093d
fix(cloud): deny insecure registration before project lookup
dnlrsls Sep 28, 2026
098bfa4
Merge pull request #1519 from dnlrsls/feat/prompt-session-authority-api
dnlrsls Sep 28, 2026
80fa20b
feat(cloudstore): persist immutable prompt pair claims
dnlrsls Sep 28, 2026
8bc462b
Merge pull request #1523 from dnlrsls/feat/prompt-pair-claim-store
dnlrsls Sep 28, 2026
1d06cfb
feat(cloud): admit prompt pair claims with dual project authority
dnlrsls Sep 28, 2026
c5188d6
test(cloud): exercise repeated pair claim admission
dnlrsls Sep 28, 2026
bb66c92
Merge pull request #1524 from dnlrsls/feat/prompt-pair-claim-api
dnlrsls Sep 28, 2026
7c0df37
feat(cloud): expose provenance transport with distinct authority errors
dnlrsls Sep 28, 2026
7747831
test(cloud): make provenance network failure deterministic and satisf…
dnlrsls Sep 28, 2026
5754f54
Merge pull request #1525 from dnlrsls/feat/prompt-provenance-transpor…
dnlrsls Sep 28, 2026
5e74689
feat(store): record local session creation owner without promoting im…
dnlrsls Sep 28, 2026
dc333f1
test(store): cover pulled collisions and failed provenance reads
dnlrsls Sep 28, 2026
f94dce4
Merge pull request #1527 from dnlrsls/feat/prompt-provenance-client-h…
dnlrsls Sep 28, 2026
fde00a5
feat(store): mark locally created keyed prompt identities
dnlrsls Sep 28, 2026
bc7fd04
test(store): prove live prompt origin disappears on deletion
dnlrsls Sep 28, 2026
e2f3dbf
Merge pull request #1530 from dnlrsls/feat/prompt-pair-local-origin
dnlrsls Sep 28, 2026
ee92228
fix(store): preserve verified prompt origin after local deletion
dnlrsls Sep 29, 2026
d2647d1
Merge pull request #1535 from dnlrsls/feat/prompt-pair-tombstone-origin
dnlrsls Sep 29, 2026
76fa9f2
feat(cloud): expose prompt authority calls to autosync
dnlrsls Sep 29, 2026
169b394
Merge pull request #1537 from dnlrsls/feat/prompt-authority-transport…
dnlrsls Sep 29, 2026
e299891
feat(store): expose eligible pending mutation high-water
dnlrsls Sep 29, 2026
e7f5c14
Merge pull request #1539 from dnlrsls/feat/pending-mutation-high-water
dnlrsls Sep 29, 2026
d18c32d
feat(autosync): preflight keyed prompt authority before push
dnlrsls Sep 29, 2026
9411cfc
Merge pull request #1540 from dnlrsls/feat/eligible-prompt-autosync-p…
dnlrsls Sep 29, 2026
631db5f
fix(autosync): pull despite local prompt provenance blocks
dnlrsls Sep 29, 2026
1d70f3b
Merge pull request #1542 from dnlrsls/fix/prompt-preflight-pull-progress
dnlrsls Sep 29, 2026
27aa5ff
feat(store): preview one exact prompt source without authority
dnlrsls Sep 29, 2026
f800a69
Merge pull request #1545 from dnlrsls/feat/prompt-source-preview
dnlrsls Sep 29, 2026
7215a05
feat(cloudstore): record explicit prompt source attestations
dnlrsls Sep 29, 2026
aee38de
Merge pull request #1547 from dnlrsls/feat/prompt-source-attestation-…
dnlrsls Sep 29, 2026
5650e6c
feat(cloudserver): authorize explicit prompt source attestations
dnlrsls Sep 29, 2026
bd126db
Merge pull request #1548 from dnlrsls/feat/prompt-source-attestation-api
dnlrsls Sep 29, 2026
15e6f7c
feat(store): bind local prompt confirmations to cloud target
dnlrsls Sep 29, 2026
6409181
Merge pull request #1552 from dnlrsls/feat/prompt-source-confirmation…
dnlrsls Sep 29, 2026
9dc9cef
feat(remote): return validated prompt source attestation id
dnlrsls Sep 29, 2026
42a69f4
fix(remote): check attestation response close
dnlrsls Sep 29, 2026
f488f2d
Merge pull request #1553 from dnlrsls/feat/prompt-source-attestation-…
dnlrsls Sep 29, 2026
d5eb0a1
feat(cloud): confirm one prompt source via CLI
dnlrsls Sep 29, 2026
0869276
fix(cloud): check source attestation store close
dnlrsls Sep 29, 2026
f3badde
Merge pull request #1554 from dnlrsls/feat/prompt-source-attestation-cli
dnlrsls Sep 29, 2026
5c59900
feat(cloud): verify exact prompt source attestation
dnlrsls Sep 29, 2026
09d655d
fix(store): check rows.Close error in session export
Alan-TheGentleman Sep 29, 2026
01e9a6c
Merge pull request #1555 from dnlrsls/feat/prompt-attestation-verify-api
dnlrsls Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,16 @@ Engram exposes two different runtimes. Keep routes split by runtime:
- **Cloud runtime (`engram cloud serve`)**
- `GET /health` (cloud service health)
- `GET /sync/pull`, `GET /sync/pull/{chunkID}`, `POST /sync/push`, `POST /sync/mutations/push`, `GET /sync/mutations/pull` (cloud sync transport)
- `POST /sync/session-authorities` registers a session explicitly with JSON `session_id` and owner `project`.
Managed principals need a project grant; legacy authenticated tokens use the configured allowlist.
Grant normalization does not change the stored owner: a grant for `alpha-foo` can authorize `alpha/foo`, whose owner remains `alpha/foo`.
In insecure no-auth mode registration returns 401 without writing; oversized JSON returns 413.
Chunks and imported sessions never bootstrap registration: an imported/offline session needs deliberate reauthorization.
Matching owner replay returns 200; conflicting owner returns 409. Registration alone does not authorize prompt claims or deletes.
- `POST /sync/prompt-pair-claims` requires a bearer token and JSON `session_id`, `source_inbox_id`, `sync_id`, `owner_project`, and `project` (prompt storage project).
`owner_project` is an authorization selector, **not** authority: the server checks grants/legacy allowlist for both projects before looking up the independently registered session, then requires its stored owner to match the selector. Grant aliases do not rewrite stored project identity.
Missing registration, owner mismatch, and authority disappearance during claim all return the same generic 404 JSON body with `error_code: session_authority_unavailable`; an absent old-server route returns a 404 without that code. Matching claim replay returns 200, competing pair binding 409. Invalid JSON/unknown fields return 400, oversized bodies 413, and insecure no-auth mode 401.
`MutationTransport.RegisterSessionAuthority` and `ClaimPromptPair` can POST these JSON requests over the configured transport; non-200 responses are errors, with an absent old-server route classified as `server_unsupported`. Autosync does not invoke these methods yet. Clients must explicitly register the session under its owner and claim the pair under dual authorization before relying on remote pair provenance. Delete enforcement and the client handshake/pending retry path are not implemented yet; this route alone does not make unverified deletes safe.
- `GET /dashboard/*` HTML routes (browser dashboard)

Dashboard route tree (`engram cloud serve`):
Expand Down Expand Up @@ -780,7 +790,7 @@ Cloud server startup fails closed when the token is missing unless `ENGRAM_CLOUD
Cloud server always requires `ENGRAM_CLOUD_ALLOWED_PROJECTS` (comma-separated), including insecure mode, so project scope remains server-enforced.
`ENGRAM_CLOUD_TOKEN` + `ENGRAM_CLOUD_ALLOWED_PROJECTS` are server-side requirements for authenticated mode and must be configured before `engram cloud serve` (or compose startup).
Authenticated mode also requires an explicit non-default `ENGRAM_JWT_SECRET`; implicit development defaults are rejected.
Dashboard requests support browser login in authenticated mode: use `/dashboard/login` to exchange the bearer token for an HttpOnly dashboard cookie scoped to `/dashboard`. Protected `/dashboard/*` HTML routes require that cookie and do **not** treat raw `Authorization: Bearer ...` headers as an authenticated browser session. Sync API routes (`/sync/pull`, `/sync/pull/{chunkID}`, `/sync/push`, `/sync/mutations/push`, `/sync/mutations/pull`) remain header-auth only. For cloud authenticated sync and admin requests, the Authorization parser trims outer whitespace and requires exactly two whitespace-delimited fields: a case-insensitive `Bearer` scheme and one credential. Tabs or multiple spaces between fields are accepted; whitespace embedded in the credential and a scheme glued to the credential are rejected. This describes field separation, not an RFC credential-character grammar; it does not describe the local `ENGRAM_HTTP_TOKEN` parser. In insecure mode (`ENGRAM_CLOUD_INSECURE_NO_AUTH=1` + no `ENGRAM_CLOUD_TOKEN`), dashboard auth is bypassed and `/dashboard/login` redirects to `/dashboard/`.
Dashboard requests support browser login in authenticated mode: use `/dashboard/login` to exchange the bearer token for an HttpOnly dashboard cookie scoped to `/dashboard`. Protected `/dashboard/*` HTML routes require that cookie and do **not** treat raw `Authorization: Bearer ...` headers as an authenticated browser session. Sync API routes (`/sync/pull`, `/sync/pull/{chunkID}`, `/sync/push`, `/sync/mutations/push`, `/sync/mutations/pull`, `/sync/session-authorities`, `/sync/prompt-pair-claims`) remain header-auth only. For cloud authenticated sync and admin requests, the Authorization parser trims outer whitespace and requires exactly two whitespace-delimited fields: a case-insensitive `Bearer` scheme and one credential. Tabs or multiple spaces between fields are accepted; whitespace embedded in the credential and a scheme glued to the credential are rejected. This describes field separation, not an RFC credential-character grammar; it does not describe the local `ENGRAM_HTTP_TOKEN` parser. In insecure mode (`ENGRAM_CLOUD_INSECURE_NO_AUTH=1` + no `ENGRAM_CLOUD_TOKEN`), dashboard auth is bypassed and `/dashboard/login` redirects to `/dashboard/`.

`ENGRAM_CLOUD_ADMIN` is optional in authenticated mode. Its sessions can access the existing dashboard admin read surfaces, project sync controls, and audit logs, but managed-user, token, and project-grant mutations require a managed admin token.
`ENGRAM_CLOUD_ADMIN` is rejected in insecure mode (`ENGRAM_CLOUD_INSECURE_NO_AUTH=1`) to avoid an incoherent admin/browser auth path.
Expand Down
71 changes: 71 additions & 0 deletions cmd/engram/autosync_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"sync"
"sync/atomic"
"testing"
Expand All @@ -18,6 +19,49 @@ import (
_ "modernc.org/sqlite"
)

func TestMutationTransportAdapterForwardsPromptAuthority(t *testing.T) {
var paths []string
var bodies []map[string]string
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS == nil || r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer test-token" {
t.Errorf("unexpected request security or method: TLS=%v method=%s auth=%q", r.TLS != nil, r.Method, r.Header.Get("Authorization"))
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode request: %v", err)
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
paths = append(paths, r.URL.Path)
bodies = append(bodies, body)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}))
defer srv.Close()
trustTLSServer(t, srv)

mt, err := remote.NewMutationTransport(srv.URL, "test-token")
if err != nil {
t.Fatal(err)
}
adapter := &mutationTransportAdapter{remote: mt}
if err := adapter.RegisterSessionAuthority("session-1", "alpha"); err != nil {
t.Fatal(err)
}
if err := adapter.ClaimPromptPair("session-1", "inbox-2", "sync-3", "alpha", "beta"); err != nil {
t.Fatal(err)
}
wantPaths := []string{"/sync/session-authorities", "/sync/prompt-pair-claims"}
wantBodies := []map[string]string{
{"session_id": "session-1", "project": "alpha"},
{"session_id": "session-1", "source_inbox_id": "inbox-2", "sync_id": "sync-3", "owner_project": "alpha", "project": "beta"},
}
if !reflect.DeepEqual(paths, wantPaths) || !reflect.DeepEqual(bodies, wantBodies) {
t.Fatalf("requests: paths=%v bodies=%v; want paths=%v bodies=%v", paths, bodies, wantPaths, wantBodies)
}
}

// ─── E2E Round-trip test (REQ-212) ───────────────────────────────────────────

// TestAutosyncPushPullRoundTrip tests the full push/pull cycle using a real
Expand Down Expand Up @@ -305,6 +349,33 @@ func (s *autosyncFakeStore) ListPendingSyncMutations(_ string, limit int) ([]sto
return result, nil
}

func (s *autosyncFakeStore) MaxPendingSyncMutationSeq(string) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
var max int64
for _, mutation := range s.mutations {
max = mutation.seq
}
return max, nil
}

func (s *autosyncFakeStore) ListPendingSyncMutationsAfterSeq(target string, after int64, limit int) ([]store.SyncMutation, error) {
pending, err := s.ListPendingSyncMutations(target, len(s.mutations))
if err != nil {
return nil, err
}
var page []store.SyncMutation
for _, mutation := range pending {
if mutation.Seq > after {
page = append(page, mutation)
if len(page) == limit {
break
}
}
}
return page, nil
}

func (s *autosyncFakeStore) CountPendingNonEnrolledSyncMutations(_ string) ([]store.PendingSyncMutationProjectCount, error) {
return nil, nil
}
Expand Down
8 changes: 5 additions & 3 deletions cmd/engram/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,12 +222,12 @@ var runUpgradeRemirror = func(s *store.Store, project string, cc *cloudconfig.Co
func cmdCloud(cfg store.Config) {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: engram cloud <subcommand> [options]")
fmt.Fprintln(os.Stderr, "supported subcommands: status, enroll, unenroll, config, serve, upgrade, repair, bootstrap")
fmt.Fprintln(os.Stderr, "supported subcommands: status, enroll, unenroll, config, serve, upgrade, repair, bootstrap, attest-prompt-source")
exitFunc(1)
}
if os.Args[2] == "--help" || os.Args[2] == "-h" || os.Args[2] == "help" {
fmt.Println("usage: engram cloud <subcommand> [options]")
fmt.Println("supported subcommands: status, enroll, unenroll, config, serve, upgrade, repair, bootstrap")
fmt.Println("supported subcommands: status, enroll, unenroll, config, serve, upgrade, repair, bootstrap, attest-prompt-source")
return
}

Expand All @@ -248,9 +248,11 @@ func cmdCloud(cfg store.Config) {
cmdCloudRepair()
case "bootstrap":
cmdCloudBootstrap()
case "attest-prompt-source":
cmdCloudAttestPromptSource(cfg)
default:
fmt.Fprintf(os.Stderr, "unknown cloud command: %s\n", os.Args[2])
fmt.Fprintln(os.Stderr, "supported subcommands: status, enroll, unenroll, config, serve, upgrade, repair, bootstrap")
fmt.Fprintln(os.Stderr, "supported subcommands: status, enroll, unenroll, config, serve, upgrade, repair, bootstrap, attest-prompt-source")
exitFunc(1)
}
}
Expand Down
119 changes: 119 additions & 0 deletions cmd/engram/cloud_prompt_source_attestation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package main

import (
"bufio"
"fmt"
"io"
"net/url"
"os"
"strings"

"github.com/Gentleman-Programming/engram/v2/internal/cloud/remote"
"github.com/Gentleman-Programming/engram/v2/internal/cloudconfig"
"github.com/Gentleman-Programming/engram/v2/internal/store"
)

const promptSourceAttestationUsage = "usage: engram cloud attest-prompt-source --sync-id <exact-sync-id> --owner-project <asserted-owner>"

type promptSourceAttester interface {
AttestPromptSource(sessionID, sourceInboxID, syncID, ownerProject, promptProject string) (int64, error)
}

// The preview is observed data; the owner is a separate human assertion.
func attestPromptSource(s *store.Store, syncID, owner, endpoint string, remote promptSourceAttester, input io.Reader, output io.Writer) error {
preview, found, err := s.PreviewPromptSource(syncID)
if err != nil {
return fmt.Errorf("prompt source preview failed: %w", err)
}
if !found {
return fmt.Errorf("no unambiguous complete prompt source preview for exact sync ID")
}
if _, err := fmt.Fprintf(output, "Observed source: session=%q source_inbox=%q sync_id=%q prompt_project=%q kind=%q\nHuman-asserted owner: %q\n", preview.SessionID, preview.SourceInboxID, preview.SyncID, preview.Project, preview.Kind, owner); err != nil {
return fmt.Errorf("attestation preview output failed: %w", err)
}
if _, err := fmt.Fprint(output, "Send this exact tuple for remote attestation? Type yes or no: "); err != nil {
return fmt.Errorf("attestation confirmation question output failed: %w", err)
}
line, err := bufio.NewReader(input).ReadString('\n')
if err != nil && err != io.EOF {
return fmt.Errorf("confirmation input failed: %w", err)
}
if strings.TrimSpace(line) != "yes" {
if strings.TrimSpace(line) == "no" {
return fmt.Errorf("attestation declined; no remote call made")
}
return fmt.Errorf("invalid confirmation; type yes or no; no remote call made")
}
id, err := remote.AttestPromptSource(preview.SessionID, preview.SourceInboxID, preview.SyncID, owner, preview.Project)
if err != nil {
return fmt.Errorf("remote attestation failed: %w", err)
}
if id <= 0 {
return fmt.Errorf("remote attestation returned no positive ID; local confirmation not saved")
}
if err := s.ConfirmPromptSourceAttestation(endpoint, preview, owner, id); err != nil {
return fmt.Errorf("remote attestation succeeded (ID %d), but local confirmation failed: %w; do not assume local authorization", id, err)
}
if _, err := fmt.Fprintf(output, "Remote attestation ID %d confirmed locally for sync ID %q\n", id, syncID); err != nil {
return fmt.Errorf("remote attestation confirmed locally, but success output failed: %w", err)
}
return nil
}

// Validate the endpoint before creating transport or persisting it as remote_target.
func promptSourceAttestationEndpoint(raw string) (string, error) {
endpoint, err := cloudconfig.ValidateServerURL(raw)
if err != nil {
return "", fmt.Errorf("invalid cloud server URL")
}
parsed, err := url.Parse(endpoint)
if err != nil || parsed.User != nil {
return "", fmt.Errorf("cloud server URL credentials are not allowed")
}
return endpoint, nil
}

func cmdCloudAttestPromptSource(cfg store.Config) {
args := os.Args[3:]
if len(args) == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "help") {
fmt.Println(promptSourceAttestationUsage)
return
}
if len(args) != 4 || args[0] != "--sync-id" || args[2] != "--owner-project" || strings.TrimSpace(args[1]) == "" || strings.TrimSpace(args[3]) == "" {
fmt.Fprintln(os.Stderr, promptSourceAttestationUsage)
fatal(fmt.Errorf("one exact sync ID and an asserted owner project are required"))
return
}
cc, err := resolveCloudRuntimeConfig(cfg)
if err != nil {
fatal(fmt.Errorf("cloud runtime configuration unavailable"))
return
}
if cc == nil || cc.ServerURL == "" {
fatal(fmt.Errorf("cloud server URL is required"))
return
}
endpoint, err := promptSourceAttestationEndpoint(cc.ServerURL)
if err != nil {
fatal(err)
return
}
if strings.TrimSpace(cc.Token) == "" {
fatal(fmt.Errorf("human bearer token required for remote dual-grant enforcement"))
return
}
transport, err := remote.NewMutationTransport(endpoint, cc.Token)
if err != nil {
fatal(fmt.Errorf("cloud transport configuration rejected"))
return
}
s, err := storeNew(cfg)
if err != nil {
fatal(fmt.Errorf("local store unavailable: %w", err))
return
}
defer func() { _ = s.Close() }()
if err := attestPromptSource(s, args[1], args[3], endpoint, transport, os.Stdin, os.Stdout); err != nil {
fatal(err)
}
}
Loading
Loading