diff --git a/.github/syft-runtime.yaml b/.github/syft-runtime.yaml new file mode 100644 index 0000000..3938fc9 --- /dev/null +++ b/.github/syft-runtime.yaml @@ -0,0 +1,2 @@ +select-catalogers: + - +javascript-package-cataloger diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5d2a65..84286bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,10 +41,13 @@ jobs: if (process.env.GITHUB_REF_NAME !== expected) throw new Error(`tag ${process.env.GITHUB_REF_NAME ?? ""} does not match ${expected}`);' + - name: Bind release metadata + run: printf 'HRA_RELEASE_VERSION=%s\n' "${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + - name: Verify exact release head and ordering run: | git fetch --force origin main --tags - tagged_commit="$(git rev-parse "$GITHUB_REF_NAME^{commit}")" + tagged_commit="$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" checked_out_commit="$(git rev-parse 'HEAD^{commit}')" main_commit="$(git rev-parse 'origin/main^{commit}')" test "$tagged_commit" = "$checked_out_commit" @@ -55,6 +58,16 @@ jobs: - name: Verify generated public documents run: bun run build:site -- --check + - name: Verify public release availability + run: >- + bun -e 'const content = await import("./site/content.ts"); + const template = await import("./site/template.ts"); + const surfaces = [content.renderReadmeMarkdown(), content.renderPrivacyMarkdown(), template.renderSiteHtml()]; + const forbidden = ["beta-not-yet-live", "hosted sync service is not live", "website is not live"]; + const endpoints = content.publicContent.endpoints; + if (content.publicReleaseState !== "release-ready" || endpoints.betaTag !== "release-ready" || endpoints.githubRepository !== "live" || endpoints.hostedSync !== "live" || endpoints.website !== "live" || surfaces.some((surface) => forbidden.some((marker) => surface.toLowerCase().includes(marker)))) + throw new Error("public release availability is not ready");' + - name: Run the repository gate run: bun run check @@ -64,31 +77,93 @@ jobs: bun pm pack --destination release mv "release/hra-${GITHUB_REF_NAME#v}.tgz" "release/hra-${GITHUB_REF_NAME}.tgz" - - name: Generate the SPDX SBOM + - name: Accept the exact packed installation + env: + BUN_INSTALL: ${{ runner.temp }}/hra-global + run: | + test "$(uname -s)" = Linux + test "$(uname -m)" = x86_64 + bun add --global --ignore-scripts "./release/hra-${GITHUB_REF_NAME}.tgz" + bun ./scripts/check-installed-package.ts "$BUN_INSTALL/install/global/node_modules/hra" + test "$("$BUN_INSTALL/bin/hra" --version)" = "hra ${GITHUB_REF_NAME#v}" + "$BUN_INSTALL/bin/hra" doctor --offline --json | bun -e 'const value = JSON.parse(await Bun.stdin.text()); if (value?.ok !== true || value?.data?.offline !== true) throw new Error("offline doctor failed");' + + - name: Generate the artifact identity SPDX SBOM uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 + env: + SYFT_SOURCE_NAME: hra + SYFT_SOURCE_VERSION: ${{ env.HRA_RELEASE_VERSION }} with: - artifact-name: hra-${{ github.ref_name }}.spdx.json + artifact-name: hra-${{ github.ref_name }}.artifact.spdx.json + file: release/hra-${{ github.ref_name }}.tgz format: spdx-json - output-file: release/hra-${{ github.ref_name }}.spdx.json - path: . + output-file: release/hra-${{ github.ref_name }}.artifact.spdx.json syft-version: v1.51.0 upload-artifact: false upload-release-assets: false + - name: Verify the artifact identity SPDX SBOM + run: >- + bun -e 'const archive = Bun.file(`release/hra-${process.env.GITHUB_REF_NAME}.tgz`); + const value = await Bun.file(`release/hra-${process.env.GITHUB_REF_NAME}.artifact.spdx.json`).json(); + if (typeof value !== "object" || value === null || !Array.isArray(value.packages)) throw new Error("SPDX packages are missing"); + const exact = value.packages.find((entry) => entry && typeof entry === "object" && entry.name === "hra" && entry.versionInfo === process.env.HRA_RELEASE_VERSION); + if (!exact || !Array.isArray(exact.checksums)) throw new Error("exact HRA artifact identity is missing from SPDX"); + const expected = new Bun.CryptoHasher("sha256").update(await archive.arrayBuffer()).digest("hex"); + if (!exact.checksums.some((entry) => entry && typeof entry === "object" && entry.algorithm === "SHA256" && entry.checksumValue === expected)) throw new Error("artifact SPDX checksum does not bind the tarball");' + + - name: Generate the Ubuntu 24.04 x64 runtime SPDX SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 + env: + SYFT_SOURCE_NAME: hra-ubuntu-24.04-x64-runtime + SYFT_SOURCE_VERSION: ${{ env.HRA_RELEASE_VERSION }} + with: + artifact-name: hra-${{ github.ref_name }}.ubuntu-24.04-x64.runtime.spdx.json + config: .github/syft-runtime.yaml + format: spdx-json + output-file: release/hra-${{ github.ref_name }}.ubuntu-24.04-x64.runtime.spdx.json + path: ${{ runner.temp }}/hra-global/install/global/node_modules + syft-version: v1.51.0 + upload-artifact: false + upload-release-assets: false + + - name: Verify the Ubuntu 24.04 x64 runtime SPDX SBOM + run: >- + bun -e 'const value = await Bun.file(`release/hra-${process.env.GITHUB_REF_NAME}.ubuntu-24.04-x64.runtime.spdx.json`).json(); + if (typeof value !== "object" || value === null || !Array.isArray(value.packages)) throw new Error("runtime SPDX packages are missing"); + const required = [["hra", process.env.HRA_RELEASE_VERSION], ["@openai/codex", "0.149.0"], ["convex", "1.45.0"], ["zod", "4.4.3"]]; + for (const [name, version] of required) if (!value.packages.some((entry) => entry && typeof entry === "object" && entry.name === name && entry.versionInfo === version)) throw new Error(`runtime SPDX is missing ${name}@${version}`);' + - name: Write checksums working-directory: release - run: shasum -a 256 hra-*.tgz hra-*.spdx.json > SHA256SUMS + run: >- + shasum -a 256 + "hra-${GITHUB_REF_NAME}.tgz" + "hra-${GITHUB_REF_NAME}.artifact.spdx.json" + "hra-${GITHUB_REF_NAME}.ubuntu-24.04-x64.runtime.spdx.json" + > SHA256SUMS + + - name: Preserve the reviewed release metadata + run: | + bun -e 'const notes = await Bun.file("docs/beta-release-notes.md").text(); if (notes.trim().length === 0) throw new Error("release notes are empty"); await Bun.write("release/RELEASE_NOTES.md", notes.trimEnd());' + git rev-parse 'HEAD^{commit}' > release/RELEASE_COMMIT - name: Preserve verified release artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: hra-release-${{ github.ref_name }} - path: release/ + path: | + release/hra-${{ github.ref_name }}.tgz + release/hra-${{ github.ref_name }}.artifact.spdx.json + release/hra-${{ github.ref_name }}.ubuntu-24.04-x64.runtime.spdx.json + release/SHA256SUMS + release/RELEASE_NOTES.md + release/RELEASE_COMMIT if-no-files-found: error retention-days: 1 - publish: - name: Publish immutable release + stage: + name: Stage verified release draft needs: verify runs-on: ubuntu-24.04 timeout-minutes: 10 @@ -101,12 +176,98 @@ jobs: name: hra-release-${{ github.ref_name }} path: release - - name: Create the GitHub release + - name: Create or resume the accepted release draft env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - run: >- - gh release create "$GITHUB_REF_NAME" release/* - --verify-tag - --generate-notes - --title "HRA $GITHUB_REF_NAME" + run: | + test "$(wc -l < release/RELEASE_COMMIT | tr -d ' ')" = 1 + grep -Eq '^[0-9a-f]{40}$' release/RELEASE_COMMIT + accepted_commit="$(tr -d '\n' < release/RELEASE_COMMIT)" + tag_commit="$(gh api "repos/$GH_REPO/commits/refs/tags/$GITHUB_REF_NAME" --jq '.sha')" + main_commit="$(gh api "repos/$GH_REPO/git/ref/heads/main" --jq '.object.sha')" + test "$accepted_commit" = "$GITHUB_SHA" + test "$tag_commit" = "$accepted_commit" + test "$main_commit" = "$accepted_commit" + marker_status="$(curl --fail --silent --show-error --proto '=https' --tlsv1.2 \ + --connect-timeout 10 --max-time 30 --retry 2 --retry-all-errors --retry-delay 1 --retry-max-time 60 \ + --header 'Cache-Control: no-cache, no-store, max-age=0' --header 'Pragma: no-cache' \ + --output canonical-marker-draft.json \ + --write-out '%{http_code}' \ + "https://hra.sh/.well-known/hra.json?release-check=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-draft")" + test "$marker_status" = 200 + jq -e --arg commit "$accepted_commit" \ + 'type == "object" and .schemaVersion == 2 and .generation == 1 and .product == "HRA" and .repository.id == 1343008607 and .repository.path == "hraness/hra" and .version == "0.1.0" and .source.commit == $commit' \ + canonical-marker-draft.json > /dev/null + release_ids="$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$GITHUB_REF_NAME\") | .id")" + release_count="$(printf '%s\n' "$release_ids" | sed '/^$/d' | wc -l | tr -d ' ')" + test "$release_count" = 0 -o "$release_count" = 1 + if test "$release_count" = 0; then + gh release create "$GITHUB_REF_NAME" \ + --verify-tag \ + --draft \ + --prerelease \ + --notes-file release/RELEASE_NOTES.md \ + --title "HRA $GITHUB_REF_NAME" + fi + release_id="$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$GITHUB_REF_NAME\") | .id")" + test "$(printf '%s\n' "$release_id" | sed '/^$/d' | wc -l | tr -d ' ')" = 1 + test "$(gh api "repos/$GH_REPO/releases/$release_id" --jq '.draft')" = true + test "$(gh api "repos/$GH_REPO/releases/$release_id" --jq '.immutable')" = false + test "$(gh api "repos/$GH_REPO/releases/$release_id" --jq '.prerelease')" = true + test "$(gh api "repos/$GH_REPO/releases/$release_id" --jq '.name')" = "HRA $GITHUB_REF_NAME" + test "$(gh api "repos/$GH_REPO/releases/$release_id" --jq '.body')" = "$(cat release/RELEASE_NOTES.md)" + gh release upload "$GITHUB_REF_NAME" \ + "release/hra-${GITHUB_REF_NAME}.tgz" \ + "release/hra-${GITHUB_REF_NAME}.artifact.spdx.json" \ + "release/hra-${GITHUB_REF_NAME}.ubuntu-24.04-x64.runtime.spdx.json" \ + release/SHA256SUMS \ + --clobber + + - name: Read back the staged draft assets + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + release_ids="$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$GITHUB_REF_NAME\" and .draft == true) | .id")" + test "$(printf '%s\n' "$release_ids" | sed '/^$/d' | wc -l | tr -d ' ')" = 1 + release_id="$release_ids" + mkdir published-draft + assets="$(gh api --paginate "repos/$GH_REPO/releases/$release_id/assets?per_page=100" --jq '.[] | [.id, .name] | @tsv')" + test "$(printf '%s\n' "$assets" | sed '/^$/d' | wc -l | tr -d ' ')" = 4 + while IFS=$'\t' read -r asset_id asset_name; do + case "$asset_name" in + "hra-${GITHUB_REF_NAME}.tgz"|"hra-${GITHUB_REF_NAME}.artifact.spdx.json"|"hra-${GITHUB_REF_NAME}.ubuntu-24.04-x64.runtime.spdx.json"|SHA256SUMS) ;; + *) exit 1 ;; + esac + gh api -H "Accept: application/octet-stream" "repos/$GH_REPO/releases/assets/$asset_id" > "published-draft/$asset_name" + done <<< "$assets" + cmp release/hra-${GITHUB_REF_NAME}.tgz published-draft/hra-${GITHUB_REF_NAME}.tgz + cmp release/hra-${GITHUB_REF_NAME}.artifact.spdx.json published-draft/hra-${GITHUB_REF_NAME}.artifact.spdx.json + cmp release/hra-${GITHUB_REF_NAME}.ubuntu-24.04-x64.runtime.spdx.json published-draft/hra-${GITHUB_REF_NAME}.ubuntu-24.04-x64.runtime.spdx.json + cmp release/SHA256SUMS published-draft/SHA256SUMS + test "$(gh api "repos/$GH_REPO/releases/$release_id" --jq '.tag_name')" = "$GITHUB_REF_NAME" + test "$(gh api "repos/$GH_REPO/releases/$release_id" --jq '.name')" = "HRA $GITHUB_REF_NAME" + test "$(gh api "repos/$GH_REPO/releases/$release_id" --jq '.draft')" = true + test "$(gh api "repos/$GH_REPO/releases/$release_id" --jq '.immutable')" = false + test "$(gh api "repos/$GH_REPO/releases/$release_id" --jq '.prerelease')" = true + test "$(gh api "repos/$GH_REPO/releases/$release_id" --jq '.body')" = "$(cat release/RELEASE_NOTES.md)" + cd published-draft + shasum -a 256 -c SHA256SUMS + cd .. + accepted_commit="$(tr -d '\n' < release/RELEASE_COMMIT)" + tag_commit="$(gh api "repos/$GH_REPO/commits/refs/tags/$GITHUB_REF_NAME" --jq '.sha')" + main_commit="$(gh api "repos/$GH_REPO/git/ref/heads/main" --jq '.object.sha')" + test "$accepted_commit" = "$GITHUB_SHA" + test "$tag_commit" = "$accepted_commit" + test "$main_commit" = "$accepted_commit" + marker_status="$(curl --fail --silent --show-error --proto '=https' --tlsv1.2 \ + --connect-timeout 10 --max-time 30 --retry 2 --retry-all-errors --retry-delay 1 --retry-max-time 60 \ + --header 'Cache-Control: no-cache, no-store, max-age=0' --header 'Pragma: no-cache' \ + --output canonical-marker-publish.json \ + --write-out '%{http_code}' \ + "https://hra.sh/.well-known/hra.json?release-check=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-staged")" + test "$marker_status" = 200 + jq -e --arg commit "$accepted_commit" \ + 'type == "object" and .schemaVersion == 2 and .generation == 1 and .product == "HRA" and .repository.id == 1343008607 and .repository.path == "hraness/hra" and .version == "0.1.0" and .source.commit == $commit' \ + canonical-marker-publish.json > /dev/null diff --git a/PRIVACY.md b/PRIVACY.md index fcb3fed..df9cdff 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -24,11 +24,17 @@ Cloud sync is optional. Local account profiles, Codex credentials, and local exe The sync service necessarily sees the verified HRA email address, device identifiers, record types, revisions, ciphertext sizes, timestamps, and execution-lease or command lifecycle metadata. It cannot decrypt session content without a paired device key. Email access alone does not recover that key. +HRA uses Convex to authenticate the HRA identity and store server-visible metadata plus encrypted projections. Convex receives the verified email address and the service metadata described above, but not the keys required to decrypt session content. + +HRA uses Resend to deliver verification email. Resend receives the recipient email address, sender identity, one-time verification code and message content, and ordinary delivery metadata. It receives no Codex credentials or encrypted session projection. + +Vercel serves hra.sh. GitHub hosts the source repository, releases, and release downloads. When you visit or download from either service, that provider receives ordinary web request metadata such as the requested URL, IP address, user agent, and time. HRA does not add analytics, cookies, remote fonts, or executable JavaScript to the site. + Device credentials are bearer credentials, not hardware-bound proofs. Connection and generation fencing blocks a copied credential from creating a second concurrent connection or surviving revocation, but an uncontested, unrevoked copy can impersonate that device until it is detected and revoked. Compact-projection recovery is append-only. It preserves every older encrypted cloud chunk, opens a new stream epoch, and keeps the acknowledged unsynced interval visible as a recovery gap until authenticated account deletion. -The website uses no analytics, cookies, remote fonts, or executable JavaScript. Codex activity remains subject to OpenAI's own service and privacy terms. +Codex activity remains subject to OpenAI's own service and privacy terms. > **Hosted sync status.** The hosted sync endpoint is beta-not-yet-live. Authenticated account deletion and capability-only progress recovery are implemented and pass deterministic hostile tests. Fresh-deployment and live completion acceptance remain launch gates. diff --git a/README.md b/README.md index 50710f0..314b3a4 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # HRA ```sh -bun add --global github:hraness/hra#v0.1.0 +bun add --global https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz ``` ```sh -hra init +hra doctor --offline ``` ```sh -hra doctor --offline +hra init ``` > **Beta not yet live.** The `v0.1.0` tag and hosted sync service are beta-not-yet-live. The install command becomes usable when the beta tag is published. @@ -18,6 +18,36 @@ HRA is one Bun CLI plus a local daemon. It keeps Codex accounts isolated, gives [GitHub](https://github.com/hraness/hra) · [Documentation](https://github.com/hraness/hra#command-reference) · [Security](https://github.com/hraness/hra/blob/main/SECURITY.md) · [Privacy](https://github.com/hraness/hra/blob/main/PRIVACY.md) +## Install, update, and remove + +HRA requires Bun 1.3.14. The CLI and local daemon support macOS and Linux; supported ChatGPT desktop account switching is macOS-only. Install one reviewed immutable tag, then verify the binary before initialization: + +```text +bun --version +bun add --global https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz +hra --version +hra doctor --offline +``` + +Before replacing the installed binary, stop the persistent daemon and confirm that its old process has released authority. The command below performs a verified repair installation of v0.1.0. For a future update, replace both v0.1.0 occurrences in the URL with the exact reviewed release version, verify it, then restart explicitly. Do not install a moving branch for a release machine: + +```text +hra daemon stop +hra daemon status --json +bun add --global https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz +hra --version +hra doctor --offline +hra daemon start +``` + +Removing the package does not remove HRA's local profiles, session history, recovery evidence, or cloud account. Log out each Codex profile and complete any intended cloud-account deletion before uninstalling. Then stop the daemon, confirm that it is stopped, and remove the installed command: + +```text +hra daemon stop +hra daemon status --json +bun remove --global hra +``` + ## First account ```text @@ -34,7 +64,7 @@ HRA cloud identity is separate from every Codex account. Use the email-code flow ## Cloud sign-in and device pairing -The hosted endpoint is beta-not-yet-live. Until it is published, these commands require an explicit deployment URL in `HRA_CONVEX_URL` before the daemon starts. HRA accepts cloud credentials only as protected JSON on standard input or a nonterminal file descriptor. It rejects email addresses, identity invites, and verification codes on the command line: +The hosted endpoint is beta-not-yet-live. An unset `HRA_CONVEX_URL` selects HRA's hosted deployment. Set it to an explicit empty value before the first daemon starts to disable cloud transport. A nonempty HTTPS value selects a self-managed Convex deployment. The first valid selection permanently binds that local state root; a later mismatch fails closed instead of moving credentials or recovery state. HRA accepts cloud credentials only as protected JSON on standard input or a nonterminal file descriptor. It rejects email addresses, identity invites, and verification codes on the command line: ```text hra auth login --input-stdin @@ -170,11 +200,17 @@ Cloud sync is optional. Local account profiles, Codex credentials, and local exe The sync service necessarily sees the verified HRA email address, device identifiers, record types, revisions, ciphertext sizes, timestamps, and execution-lease or command lifecycle metadata. It cannot decrypt session content without a paired device key. Email access alone does not recover that key. +HRA uses Convex to authenticate the HRA identity and store server-visible metadata plus encrypted projections. Convex receives the verified email address and the service metadata described above, but not the keys required to decrypt session content. + +HRA uses Resend to deliver verification email. Resend receives the recipient email address, sender identity, one-time verification code and message content, and ordinary delivery metadata. It receives no Codex credentials or encrypted session projection. + +Vercel serves hra.sh. GitHub hosts the source repository, releases, and release downloads. When you visit or download from either service, that provider receives ordinary web request metadata such as the requested URL, IP address, user agent, and time. HRA does not add analytics, cookies, remote fonts, or executable JavaScript to the site. + Device credentials are bearer credentials, not hardware-bound proofs. Connection and generation fencing blocks a copied credential from creating a second concurrent connection or surviving revocation, but an uncontested, unrevoked copy can impersonate that device until it is detected and revoked. Compact-projection recovery is append-only. It preserves every older encrypted cloud chunk, opens a new stream epoch, and keeps the acknowledged unsynced interval visible as a recovery gap until authenticated account deletion. -The website uses no analytics, cookies, remote fonts, or executable JavaScript. Codex activity remains subject to OpenAI's own service and privacy terms. +Codex activity remains subject to OpenAI's own service and privacy terms. > **Hosted sync status.** The hosted sync endpoint is beta-not-yet-live. Authenticated account deletion and capability-only progress recovery are implemented and pass deterministic hostile tests. Fresh-deployment and live completion acceptance remain launch gates. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 89a94e8..a997377 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -2,4 +2,4 @@ HRA depends on the official OpenAI Codex package, which is licensed under Apache License 2.0. HRA does not copy or redistribute Codex credentials. -Exact dependency versions are recorded in `bun.lock` and `package.json`. Each release also includes a generated SPDX dependency inventory. Dependency packages retain their own license texts and source metadata. +The immutable release source tag records the verified build graph in `bun.lock`, while the install tarball declares its direct runtime dependency versions in `package.json`. Each release includes an artifact-identity SPDX record whose SHA-256 digest binds that exact tarball. It also includes a separately named Ubuntu 24.04 x64 runtime SPDX inventory captured from the isolated installation accepted before publication. That inventory records the dependencies resolved for the acceptance runner; it does not claim to describe every consumer platform or later package-manager resolution. The tarball does not vendor transitive dependencies. Dependency packages retain their own license texts and source metadata. diff --git a/convex/accountDeletion.ts b/convex/accountDeletion.ts index 63ebab5..3e00882 100644 --- a/convex/accountDeletion.ts +++ b/convex/accountDeletion.ts @@ -119,6 +119,7 @@ export const ACCOUNT_DELETION_TABLE_STRATEGY = { storageResourceUsageByUser: "user_index", storageResourceUsageByAccount: "user_index", storageUsageService: "service_retained", + serviceControl: "service_retained", maintenanceState: "service_retained", } as const satisfies Readonly, unknown>( + "quota:genesisHardAuthority", +); +const status = makeFunctionReference<"query", Record, unknown>( + "admissionControl:status", +); +const transition = makeFunctionReference< + "mutation", + { expectedGeneration: number; mutationId: string; state: "frozen" | "open" }, + unknown +>("admissionControl:transition"); +const recordInvite = makeFunctionReference< + "mutation", + { + capabilityDigest: string; + lifetimeMs: number; + publicId: string; + purpose: "identity"; + }, + unknown +>("authInvites:recordIssue"); +const reserveEmail = makeFunctionReference< + "mutation", + { emailDigest: string; kind: "send" }, + unknown +>("authDelivery:reserveEmailAttempt"); + +const freezeId = "018bcfe5-6800-7000-8000-000000000901"; +const resumeId = "018bcfe5-6800-7000-8000-000000000902"; +const digest = "a".repeat(64); + +describe("hosted authentication admission control", () => { + let runtime: ReturnType; + + beforeEach(async () => { + runtime = convexTest(schema, modules); + await runtime.mutation(genesis, {}); + }); + + test("genesis creates one open generation-zero authority", async () => { + expect(await runtime.query(status, {})).toMatchObject({ + generation: 0, + state: "open", + }); + expect(await runtime.run(async (ctx) => { + const rows = await ctx.db.query("serviceControl").collect() as unknown as readonly unknown[]; + return rows.length; + })).toBe(1); + }); + + test("freeze is generation-fenced, exactly replayable, and resumable", async () => { + const frozen = await runtime.mutation(transition, { + expectedGeneration: 0, + mutationId: freezeId, + state: "frozen", + }); + expect(frozen).toMatchObject({ changed: true, generation: 1, replay: false, state: "frozen" }); + expect(await runtime.mutation(transition, { + expectedGeneration: 0, + mutationId: freezeId, + state: "frozen", + })).toMatchObject({ changed: true, generation: 1, replay: true, state: "frozen" }); + await expect(runtime.mutation(transition, { + expectedGeneration: 0, + mutationId: resumeId, + state: "open", + })).rejects.toThrow("AUTH_ADMISSION_AUTHORITY_STALE"); + expect(await runtime.mutation(transition, { + expectedGeneration: 1, + mutationId: resumeId, + state: "open", + })).toMatchObject({ changed: true, generation: 2, replay: false, state: "open" }); + await expect(runtime.mutation(transition, { + expectedGeneration: 2, + mutationId: "018bcfe5-6800-7000-8000-000000000904", + state: "open", + })).rejects.toThrow("AUTH_ADMISSION_AUTHORITY_STALE"); + }); + + test("concurrent transitions from one generation admit at most one winner", async () => { + const results = await Promise.allSettled([ + runtime.mutation(transition, { + expectedGeneration: 0, + mutationId: freezeId, + state: "frozen", + }), + runtime.mutation(transition, { + expectedGeneration: 0, + mutationId: resumeId, + state: "frozen", + }), + ]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + expect(await runtime.query(status, {})).toMatchObject({ generation: 1, state: "frozen" }); + }); + + test("freeze blocks invite and OTP admission without deleting recovery state", async () => { + await runtime.mutation(transition, { + expectedGeneration: 0, + mutationId: freezeId, + state: "frozen", + }); + await expect(runtime.mutation(recordInvite, { + capabilityDigest: digest, + lifetimeMs: 24 * 60 * 60 * 1_000, + publicId: invitePublicIdFromCapabilityDigest(digest), + purpose: "identity", + })).rejects.toThrow("AUTH_ADMISSION_FROZEN"); + await expect(runtime.mutation(reserveEmail, { + emailDigest: digest, + kind: "send", + })).rejects.toThrow("AUTH_ADMISSION_FROZEN"); + expect(await runtime.run(async (ctx) => ({ + attempts: await ctx.db.query("authEmailAttemptEvents").collect(), + invites: await ctx.db.query("authInvites").collect(), + }))).toEqual({ attempts: [], invites: [] }); + }); + + test("missing, duplicate, or corrupt control authority fails closed", async () => { + await runtime.run(async (ctx) => { + const row = await ctx.db.query("serviceControl").unique() as unknown as + | Readonly<{ _id: string }> + | null; + if (row === null) throw new Error("missing fixture authority"); + await ctx.db.delete(row._id as never); + }); + await expect(runtime.query(status, {})) + .rejects.toThrow("AUTH_ADMISSION_AUTHORITY_CORRUPT"); + + const corrupt = convexTest(schema, modules); + await corrupt.mutation(genesis, {}); + await corrupt.run(async (ctx) => { + const row = await ctx.db.query("serviceControl").unique() as unknown as + | Readonly<{ _id: string }> + | null; + if (row === null) throw new Error("missing fixture authority"); + await ctx.db.patch(row._id as never, { authAdmissionGeneration: -1 }); + }); + await expect(corrupt.query(status, {})) + .rejects.toThrow("AUTH_ADMISSION_AUTHORITY_CORRUPT"); + + const duplicate = convexTest(schema, modules); + await duplicate.mutation(genesis, {}); + await duplicate.run(async (ctx) => { + await ctx.db.insert("serviceControl", { + authAdmissionGeneration: 0, + authAdmissions: "open", + key: "global", + updatedAt: Date.now(), + }); + }); + await expect(duplicate.query(status, {})) + .rejects.toThrow("AUTH_ADMISSION_AUTHORITY_CORRUPT"); + }); +}); diff --git a/convex/admissionControl.ts b/convex/admissionControl.ts new file mode 100644 index 0000000..5e7c937 --- /dev/null +++ b/convex/admissionControl.ts @@ -0,0 +1,189 @@ +import { v } from "convex/values"; + +import { isFiniteTimestamp, isSafeNonNegativeInteger, isUuidV7 } from "../src/cloud/contracts"; +import { + identityInviteLifetimeMs, + invitePublicIdFromCapabilityDigest, + isInvitePublicId, +} from "../src/cloud/inviteAuthority"; +import { isAuthDigest } from "./authPolicy"; +import { + internalMutation, + internalQuery, + type MutationCtx, + type QueryCtx, +} from "./server"; +import { authAdmissionState } from "./validators"; + +export type AuthAdmissionState = "frozen" | "open"; + +const corrupt = (): never => { + throw new Error("AUTH_ADMISSION_AUTHORITY_CORRUPT"); +}; + +const stale = (): never => { + throw new Error("AUTH_ADMISSION_AUTHORITY_STALE"); +}; + +type ControlContext = MutationCtx | QueryCtx; + +export type ControlRow = Readonly<{ + _id: string; + authAdmissionGeneration: number; + authAdmissions: AuthAdmissionState; + bootstrapAcceptedAt?: number; + bootstrapCompletedAt?: number; + bootstrapInviteCapabilityDigest?: string; + bootstrapInviteLifetimeMs?: number; + bootstrapInvitePublicId?: string; + key: "global"; + lastMutationId?: string; + updatedAt: number; +}>; + +async function readControl(ctx: ControlContext): Promise { + const rows = await ctx.db.query("serviceControl") + .withIndex("by_key", (builder) => builder.eq("key", "global")) + .take(2); + const row = rows[0]; + const untrusted = row as unknown as Readonly> | undefined; + const bootstrapValues = [ + untrusted?.bootstrapCompletedAt, + untrusted?.bootstrapInviteCapabilityDigest, + untrusted?.bootstrapInviteLifetimeMs, + untrusted?.bootstrapInvitePublicId, + ]; + const hasBootstrapAuthority = bootstrapValues.some((value) => value !== undefined); + const bootstrapAcceptedAt = untrusted?.bootstrapAcceptedAt; + const bootstrapCompletedAt = untrusted?.bootstrapCompletedAt; + if ( + rows.length !== 1 + || row === undefined + || untrusted?.key !== "global" + || !isSafeNonNegativeInteger(untrusted.authAdmissionGeneration) + || (untrusted.authAdmissions !== "open" && untrusted.authAdmissions !== "frozen") + || !isFiniteTimestamp(untrusted.updatedAt) + || (untrusted.authAdmissionGeneration === 0) !== (untrusted.lastMutationId === undefined) + || ( + untrusted.lastMutationId !== undefined + && !isUuidV7(untrusted.lastMutationId) + ) + || ( + hasBootstrapAuthority + && ( + !isFiniteTimestamp(untrusted.bootstrapCompletedAt) + || untrusted.bootstrapCompletedAt > untrusted.updatedAt + || !isAuthDigest(untrusted.bootstrapInviteCapabilityDigest) + || untrusted.bootstrapInviteLifetimeMs !== identityInviteLifetimeMs + || !isInvitePublicId(untrusted.bootstrapInvitePublicId) + || invitePublicIdFromCapabilityDigest( + untrusted.bootstrapInviteCapabilityDigest, + ) !== untrusted.bootstrapInvitePublicId + ) + ) + || ( + bootstrapAcceptedAt !== undefined + && ( + !hasBootstrapAuthority + || !isFiniteTimestamp(bootstrapAcceptedAt) + || !isFiniteTimestamp(bootstrapCompletedAt) + || bootstrapAcceptedAt < bootstrapCompletedAt + || bootstrapAcceptedAt > untrusted.updatedAt + ) + ) + ) return corrupt(); + return row; +} + +export async function requireAuthAdmissionsOpen( + ctx: ControlContext, +): Promise { + const control = await readControl(ctx); + if (control.authAdmissions !== "open") { + throw new Error("AUTH_ADMISSION_FROZEN"); + } + return control; +} + +export async function recordBootstrapInviteAccepted( + ctx: MutationCtx, + invite: Readonly<{ + capabilityDigest: string; + publicId: string; + requestedLifetimeMs?: number; + }>, + acceptedAt: number, +): Promise { + const control = await readControl(ctx); + if (control.bootstrapInvitePublicId === undefined) return; + if ( + control.bootstrapInviteCapabilityDigest !== invite.capabilityDigest + || control.bootstrapInvitePublicId !== invite.publicId + || control.bootstrapInviteLifetimeMs !== invite.requestedLifetimeMs + ) return; + if (control.bootstrapAcceptedAt !== undefined) { + if (control.bootstrapAcceptedAt > acceptedAt) return corrupt(); + return; + } + if ( + !isFiniteTimestamp(acceptedAt) + || control.bootstrapCompletedAt === undefined + || acceptedAt < control.bootstrapCompletedAt + ) return corrupt(); + await ctx.db.patch(control._id as never, { + bootstrapAcceptedAt: acceptedAt, + updatedAt: Math.max(control.updatedAt, acceptedAt), + }); +} + +const publicControl = (control: ControlRow) => ({ + generation: control.authAdmissionGeneration, + state: control.authAdmissions, + updatedAt: control.updatedAt, +}); + +export const status = internalQuery({ + args: {}, + handler: async (ctx) => publicControl(await readControl(ctx)), +}); + +export const transition = internalMutation({ + args: { + expectedGeneration: v.number(), + mutationId: v.string(), + state: authAdmissionState, + }, + handler: async (ctx, args) => { + if ( + !isSafeNonNegativeInteger(args.expectedGeneration) + || !isUuidV7(args.mutationId) + ) return stale(); + const current = await readControl(ctx); + if (current.lastMutationId === args.mutationId) { + if ( + current.authAdmissionGeneration !== args.expectedGeneration + 1 + || current.authAdmissions !== args.state + ) return stale(); + return { ...publicControl(current), changed: true, replay: true }; + } + if (current.authAdmissionGeneration !== args.expectedGeneration) return stale(); + if (current.authAdmissions === args.state) { + return stale(); + } + if (current.authAdmissionGeneration >= Number.MAX_SAFE_INTEGER) return corrupt(); + const next = { + authAdmissionGeneration: current.authAdmissionGeneration + 1, + authAdmissions: args.state, + lastMutationId: args.mutationId, + updatedAt: Date.now(), + } as const; + await ctx.db.patch(current._id as never, next); + return { + generation: next.authAdmissionGeneration, + state: next.authAdmissions, + updatedAt: next.updatedAt, + changed: true, + replay: false, + }; + }, +}); diff --git a/convex/auth.ts b/convex/auth.ts index 30a0207..0025265 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -27,6 +27,7 @@ import { } from "./quota"; import { internalMutation, type DataModel, type MutationCtx } from "./server"; import { requireActiveAuthSubject } from "./authDelivery"; +import { requireAuthAdmissionsOpen } from "./admissionControl"; export const hraOtpProviderId = "hra-control-plane-otp-v1"; @@ -392,6 +393,9 @@ export async function runQuotaAwareAuthStoreForTest( handler: (ctx: MutationCtx) => Promise, ): Promise { requireAuthStoreOperation(operation); + if (operation === "refreshSession") { + await requireAuthAdmissionsOpen(ctx); + } await adjustServiceQuotaForPatch( ctx, { kind: "convex_auth_store_authority_probe", version: 1 }, @@ -461,6 +465,7 @@ const hraOtp = ConvexCredentials({ const configuredAuth = convexAuth({ callbacks: { async beforeSessionCreation(ctx, { userId }) { + await requireAuthAdmissionsOpen(ctx as unknown as MutationCtx); await requireActiveAuthSubject( ctx as unknown as MutationCtx, userId, diff --git a/convex/authDelivery.ts b/convex/authDelivery.ts index 27dc8bf..8a3b5a1 100644 --- a/convex/authDelivery.ts +++ b/convex/authDelivery.ts @@ -13,6 +13,7 @@ import { consumeBoundIdentityInvite, requireBoundIdentityInvite, } from "./authInvites"; +import { requireAuthAdmissionsOpen } from "./admissionControl"; import { adjustParentAttributedQuotaForPatch, adjustQuotaForPatch, @@ -119,6 +120,7 @@ export const reserveEmailAttempt = internalMutation({ if (args.inviteCapabilityDigest !== undefined) { requireDigest(args.inviteCapabilityDigest); } + await requireAuthAdmissionsOpen(ctx); const now = Date.now(); let subject = await subjectByEmail(ctx, args.emailDigest); let inviteBinding: "bound" | "not_required" | "replay" = "not_required"; diff --git a/convex/authInvites.test.ts b/convex/authInvites.test.ts index 4019c55..4c52d31 100644 --- a/convex/authInvites.test.ts +++ b/convex/authInvites.test.ts @@ -7,6 +7,7 @@ import { isIdentityInviteCapability } from "../src/cloud/authCredentials"; import { digestInviteCapability, generateInviteAuthority, + invitePublicIdFromCapabilityDigest, maximumInviteLifetimeMs, minimumInviteLifetimeMs, } from "./authInvites"; @@ -25,7 +26,6 @@ type Reservation = Readonly<{ inviteBinding: "bound" | "not_required" | "replay"; }>; type IssueResult = Readonly<{ - capability: string; expiresAt: number; publicId: string; purpose: "device" | "identity"; @@ -33,8 +33,7 @@ type IssueResult = Readonly<{ state: "issued"; }>; -const issue = makeFunctionReference<"action", Args, IssueResult>("authInvites:issue"); -const recordIssue = makeFunctionReference<"mutation", Args, Omit>( +const recordIssue = makeFunctionReference<"mutation", Args, IssueResult>( "authInvites:recordIssue", ); const inviteStatus = makeFunctionReference<"query", Args, unknown>("authInvites:status"); @@ -56,7 +55,7 @@ const emailA = "a".repeat(64); const emailB = "b".repeat(64); async function preparedIdentityInvite() { - const authority = generateInviteAuthority("identity"); + const authority = await generateInviteAuthority("identity"); return { ...authority, capabilityDigest: await digestInviteCapability(authority.capability, "identity"), @@ -93,11 +92,9 @@ async function hardRuntime() { describe("identity invitation admission", () => { test("issues 256-bit capabilities, stores only their digest, and exposes bounded status", async () => { const testRuntime = await hardRuntime(); - const issued = await testRuntime.action(issue, { - lifetimeMs: minimumInviteLifetimeMs, - purpose: "identity", - }); - expect(isIdentityInviteCapability(issued.capability)).toBe(true); + const authority = await preparedIdentityInvite(); + const issued = await recordPrepared(testRuntime, authority); + expect(isIdentityInviteCapability(authority.capability)).toBe(true); expect(issued).toMatchObject({ purpose: "identity", replay: false, state: "issued" }); expect(issued.expiresAt).toBeGreaterThan(Date.now()); expect(issued.expiresAt).toBeLessThanOrEqual(Date.now() + maximumInviteLifetimeMs); @@ -105,7 +102,10 @@ describe("identity invitation admission", () => { const stored = await testRuntime.run(async (ctx) => await ctx.db.query("authInvites").first()); expect(stored?.capabilityDigest).toMatch(/^[0-9a-f]{64}$/u); - expect(JSON.stringify(stored)).not.toContain(issued.capability); + expect(issued.publicId).toBe( + invitePublicIdFromCapabilityDigest(stored?.capabilityDigest ?? ""), + ); + expect(JSON.stringify(stored)).not.toContain(authority.capability); expect(await testRuntime.query(inviteStatus, { publicId: issued.publicId })) .toMatchObject({ bound: false, diff --git a/convex/authInvites.ts b/convex/authInvites.ts index 4b41f6c..bf582dd 100644 --- a/convex/authInvites.ts +++ b/convex/authInvites.ts @@ -1,38 +1,40 @@ -import { makeFunctionReference } from "convex/server"; import { v, type GenericId as Id } from "convex/values"; import { - identityInviteCapabilityPrefix, - identityInviteSecretLength, - isIdentityInviteCapability, -} from "../src/cloud/authCredentials"; + invitePublicIdFromCapabilityDigest, + isInvitePublicId, + type InvitePurpose, +} from "../src/cloud/inviteAuthority"; import { authOtpLifetimeMs, isAuthDigest } from "./authPolicy"; +import { + recordBootstrapInviteAccepted, + requireAuthAdmissionsOpen, +} from "./admissionControl"; import { adjustServiceQuotaForPatch, reserveServiceQuotaForInsert, } from "./quota"; import { - internalAction, internalMutation, internalQuery, type MutationCtx, } from "./server"; import { invitePurpose } from "./validators"; -export type InvitePurpose = "device" | "identity"; +export { + digestInviteCapability, + generateInviteAuthority, + invitePublicIdFromCapabilityDigest, + invitePublicIdPrefix, + isInviteCapability, + isInvitePublicId, +} from "../src/cloud/inviteAuthority"; +export type { InvitePurpose } from "../src/cloud/inviteAuthority"; export const minimumInviteLifetimeMs = authOtpLifetimeMs + 60_000; export const maximumInviteLifetimeMs = 30 * 24 * 60 * 60 * 1_000; export const terminalInviteReceiptLifetimeMs = 24 * 60 * 60 * 1_000; -export const invitePublicIdPrefix = "invite_"; - -const deviceInviteCapabilityPrefix = "hra_invite_device_v1_"; -const invitePublicIdPattern = /^invite_[A-Za-z0-9_-]{32}$/u; -const deviceInviteCapabilityPattern = - /^hra_invite_device_v1_[A-Za-z0-9_-]{43}$/u; const authenticationRejectedMessage = "Authentication could not be completed."; -const base64UrlAlphabet = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; function rejectAuthentication(): never { throw new Error(authenticationRejectedMessage); @@ -48,75 +50,6 @@ function isSafeLifetime(value: number): boolean { && value <= maximumInviteLifetimeMs; } -export function isInvitePublicId(value: unknown): value is string { - return typeof value === "string" && invitePublicIdPattern.test(value); -} - -export function isInviteCapability( - value: unknown, - purpose: InvitePurpose, -): value is string { - return purpose === "identity" - ? isIdentityInviteCapability(value) - : typeof value === "string" && deviceInviteCapabilityPattern.test(value); -} - -function encodeBase64Url(bytes: Uint8Array): string { - let encoded = ""; - let buffer = 0; - let bits = 0; - for (const byte of bytes) { - buffer = (buffer << 8) | byte; - bits += 8; - while (bits >= 6) { - bits -= 6; - encoded += base64UrlAlphabet.charAt((buffer >>> bits) & 63); - buffer &= (1 << bits) - 1; - } - } - if (bits > 0) encoded += base64UrlAlphabet.charAt((buffer << (6 - bits)) & 63); - return encoded; -} - -export function generateInviteAuthority(purpose: InvitePurpose): Readonly<{ - capability: string; - publicId: string; -}> { - const capabilitySecret = encodeBase64Url( - crypto.getRandomValues(new Uint8Array(32)), - ); - const publicIdSecret = encodeBase64Url( - crypto.getRandomValues(new Uint8Array(24)), - ); - if ( - capabilitySecret.length !== identityInviteSecretLength - || publicIdSecret.length !== 32 - ) rejectInviteTool(); - return { - capability: `${purpose === "identity" - ? identityInviteCapabilityPrefix - : deviceInviteCapabilityPrefix}${capabilitySecret}`, - publicId: `${invitePublicIdPrefix}${publicIdSecret}`, - }; -} - -function toHex(bytes: ArrayBuffer): string { - return [...new Uint8Array(bytes)] - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); -} - -export async function digestInviteCapability( - capability: string, - purpose: InvitePurpose, -): Promise { - if (!isInviteCapability(capability, purpose)) rejectAuthentication(); - const bytes = new TextEncoder().encode( - `hra-control-plane-invite-capability:v1:${purpose}:${capability}`, - ); - return toHex(await crypto.subtle.digest("SHA-256", bytes)); -} - function admissionExpiry(invite: Readonly<{ admissionExpiresAt?: number; expiresAt: number; @@ -124,14 +57,6 @@ function admissionExpiry(invite: Readonly<{ return invite.admissionExpiresAt ?? invite.expiresAt; } -type RecordIssueArgs = Readonly<{ - capabilityDigest: string; - issuedByUserId?: Id<"users">; - lifetimeMs: number; - publicId: string; - purpose: InvitePurpose; -}>; - type IssueRecord = Readonly<{ expiresAt: number; publicId: string; @@ -140,12 +65,6 @@ type IssueRecord = Readonly<{ state: "issued"; }>; -const recordIssueReference = makeFunctionReference< - "mutation", - RecordIssueArgs, - IssueRecord ->("authInvites:recordIssue"); - export const recordIssue = internalMutation({ args: { capabilityDigest: v.string(), @@ -158,8 +77,10 @@ export const recordIssue = internalMutation({ if ( !isAuthDigest(args.capabilityDigest) || !isInvitePublicId(args.publicId) + || invitePublicIdFromCapabilityDigest(args.capabilityDigest) !== args.publicId || !isSafeLifetime(args.lifetimeMs) ) rejectInviteTool(); + const control = await requireAuthAdmissionsOpen(ctx); if ( args.issuedByUserId !== undefined && await ctx.db.get(args.issuedByUserId) === null @@ -200,6 +121,13 @@ export const recordIssue = internalMutation({ }; } + if ( + control.bootstrapInvitePublicId !== undefined + && control.bootstrapAcceptedAt === undefined + ) { + rejectInviteTool(); + } + const now = Date.now(); const expiresAt = now + args.lifetimeMs; const inviteId = await ctx.db.insert("authInvites", { @@ -229,31 +157,6 @@ export const recordIssue = internalMutation({ }, }); -export const issue = internalAction({ - args: { - issuedByUserId: v.optional(v.id("users")), - lifetimeMs: v.number(), - purpose: invitePurpose, - }, - handler: async (ctx, args) => { - if (!isSafeLifetime(args.lifetimeMs)) rejectInviteTool(); - const authority = generateInviteAuthority(args.purpose); - const recorded = await ctx.runMutation(recordIssueReference, { - capabilityDigest: await digestInviteCapability( - authority.capability, - args.purpose, - ), - lifetimeMs: args.lifetimeMs, - publicId: authority.publicId, - purpose: args.purpose, - ...(args.issuedByUserId === undefined - ? {} - : { issuedByUserId: args.issuedByUserId }), - }); - return { ...recorded, capability: authority.capability }; - }, -}); - function publicStatus(invite: Readonly<{ admissionExpiresAt?: number; boundEmailDigest?: string; @@ -406,5 +309,6 @@ export async function consumeBoundIdentityInvite( updatedAt: now, } as const; await adjustServiceQuotaForPatch(ctx, invite, patch); + await recordBootstrapInviteAccepted(ctx, invite, now); await ctx.db.patch(invite._id, patch); } diff --git a/convex/authQuota.test.ts b/convex/authQuota.test.ts index 9f694b8..b6431ee 100644 --- a/convex/authQuota.test.ts +++ b/convex/authQuota.test.ts @@ -12,6 +12,7 @@ import { import { digestInviteCapability, generateInviteAuthority, + invitePublicIdFromCapabilityDigest, minimumInviteLifetimeMs, } from "./authInvites"; import { adjustParentAttributedQuotaForPatch } from "./quota"; @@ -37,6 +38,11 @@ const storeChallenge = makeFunctionReference<"mutation", Args, Id<"authOtpChalle "authDelivery:storeOtpChallenge", ); const authStore = makeFunctionReference<"mutation", Args, unknown>("auth:store"); +const transitionAdmission = makeFunctionReference< + "mutation", + { expectedGeneration: number; mutationId: string; state: "frozen" | "open" }, + unknown +>("admissionControl:transition"); async function hardRuntime() { const runtime = convexTest(schema, authModules); @@ -100,9 +106,24 @@ describe("Convex Auth hard quota boundary", () => { }); }); + test("blocks refresh-session storage before its handler while admission is frozen", async () => { + const runtime = await hardRuntime(); + await runtime.mutation(transitionAdmission, { + expectedGeneration: 0, + mutationId: "018bcfe5-6800-7000-8000-000000000903", + state: "frozen", + }); + let reachedHandler = false; + await expect(runtime.run(async (ctx) => + await runQuotaAwareAuthStoreForTest(ctx, "refreshSession", async () => { + reachedHandler = true; + }))).rejects.toThrow("AUTH_ADMISSION_FROZEN"); + expect(reachedHandler).toBe(false); + }); + test("charges first invite admission and makes exact account replays quota-neutral", async () => { const runtime = await hardRuntime(); - const invite = generateInviteAuthority("identity"); + const invite = await generateInviteAuthority("identity"); const capabilityDigest = await digestInviteCapability(invite.capability, "identity"); await runtime.mutation(recordIssue, { capabilityDigest, @@ -296,7 +317,7 @@ describe("Convex Auth hard quota boundary", () => { await expect(corrupt.mutation(recordIssue, { capabilityDigest: "d".repeat(64), lifetimeMs: minimumInviteLifetimeMs, - publicId: `invite_${"a".repeat(32)}`, + publicId: invitePublicIdFromCapabilityDigest("d".repeat(64)), purpose: "identity", })).rejects.toThrow("QUOTA_AUTHORITY_CORRUPT"); expect(await corrupt.run(async (ctx) => diff --git a/convex/cloudTransactions.test.ts b/convex/cloudTransactions.test.ts index effdf59..64003d3 100644 --- a/convex/cloudTransactions.test.ts +++ b/convex/cloudTransactions.test.ts @@ -56,6 +56,9 @@ const recoverEffectStarted = makeFunctionReference<"mutation", Args, unknown>( const genesisQuota = makeFunctionReference<"mutation", Record, unknown>( "quota:genesisHardAuthority", ); +const transitionAuthAdmission = makeFunctionReference<"mutation", Args, unknown>( + "admissionControl:transition", +); const encryptedEnvelope = { algorithm: "A256GCM" as const, @@ -120,6 +123,42 @@ async function authenticatedWorld() { } describe("cloud transactions", () => { + test("auth freeze blocks new device credentials but preserves exact registration replay", async () => { + const world = await authenticatedWorld(); + const now = Date.now(); + const registration = { + bootstrapKeyEnvelope: wrappedKeyEnvelope, + encryptedLabel: encryptedEnvelope, + idempotencyKey: uuidV7(now, "ad01"), + keyVersion: 1, + publicId: "device_admission1", + requestDigest: "e".repeat(64), + signingPublicKey: publicKey, + wrappingPublicKey: publicKey, + } as const; + await world.testRuntime.mutation(transitionAuthAdmission, { + expectedGeneration: 0, + mutationId: uuidV7(now, "ad02"), + state: "frozen", + }); + await expectPromiseToReject( + world.runtime.mutation(register, registration), + "AUTH_ADMISSION_FROZEN", + ); + await world.testRuntime.mutation(transitionAuthAdmission, { + expectedGeneration: 1, + mutationId: uuidV7(now, "ad03"), + state: "open", + }); + const registered = await world.runtime.mutation(register, registration); + await world.testRuntime.mutation(transitionAuthAdmission, { + expectedGeneration: 2, + mutationId: uuidV7(now, "ad04"), + state: "frozen", + }); + expect(await world.runtime.mutation(register, registration)).toEqual(registered); + }); + test("fences one encrypted command effect under an exact device and lease", async () => { const world = await authenticatedWorld(); const now = Date.now(); diff --git a/convex/devices.ts b/convex/devices.ts index e4eb5ad..dfae05e 100644 --- a/convex/devices.ts +++ b/convex/devices.ts @@ -21,6 +21,7 @@ import { requireAuthAuthority, requireDeviceAuthority, } from "./authority"; +import { requireAuthAdmissionsOpen } from "./admissionControl"; import { loadIdempotencyReceipt, storeIdempotencyReceipt, @@ -203,6 +204,7 @@ export const register = mutation({ await bindRegistrationToAuthSession(ctx, auth, device); return summarizeDevice(device); } + await requireAuthAdmissionsOpen(ctx); const existingBindings = await ctx.db .query("deviceSessions") .withIndex("by_auth_session", (builder) => diff --git a/convex/hostedBootstrap.test.ts b/convex/hostedBootstrap.test.ts new file mode 100644 index 0000000..c1887bb --- /dev/null +++ b/convex/hostedBootstrap.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, test } from "bun:test"; +import { makeFunctionReference } from "convex/server"; +import type { GenericId as Id, Value } from "convex/values"; +import { convexTest } from "convex-test"; + +import { + digestInviteCapability, + generateInviteAuthority, + identityInviteLifetimeMs, + invitePublicIdFromCapabilityDigest, +} from "../src/cloud/inviteAuthority"; + +import { + consumeBoundIdentityInvite, + minimumInviteLifetimeMs, +} from "./authInvites"; +import { + adjustServiceQuotaForPatch, + logicalDocumentBytes, + releaseServiceQuotaForDelete, +} from "./quota"; +import schema from "./schema"; +import { modules } from "./test.setup"; + +type Args = Readonly>; +type HostedGenesisResult = Readonly<{ + enforcement: "hard"; + invite: Readonly<{ + expiresAt: number; + publicId: string; + purpose: "identity"; + state: "issued"; + }>; + replay: boolean; +}>; + +const hostedGenesis = makeFunctionReference<"mutation", Args, HostedGenesisResult>( + "quota:genesisHostedAuthority", +); +const recordIssue = makeFunctionReference<"mutation", Args, unknown>( + "authInvites:recordIssue", +); +const admissionStatus = makeFunctionReference<"query", Args, unknown>( + "admissionControl:status", +); + +const prepare = async () => { + const authority = await generateInviteAuthority("identity"); + return { + ...authority, + capabilityDigest: await digestInviteCapability(authority.capability, "identity"), + }; +}; + +const genesisArguments = (authority: Awaited>) => ({ + capabilityDigest: authority.capabilityDigest, + lifetimeMs: identityInviteLifetimeMs, + publicId: authority.publicId, +}); + +describe("atomic hosted authority bootstrap", () => { + test("creates one quota authority, bootstrap binding, and charged first invite", async () => { + const runtime = convexTest(schema, modules); + const authority = await prepare(); + const result = await runtime.mutation(hostedGenesis, genesisArguments(authority)); + expect(result).toMatchObject({ + enforcement: "hard", + invite: { publicId: authority.publicId, purpose: "identity", state: "issued" }, + replay: false, + }); + + const rows = await runtime.run(async (ctx) => ({ + control: await ctx.db.query("serviceControl").collect(), + invites: await ctx.db.query("authInvites").collect(), + quota: await ctx.db.query("storageUsageService").collect(), + })); + expect(rows.control).toHaveLength(1); + expect(rows.invites).toHaveLength(1); + expect(rows.quota).toHaveLength(1); + expect(rows.control[0]).toMatchObject({ + authAdmissionGeneration: 0, + authAdmissions: "open", + bootstrapInviteCapabilityDigest: authority.capabilityDigest, + bootstrapInviteLifetimeMs: identityInviteLifetimeMs, + bootstrapInvitePublicId: authority.publicId, + }); + expect(rows.control[0]?.bootstrapAcceptedAt).toBeUndefined(); + const invite = rows.invites[0]; + if (invite === undefined) throw new Error("missing bootstrap invite"); + const inviteBytes = logicalDocumentBytes(invite); + expect(rows.quota[0]).toMatchObject({ + enforcement: "hard", + identities: 0, + logicalBytes: inviteBytes, + records: 1, + serviceLogicalBytes: inviteBytes, + serviceRecords: 1, + userLogicalBytes: 0, + userRecords: 0, + }); + }); + + test("replays only the exact request without adding rows or quota", async () => { + const runtime = convexTest(schema, modules); + const authority = await prepare(); + const first = await runtime.mutation(hostedGenesis, genesisArguments(authority)); + expect(await runtime.mutation(hostedGenesis, genesisArguments(authority))) + .toEqual({ ...first, replay: true }); + expect(await runtime.run(async (ctx) => ({ + controls: (await ctx.db.query("serviceControl").collect()).length, + invites: (await ctx.db.query("authInvites").collect()).length, + quota: await ctx.db.query("storageUsageService").unique(), + }))).toMatchObject({ + controls: 1, + invites: 1, + quota: { records: 1, serviceRecords: 1 }, + }); + }); + + test("serializes different prepared requests to exactly one winner", async () => { + const runtime = convexTest(schema, modules); + const first = await prepare(); + const second = await prepare(); + const results = await Promise.allSettled([ + runtime.mutation(hostedGenesis, genesisArguments(first)), + runtime.mutation(hostedGenesis, genesisArguments(second)), + ]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + const winner = results.find((result) => result.status === "fulfilled"); + if (winner?.status !== "fulfilled") throw new Error("missing winner"); + const rows = await runtime.run(async (ctx) => ({ + control: await ctx.db.query("serviceControl").unique(), + invites: await ctx.db.query("authInvites").collect(), + quota: await ctx.db.query("storageUsageService").unique(), + })); + expect(rows.invites).toHaveLength(1); + expect(rows.invites[0]?.publicId).toBe(winner.value.invite.publicId); + expect(rows.control?.bootstrapInvitePublicId).toBe(winner.value.invite.publicId); + expect(rows.quota).toMatchObject({ records: 1, serviceRecords: 1 }); + }); + + test("refuses invalid mapping and partial binding without creating a second authority", async () => { + const empty = convexTest(schema, modules); + const authority = await prepare(); + await expect(empty.mutation(hostedGenesis, { + ...genesisArguments(authority), + publicId: invitePublicIdFromCapabilityDigest("f".repeat(64)), + })).rejects.toThrow("HOSTED_BOOTSTRAP_AUTHORITY_REFUSED"); + expect(await empty.run(async (ctx) => ({ + controls: (await ctx.db.query("serviceControl").collect()).length, + invites: (await ctx.db.query("authInvites").collect()).length, + quota: (await ctx.db.query("storageUsageService").collect()).length, + }))).toEqual({ controls: 0, invites: 0, quota: 0 }); + + const corrupt = convexTest(schema, modules); + await corrupt.mutation(hostedGenesis, genesisArguments(authority)); + await corrupt.run(async (ctx) => { + const control = await ctx.db.query("serviceControl").unique(); + if (control === null) throw new Error("missing control"); + await ctx.db.patch(control._id, { bootstrapInviteCapabilityDigest: undefined }); + }); + await expect(corrupt.query(admissionStatus, {})) + .rejects.toThrow("AUTH_ADMISSION_AUTHORITY_CORRUPT"); + await expect(corrupt.mutation(hostedGenesis, genesisArguments(authority))) + .rejects.toThrow("HOSTED_BOOTSTRAP_AUTHORITY_REFUSED"); + }); + + test("durably unlocks friend issuance after bootstrap acceptance and receipt cleanup", async () => { + const runtime = convexTest(schema, modules); + const bootstrap = await prepare(); + const friend = await prepare(); + await runtime.mutation(hostedGenesis, genesisArguments(bootstrap)); + + await expect(runtime.mutation(recordIssue, { + capabilityDigest: friend.capabilityDigest, + lifetimeMs: minimumInviteLifetimeMs, + publicId: friend.publicId, + purpose: "identity", + })).rejects.toThrow("Invite operation could not be completed."); + + const emailDigest = "e".repeat(64); + await runtime.run(async (ctx) => { + const invite = await ctx.db.query("authInvites") + .withIndex("by_public_id", (query) => query.eq("publicId", bootstrap.publicId)) + .unique(); + if (invite === null) throw new Error("missing bootstrap invite"); + const now = Date.now(); + const boundPatch = { + boundAt: now, + boundEmailDigest: emailDigest, + state: "bound_to_email" as const, + updatedAt: now, + }; + await adjustServiceQuotaForPatch(ctx, invite, boundPatch); + await ctx.db.patch(invite._id, boundPatch); + await consumeBoundIdentityInvite(ctx, { + emailDigest, + inviteId: invite._id as Id<"authInvites">, + }); + const consumed = await ctx.db.get(invite._id); + if (consumed === null) throw new Error("missing consumed bootstrap invite"); + await releaseServiceQuotaForDelete(ctx, consumed); + await ctx.db.delete(consumed._id); + }); + + const control = await runtime.run(async (ctx) => + await ctx.db.query("serviceControl").unique()); + expect(control?.bootstrapAcceptedAt).toBeNumber(); + expect(await runtime.mutation(recordIssue, { + capabilityDigest: friend.capabilityDigest, + lifetimeMs: minimumInviteLifetimeMs, + publicId: friend.publicId, + purpose: "identity", + })).toMatchObject({ publicId: friend.publicId, replay: false, state: "issued" }); + expect(await runtime.run(async (ctx) => + (await ctx.db.query("authInvites").collect()).map((invite) => invite.publicId))) + .toEqual([friend.publicId]); + }); +}); diff --git a/convex/lifecyclePolicy.ts b/convex/lifecyclePolicy.ts index 7e61195..340cdf9 100644 --- a/convex/lifecyclePolicy.ts +++ b/convex/lifecyclePolicy.ts @@ -41,6 +41,7 @@ export const HOSTED_TABLE_LIFECYCLE = { deviceRevocationJobs: { owner: "user", quota: "job", retention: "job_until_complete", deletionOrder: 80, disposition: "erase" }, storageUsageByUser: { owner: "user", quota: null, retention: "active", deletionOrder: 150, disposition: "erase" }, storageUsageService: { owner: "service", quota: null, retention: "service_permanent", deletionOrder: null, disposition: "service_reset" }, + serviceControl: { owner: "service", quota: null, retention: "service_permanent", deletionOrder: null, disposition: "service_reset" }, storageResourceUsageByUser: { owner: "user", quota: null, retention: "active", deletionOrder: 150, disposition: "erase" }, storageResourceUsageByAccount: { owner: "user", quota: null, retention: "active", deletionOrder: 150, disposition: "erase" }, maintenanceState: { owner: "service", quota: null, retention: "service_permanent", deletionOrder: null, disposition: "service_reset" }, diff --git a/convex/maintenance.test.ts b/convex/maintenance.test.ts index 32c1463..c3544b2 100644 --- a/convex/maintenance.test.ts +++ b/convex/maintenance.test.ts @@ -44,6 +44,14 @@ const genesisQuota = makeFunctionReference<"mutation", Record, un ); describe("bounded cloud retention", () => { + test("does not create maintenance state before hard genesis", async () => { + const runtime = convexTest(schema, modules); + await expect(runtime.mutation(cleanupExpired, { limit: 200 })) + .rejects.toThrow("QUOTA_AUTHORITY_CORRUPT"); + expect(await runtime.run(async (ctx) => + (await ctx.db.query("maintenanceState").collect()).length)).toBe(0); + }); + test("materializes a legacy usage cursor before deleting its final source row", async () => { const runtime = convexTest(schema, modules); await runtime.mutation(genesisQuota, {}); diff --git a/convex/maintenance.ts b/convex/maintenance.ts index dc3d497..c276a79 100644 --- a/convex/maintenance.ts +++ b/convex/maintenance.ts @@ -13,6 +13,7 @@ import { releaseQuotaForDelete, releaseQuotaForStoredIdentity, releaseServiceQuotaForDelete, + requireHardQuotaAuthority, } from "./quota"; import { internalMutation, type MutationCtx } from "./server"; @@ -417,6 +418,7 @@ const emptyCounts = (): CleanupCounts => ({ export const cleanupExpired = internalMutation({ args: { limit: v.number() }, handler: async (ctx, args) => { + await requireHardQuotaAuthority(ctx); let remaining = requireCleanupLimit(args.limit); const now = Date.now(); const counts = emptyCounts(); diff --git a/convex/quota.test.ts b/convex/quota.test.ts index 138b2ab..bd7effe 100644 --- a/convex/quota.test.ts +++ b/convex/quota.test.ts @@ -309,6 +309,15 @@ describe("hosted quota authority", () => { await expect(attempted.mutation(genesisHardAuthority, {})) .rejects.toThrow("QUOTA_HARD_GENESIS_NOT_EMPTY"); + const maintenanceDirty = convexTest(schema, modules); + await maintenanceDirty.run(async (ctx) => await ctx.db.insert("maintenanceState", { + key: "retention", + nextCategory: "auth_attempts", + updatedAt: 1, + })); + await expect(maintenanceDirty.mutation(genesisHardAuthority, {})) + .rejects.toThrow("QUOTA_HARD_GENESIS_NOT_EMPTY"); + const historicalShadow = convexTest(schema, modules); await historicalShadow.run(async (ctx) => await ctx.db.insert("storageUsageService", { enforcement: "shadow", diff --git a/convex/quota.ts b/convex/quota.ts index 5401a4b..6c8b380 100644 --- a/convex/quota.ts +++ b/convex/quota.ts @@ -1,6 +1,12 @@ import { paginationOptsValidator } from "convex/server"; import { getDocumentSize, v, type GenericId as Id, type Value } from "convex/values"; +import { + identityInviteLifetimeMs, + invitePublicIdFromCapabilityDigest, + isInvitePublicId, +} from "../src/cloud/inviteAuthority"; +import { isAuthDigest } from "./authPolicy"; import { internalMutation, internalQuery, type MutationCtx } from "./server"; import { quotaAccountResource, @@ -140,6 +146,14 @@ function requireHardServiceAuthority( return authority; } +export async function requireHardQuotaAuthority(ctx: MutationCtx): Promise { + const rows = await ctx.db.query("storageUsageService") + .withIndex("by_key", (builder) => builder.eq("key", "global")) + .take(2); + if (rows.length !== 1) corrupt(); + requireHardServiceAuthority(rows[0]); +} + export function nextResourceRecords( current: number, delta: number, @@ -819,60 +833,217 @@ export const QUOTA_GENESIS_CHARGED_TABLES = [ const hasAny = async (promise: Promise): Promise => (await promise).length !== 0; +async function requireGenesisEmpty(ctx: MutationCtx): Promise { + const [serviceExists, controlExists] = await Promise.all([ + hasAny(ctx.db.query("storageUsageService").take(1)), + hasAny(ctx.db.query("serviceControl").take(1)), + ]); + if (serviceExists || controlExists) { + throw new Error("QUOTA_HARD_GENESIS_ALREADY_EXISTS"); + } + const occupied = await Promise.all([ + hasAny(ctx.db.query("users").take(1)), + hasAny(ctx.db.query("authSessions").take(1)), + hasAny(ctx.db.query("authAccounts").take(1)), + hasAny(ctx.db.query("authRefreshTokens").take(1)), + hasAny(ctx.db.query("authVerificationCodes").take(1)), + hasAny(ctx.db.query("authVerifiers").take(1)), + hasAny(ctx.db.query("authRateLimits").take(1)), + hasAny(ctx.db.query("authSubjects").take(1)), + hasAny(ctx.db.query("authEmailAttemptEvents").take(1)), + hasAny(ctx.db.query("authOtpChallenges").take(1)), + hasAny(ctx.db.query("authInvites").take(1)), + hasAny(ctx.db.query("devices").take(1)), + hasAny(ctx.db.query("deviceSessions").take(1)), + hasAny(ctx.db.query("deviceBindChallenges").take(1)), + hasAny(ctx.db.query("deviceKeyEnvelopes").take(1)), + hasAny(ctx.db.query("recoveryEnvelopes").take(1)), + hasAny(ctx.db.query("devicePresence").take(1)), + hasAny(ctx.db.query("sessionHeads").take(1)), + hasAny(ctx.db.query("sessionChunks").take(1)), + hasAny(ctx.db.query("sessionStreamEpochs").take(1)), + hasAny(ctx.db.query("executionLeases").take(1)), + hasAny(ctx.db.query("sessionCommands").take(1)), + hasAny(ctx.db.query("codexAccounts").take(1)), + hasAny(ctx.db.query("deviceAccountBindings").take(1)), + hasAny(ctx.db.query("accountUsageSnapshots").take(1)), + hasAny(ctx.db.query("idempotencyReceipts").take(1)), + hasAny(ctx.db.query("securityEvents").take(1)), + hasAny(ctx.db.query("accountDeletionJobs").take(1)), + hasAny(ctx.db.query("accountDeletionReceipts").take(1)), + hasAny(ctx.db.query("deviceRevocationJobs").take(1)), + hasAny(ctx.db.query("storageUsageByUser").take(1)), + hasAny(ctx.db.query("storageResourceUsageByUser").take(1)), + hasAny(ctx.db.query("storageResourceUsageByAccount").take(1)), + hasAny(ctx.db.query("maintenanceState").take(1)), + ]); + if (occupied.some(Boolean)) throw new Error("QUOTA_HARD_GENESIS_NOT_EMPTY"); +} + +async function insertHardAuthority( + ctx: MutationCtx, + now: number, + bootstrap?: Readonly<{ + capabilityDigest: string; + lifetimeMs: number; + publicId: string; + }>, +): Promise { + await ctx.db.insert("storageUsageService", { + enforcement: "hard", + identities: 0, + key: "global", + logicalBytes: 0, + records: 0, + serviceLogicalBytes: 0, + serviceRecords: 0, + updatedAt: now, + userLogicalBytes: 0, + userRecords: 0, + }); + await ctx.db.insert("serviceControl", { + authAdmissionGeneration: 0, + authAdmissions: "open", + ...(bootstrap === undefined + ? {} + : { + bootstrapCompletedAt: now, + bootstrapInviteCapabilityDigest: bootstrap.capabilityDigest, + bootstrapInviteLifetimeMs: bootstrap.lifetimeMs, + bootstrapInvitePublicId: bootstrap.publicId, + }), + key: "global", + updatedAt: now, + }); +} + export const genesisHardAuthority = internalMutation({ args: {}, handler: async (ctx) => { - const serviceExists = await hasAny(ctx.db.query("storageUsageService").take(1)); - if (serviceExists) throw new Error("QUOTA_HARD_GENESIS_ALREADY_EXISTS"); - const occupied = await Promise.all([ - hasAny(ctx.db.query("users").take(1)), - hasAny(ctx.db.query("authSessions").take(1)), - hasAny(ctx.db.query("authAccounts").take(1)), - hasAny(ctx.db.query("authRefreshTokens").take(1)), - hasAny(ctx.db.query("authVerificationCodes").take(1)), - hasAny(ctx.db.query("authVerifiers").take(1)), - hasAny(ctx.db.query("authRateLimits").take(1)), - hasAny(ctx.db.query("authSubjects").take(1)), - hasAny(ctx.db.query("authEmailAttemptEvents").take(1)), - hasAny(ctx.db.query("authOtpChallenges").take(1)), - hasAny(ctx.db.query("authInvites").take(1)), - hasAny(ctx.db.query("devices").take(1)), - hasAny(ctx.db.query("deviceSessions").take(1)), - hasAny(ctx.db.query("deviceBindChallenges").take(1)), - hasAny(ctx.db.query("deviceKeyEnvelopes").take(1)), - hasAny(ctx.db.query("recoveryEnvelopes").take(1)), - hasAny(ctx.db.query("devicePresence").take(1)), - hasAny(ctx.db.query("sessionHeads").take(1)), - hasAny(ctx.db.query("sessionChunks").take(1)), - hasAny(ctx.db.query("sessionStreamEpochs").take(1)), - hasAny(ctx.db.query("executionLeases").take(1)), - hasAny(ctx.db.query("sessionCommands").take(1)), - hasAny(ctx.db.query("codexAccounts").take(1)), - hasAny(ctx.db.query("deviceAccountBindings").take(1)), - hasAny(ctx.db.query("accountUsageSnapshots").take(1)), - hasAny(ctx.db.query("idempotencyReceipts").take(1)), - hasAny(ctx.db.query("securityEvents").take(1)), - hasAny(ctx.db.query("accountDeletionJobs").take(1)), - hasAny(ctx.db.query("accountDeletionReceipts").take(1)), - hasAny(ctx.db.query("deviceRevocationJobs").take(1)), - hasAny(ctx.db.query("storageUsageByUser").take(1)), - hasAny(ctx.db.query("storageResourceUsageByUser").take(1)), - hasAny(ctx.db.query("storageResourceUsageByAccount").take(1)), + await requireGenesisEmpty(ctx); + await insertHardAuthority(ctx, Date.now()); + return { enforcement: "hard" as const }; + }, +}); + +const refuseHostedBootstrap = (): never => { + throw new Error("HOSTED_BOOTSTRAP_AUTHORITY_REFUSED"); +}; + +export const genesisHostedAuthority = internalMutation({ + args: { + capabilityDigest: v.string(), + lifetimeMs: v.number(), + publicId: v.string(), + }, + handler: async (ctx, args) => { + if ( + !isAuthDigest(args.capabilityDigest) + || args.lifetimeMs !== identityInviteLifetimeMs + || !isInvitePublicId(args.publicId) + || invitePublicIdFromCapabilityDigest(args.capabilityDigest) !== args.publicId + ) return refuseHostedBootstrap(); + + const [serviceRows, controlRows, publicInvites, digestInvites] = await Promise.all([ + ctx.db.query("storageUsageService") + .withIndex("by_key", (builder) => builder.eq("key", "global")) + .take(2), + ctx.db.query("serviceControl") + .withIndex("by_key", (builder) => builder.eq("key", "global")) + .take(2), + ctx.db.query("authInvites") + .withIndex("by_public_id", (builder) => builder.eq("publicId", args.publicId)) + .take(2), + ctx.db.query("authInvites") + .withIndex("by_capability_digest", (builder) => + builder.eq("capabilityDigest", args.capabilityDigest)) + .take(2), ]); - if (occupied.some(Boolean)) throw new Error("QUOTA_HARD_GENESIS_NOT_EMPTY"); - await ctx.db.insert("storageUsageService", { - enforcement: "hard", - identities: 0, - key: "global", - logicalBytes: 0, - records: 0, - serviceLogicalBytes: 0, - serviceRecords: 0, - updatedAt: Date.now(), - userLogicalBytes: 0, - userRecords: 0, + if (serviceRows.length !== 0 || controlRows.length !== 0) { + const service = serviceRows[0]; + const control = controlRows[0]; + const invite = publicInvites[0]; + if ( + serviceRows.length !== 1 + || controlRows.length !== 1 + || publicInvites.length !== 1 + || digestInvites.length !== 1 + || service === undefined + || control === undefined + || invite === undefined + || digestInvites[0]?._id !== invite._id + || control.authAdmissionGeneration !== 0 + || control.authAdmissions !== "open" + || control.bootstrapAcceptedAt !== undefined + || control.lastMutationId !== undefined + || control.bootstrapInviteCapabilityDigest !== args.capabilityDigest + || control.bootstrapInviteLifetimeMs !== args.lifetimeMs + || control.bootstrapInvitePublicId !== args.publicId + || control.bootstrapCompletedAt !== control.updatedAt + || invite.capabilityDigest !== args.capabilityDigest + || invite.publicId !== args.publicId + || invite.purpose !== "identity" + || invite.state !== "issued" + || invite.issuedByUserId !== undefined + || invite.requestedLifetimeMs !== args.lifetimeMs + || invite.admissionExpiresAt !== invite.expiresAt + || invite.expiresAt - invite.createdAt !== args.lifetimeMs + || invite.updatedAt !== invite.createdAt + ) return refuseHostedBootstrap(); + requireHardServiceAuthority(service); + const inviteBytes = logicalDocumentBytes(invite); + if ( + service.identities !== 0 + || service.records !== 1 + || service.logicalBytes !== inviteBytes + || service.serviceRecords !== 1 + || service.serviceLogicalBytes !== inviteBytes + || service.userRecords !== 0 + || service.userLogicalBytes !== 0 + ) return refuseHostedBootstrap(); + return { + enforcement: "hard" as const, + invite: { + expiresAt: invite.expiresAt, + publicId: invite.publicId, + purpose: invite.purpose, + state: invite.state, + }, + replay: true, + }; + } + if (publicInvites.length !== 0 || digestInvites.length !== 0) { + return refuseHostedBootstrap(); + } + + await requireGenesisEmpty(ctx); + const now = Date.now(); + await insertHardAuthority(ctx, now, args); + const expiresAt = now + args.lifetimeMs; + const inviteId = await ctx.db.insert("authInvites", { + admissionExpiresAt: expiresAt, + capabilityDigest: args.capabilityDigest, + createdAt: now, + expiresAt, + publicId: args.publicId, + purpose: "identity", + requestedLifetimeMs: args.lifetimeMs, + state: "issued", + updatedAt: now, }); - return { enforcement: "hard" as const }; + const invite = await ctx.db.get(inviteId); + if (invite === null) return refuseHostedBootstrap(); + await reserveServiceQuotaForInsert(ctx, invite); + return { + enforcement: "hard" as const, + invite: { + expiresAt, + publicId: args.publicId, + purpose: "identity" as const, + state: "issued" as const, + }, + replay: false, + }; }, }); diff --git a/convex/schema-invariants.test.ts b/convex/schema-invariants.test.ts index ac28b64..70d203f 100644 --- a/convex/schema-invariants.test.ts +++ b/convex/schema-invariants.test.ts @@ -42,5 +42,12 @@ describe("hosted schema invariants", () => { expect(genesisTables).toEqual(chargedTables); expect(HOSTED_TABLE_LIFECYCLE.storageResourceUsageByUser.quota).toBeNull(); expect(HOSTED_TABLE_LIFECYCLE.storageResourceUsageByAccount.quota).toBeNull(); + expect(HOSTED_TABLE_LIFECYCLE.serviceControl).toEqual({ + owner: "service", + quota: null, + retention: "service_permanent", + deletionOrder: null, + disposition: "service_reset", + }); }); }); diff --git a/convex/schema.ts b/convex/schema.ts index f124b5c..941a4d3 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -6,6 +6,7 @@ import { accountBindingState, accountDeletionCategory, accountDeletionState, + authAdmissionState, authAttemptKind, authSubjectStatus, challengeDeliveryState, @@ -444,6 +445,18 @@ export default defineSchema({ userLogicalBytes: v.number(), userRecords: v.number(), }).index("by_key", ["key"]), + serviceControl: defineTable({ + authAdmissionGeneration: v.number(), + authAdmissions: authAdmissionState, + bootstrapAcceptedAt: v.optional(v.number()), + bootstrapCompletedAt: v.optional(v.number()), + bootstrapInviteCapabilityDigest: v.optional(v.string()), + bootstrapInviteLifetimeMs: v.optional(v.number()), + bootstrapInvitePublicId: v.optional(v.string()), + key: v.literal("global"), + lastMutationId: v.optional(v.string()), + updatedAt: v.number(), + }).index("by_key", ["key"]), storageResourceUsageByUser: defineTable({ records: v.number(), resource: quotaUserResource, diff --git a/convex/test.setup.ts b/convex/test.setup.ts index de7b61a..4de7d02 100644 --- a/convex/test.setup.ts +++ b/convex/test.setup.ts @@ -4,6 +4,7 @@ export const modules = { "./account.ts": async () => await import("./account"), "./authDelivery.ts": async () => await import("./authDelivery"), "./authInvites.ts": async () => await import("./authInvites"), + "./admissionControl.ts": async () => await import("./admissionControl"), "./commands.ts": async () => await import("./commands"), "./devices.ts": async () => await import("./devices"), "./deviceRevocation.ts": async () => await import("./deviceRevocation"), diff --git a/convex/validators.ts b/convex/validators.ts index 07f514b..ac1708e 100644 --- a/convex/validators.ts +++ b/convex/validators.ts @@ -2,6 +2,7 @@ import { v } from "convex/values"; export const authAttemptKind = v.union(v.literal("send"), v.literal("verify")); export const authSubjectStatus = v.union(v.literal("active"), v.literal("disabled")); +export const authAdmissionState = v.union(v.literal("open"), v.literal("frozen")); export const challengeDeliveryState = v.union( v.literal("reserved"), v.literal("accepted"), diff --git a/docs/beta-release-notes.md b/docs/beta-release-notes.md new file mode 100644 index 0000000..0aa0003 --- /dev/null +++ b/docs/beta-release-notes.md @@ -0,0 +1,34 @@ +# HRA v0.1.0 friend beta + +HRA is a persistent Codex CLI for isolated accounts, live session control, and optional end-to-end encrypted device sync. + +## Install + +Install the immutable beta tag with Bun 1.3.14: + +```sh +bun add --global https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz +hra --version +hra doctor --offline +hra init +``` + +Cloud enrollment is invitation-only during the friend beta. Keep the invite and email verification code out of shell arguments and history; `hra auth login --input-stdin` or `--input-fd` accepts one protected JSON document. + +## Included + +- Multiple isolated Codex account profiles and historical usage observations. +- Persistent local sessions with bounded event streaming, typed approval and question handoff, and agent-safe JSON or JSONL output. +- Encrypted session projections, device presence, pairing, revocation, and remote commands through the hosted HRA control plane. +- Reversible account switching for the supported macOS ChatGPT application. +- A checksummed install tarball, artifact-identity SPDX record, and Ubuntu 24.04 x64 accepted-install runtime SPDX inventory. + +## Known limits + +- This is a friend beta. Hosted identity creation requires a one-time invitation. +- Resolve approvals, questions, and forms on the device executing the session. Remote interaction resolution is not enabled. +- Plugin and connector discovery is read-only. HRA does not install, enable, authorize, or open OAuth flows. +- Desktop account switching is macOS-only in this release. +- Device credentials are bearer credentials, not hardware-bound proofs. Revoke a missing or suspect device from another active device. + +Read the [v0.1.0 README](https://github.com/hraness/hra/tree/v0.1.0#readme), [privacy notice](https://github.com/hraness/hra/blob/v0.1.0/PRIVACY.md), and [security policy](https://github.com/hraness/hra/blob/v0.1.0/SECURITY.md) before enrollment. Report defects through [GitHub issues](https://github.com/hraness/hra/issues) and security concerns through the private process in the security policy. diff --git a/docs/beta-release.md b/docs/beta-release.md new file mode 100644 index 0000000..d6e7faa --- /dev/null +++ b/docs/beta-release.md @@ -0,0 +1,48 @@ +# Beta release + +The `v0.1.0` release has one explicit irreversible step. GitHub Actions verifies the exact current `main` commit, builds and accepts the tarball, generates both SPDX records, creates or resumes one draft release, replaces its four assets with the accepted bytes, reads every asset back, and stops. The built-in Actions token never publishes the draft because it cannot read the repository's immutable-release setting. + +Publish only from a clean checkout of the accepted commit with an authenticated GitHub CLI session that can read repository administration settings. Do not copy a broad personal token into Actions. + +## Stage the draft + +Complete hosted acceptance, final domain cutover, and the `release-ready` public-content change first. Record the exact merged and deployed new-HRA commit as `N_COMMIT`. Create `v0.1.0` at `N_COMMIT` and push that one tag. The Release workflow must finish with `Stage verified release draft` successful. + +Record the exact workflow run ID and attempt from GitHub. Do not select “latest.” The publication command checks repository ID `1343008607`, path `hraness/hra`, workflow name and path, tag push, run ID and attempt, head commit, successful conclusion, and the one unexpired `hra-release-v0.1.0` artifact. + +## Publish + +From a clean `main` checkout at `N_COMMIT`, run: + +```sh +bun run release:publish -- publish \ + --tag v0.1.0 \ + --run-id \ + --run-attempt \ + --expected-commit \ + --gh-cli /opt/homebrew/bin/gh \ + --acknowledge-immutable-publication +``` + +Before publication, the operator downloads the exact one-day Actions artifact; validates its exact file set, checksums, artifact-identity SPDX record, Ubuntu 24.04 x64 runtime SPDX inventory, package tree, and isolated install; compares every draft asset and all release metadata; requires the REST release record to be `draft: true` and `immutable: false`; re-resolves the fully qualified tag and `heads/main`; reads immutable-release enforcement with the local admin session; and requires a direct nonredirecting HTTP 200 from the canonical `hra.sh` generation-1 marker at `N_COMMIT`. + +Undrafting is the commit point. The operator PATCHes the numeric release ID whose metadata and assets it just accepted; it never resolves the tag again for the mutation. It then requires that same REST release record to be `draft: false` and `immutable: true`, with unchanged metadata and bytes, anonymously downloads and digest-verifies the public tarball, and performs the exact-URL install, version check, production-tree policy, and offline doctor in isolated state. + +Success prints one bounded JSON value with status `published`. A refusal before the commit point reports phase `before_publication` and leaves the draft reversible. An ambiguous undraft reports `publication_unknown`; inspect the exact release before doing anything else. + +## Acceptance recovery + +If publication succeeded but a later readback or public-route check failed, never delete, rewrite, or replace the immutable release. From a clean checkout of `N_COMMIT`, retry only acceptance: + +```sh +bun run release:publish -- accept \ + --tag v0.1.0 \ + --run-id \ + --run-attempt \ + --expected-commit \ + --gh-cli /opt/homebrew/bin/gh +``` + +The Actions artifact is retained for one day. Publish or complete acceptance while it remains available. If it expires before the commit point, rerun the exact tag workflow, record the new exact run attempt, and repeat the checks. + +The staging workflow safely resumes one exact draft and replaces the four expected assets with accepted bytes. If the draft has wrong metadata or unexpected extra assets, first read its numeric ID, tag, draft state, and asset list through the GitHub API. Delete only that confirmed unpublished `v0.1.0` draft, leave the tag untouched, rerun the tag workflow, and use its new exact run attempt. Never use this cleanup path after publication. diff --git a/docs/domain-cutover.md b/docs/domain-cutover.md index 4f61605..69a2004 100644 --- a/docs/domain-cutover.md +++ b/docs/domain-cutover.md @@ -85,7 +85,7 @@ Q must pass authenticated root, privacy, `robots.txt`, `sitemap.xml`, `llms.txt` Assign N to the fixed new-HRA staging alias only after its authenticated checks pass: ```sh -vercel alias set .vercel.app try-hra.vercel.app --scope hraness +vercel alias set try-hra.vercel.app --scope hraness vercel api /v4/aliases/try-hra.vercel.app --scope hraness --raw | jq -c '{alias,projectId,deploymentId,deployment:{id:.deployment.id,url:.deployment.url}}' ``` @@ -189,8 +189,21 @@ This decision table is symmetric, so it applies to forward and reverse movement. Repository names and immutable tags do not roll back. Traffic can return to HRA v0 with the checked `reverse` plan: -1. Prove Q and `https://hra-weld.vercel.app` healthy, then read P separately by ID. -2. Run N → Q and require a `committed` JSON result. -3. Independently verify exact Q alias, marker, and domain ownership. -4. Disable new hosted invitations and credentials without changing HRA v0 data or provider state. -5. Keep `hraness/hra` and every published tag intact. Repair forward on a new protected current-head commit, deploy it, rehearse both directions, and cut over again. +1. Freeze new hosted authentication admission on the exact new-HRA Convex deployment before moving public traffic. Read the current generation, choose one UUIDv7, and preserve that safe replay tuple in the private incident record: + + ```sh + bun run hosted:admission -- status + bun run hosted:admission -- freeze --expected-generation --mutation-id + ``` + + The checked operator verifies the numeric team, project, deployment, production type, generated name, URL, and default-production binding before and after the mutation. Freeze blocks new identity invites, OTP requests and verification, auth session creation or refresh, and new device credentials. Existing reads, account deletion, device revocation, maintenance, and an exact replay of a registration already committed before the freeze remain available. A lost response is recovered only by repeating the same generation and UUIDv7. Do not rotate JWT, HMAC, email, or device keys as an incident shortcut. +2. Prove Q and `https://hra-weld.vercel.app` healthy, then read P separately by ID. A failed Q proof leaves new-HRA admission frozen and public traffic unchanged. +3. Run N → Q and require a `committed` JSON result. If reversal fails, keep admission frozen while the checked operator restores or proves the last safe traffic state. +4. Independently verify exact Q alias, marker, and domain ownership. +5. Revoke every still-live invitation by its recorded public ID with the checked invitation operator. Revoke a suspect device from another active device or begin authenticated account deletion for a compromised identity. +6. Resume admission only after a fixed forward release passes the full live gate. Read the frozen generation, choose a new UUIDv7, and require the explicit resume acknowledgement: + + ```sh + bun run hosted:admission -- resume --expected-generation --mutation-id --acknowledge-resume + ``` +7. Keep `hraness/hra` and every published tag intact. Repair forward on a new protected current-head commit, deploy it, rehearse both directions, and cut over again. diff --git a/docs/hosted-sync.md b/docs/hosted-sync.md index b69a618..25db744 100644 --- a/docs/hosted-sync.md +++ b/docs/hosted-sync.md @@ -18,7 +18,7 @@ The provider identity guard pins the intended Convex team to numeric ID `513923` bun run hosted:deploy -- \ --deployment steady-otter-321 \ --team-id 513923 \ - --project-id 1234567 \ + --project-id 2854545 \ --deployment-id 7654321 \ --deployment-url https://steady-otter-321.convex.cloud \ --source-commit 0123456789abcdef0123456789abcdef01234567 @@ -44,7 +44,7 @@ Pass the JSON from a protected secret source through standard input: protected-json-source | bun run hosted:configure -- \ --deployment steady-otter-321 \ --team-id 513923 \ - --project-id 1234567 \ + --project-id 2854545 \ --deployment-id 7654321 \ --deployment-url https://steady-otter-321.convex.cloud ``` @@ -55,7 +55,7 @@ An agent can use a private nonterminal descriptor: bun run hosted:configure -- \ --deployment steady-otter-321 \ --team-id 513923 \ - --project-id 1234567 \ + --project-id 2854545 \ --deployment-id 7654321 \ --deployment-url https://steady-otter-321.convex.cloud \ --input-fd 3 3< <(protected-json-source) @@ -74,36 +74,109 @@ The helper reads at most 8 KiB, rejects a terminal descriptor, and ignores inher If any target name already exists, the names response is ambiguous, Convex refuses the batch, or the final names readback is incomplete, the helper closes with a generic error. A failure after the batch may have left a complete or partial provider write. Do not retry or overwrite. Inspect names only, then replace the still-unused deployment if the result is uncertain. -## Establish hard quota authority and issue the first invite +## Establish hosted authority and issue the first invite -No authentication, OTP, invitation, device, or application write may happen before quota genesis. Choose a new absolute output path in a private operator directory. The path must not exist. Run the one-shot bootstrap on the exact new production deployment: +No authentication, OTP, invitation, device, or application write may happen before bootstrap. One request-bound mutation atomically establishes hard quota authority, open generation-zero authentication-admission authority, a durable binding to the first invitation digest, and the charged first invitation. Choose a new absolute output path in a private operator directory. The path must not exist. Run the one-shot bootstrap on the exact new production deployment: ```sh bun run hosted:bootstrap -- \ --deployment steady-otter-321 \ --team-id 513923 \ - --project-id 1234567 \ + --project-id 2854545 \ --deployment-id 7654321 \ --deployment-url https://steady-otter-321.convex.cloud \ --invite-output /absolute/private/path/identity-invite ``` -The helper uses the authenticated CLI state, strips inherited Convex deploy credentials, and performs numeric target preflight and postflight. It performs this closed sequence: +The helper uses authenticated CLI state, strips inherited Convex deploy credentials and any inherited value containing an invitation capability, bounds every provider call, and performs numeric target preflight and postflight. It performs this closed sequence: -- Read at most two `storageUsageService` rows through a bounded inline query and require an exact empty array. -- Resolve and open the output's existing parent as a no-follow directory, hold and repeatedly verify its device and inode, then reserve the requested output with no-follow, exclusive creation and mode `0600`. Existing files and final-component symlinks are refused before mutation. The new directory entry is synced before genesis, so an issued capability can never depend on an output reservation that existed only in memory. -- Run `quota:genesisHardAuthority` and require exactly `{"enforcement":"hard"}`. The mutation atomically refuses every pre-genesis authentication, invitation, device, application, or quota row covered by hard authority. -- Read the authority again and require exactly one strict `global` row in `hard` mode, with all identity, record, logical-byte, service, and user counters at zero. -- Run `authInvites:issue` once for a 24-hour identity invite and parse one strict result. -- Read the exact numeric provider identity again after invite issuance and before disclosing the capability locally. -- Sync only the bare bearer capability plus a newline to the already reserved file. Verify that the same inode remains a regular, single-link, mode-`0600` file, sync the parent directory again, and close both held handles. Abort removes only the inode this process reserved and syncs that removal. +- Read at most two `storageUsageService`, `serviceControl`, `maintenanceState`, and `authInvites` rows through one bounded inline query and require all four exact empty arrays. Scheduled maintenance is hard-gated on quota authority and may not create its cursor first. +- Resolve and open the output's existing parent as a no-follow directory, require that it is owned by the invoking user with exact mode `0700`, hold and repeatedly verify its owner, mode, device, and inode, then reserve the requested output with no-follow, exclusive creation and mode `0600`. Existing files, shared parent directories, and final-component symlinks are refused before mutation. +- Generate the 256-bit bearer capability locally. Derive its remote public ID from its purpose-separated SHA-256 digest, sync only the capability plus a newline to the reserved file, and verify the same single-link mode-`0600` inode. This protected local custody is durable before the first provider mutation. The capability never enters provider arguments, environment, stdout, or stderr. +- Run `quota:genesisHostedAuthority` with only the digest, derived public ID, and exact 24-hour lifetime. The mutation requires every covered authentication, invitation, device, application, quota, maintenance, and service-control table to be pristine. In one Convex transaction it creates the hard quota singleton, the open service-control singleton with the complete bootstrap binding, the first identity invitation, and its exact service quota charge. A concurrent request with a different full capability digest is refused. Replaying the exact request is neutral. +- Read all three rows again. Require exactly one strict `global` hard quota row charged for exactly one service-owned invitation and zero user data, one strict open generation-zero service-control row bound to this full digest, public ID, and lifetime, and one strict issued invitation with the same binding. Recompute the stored invitation's Convex logical size locally and require both aggregate and service byte counters to equal it. +- Read the exact numeric provider identity again after the mutation. A pre-custody failure removes only the inode this process reserved. Once custody is durable, every failure preserves the capability file for deterministic recovery. -Provider stdout, provider stderr, and the capability are never forwarded. The safe success line attests that hard zero authority was read back before the first invitation write and that the capability reached the protected file. +Provider stdout, provider stderr, and the capability are never forwarded. Success returns one bounded JSON object with the safe public invite ID and non-secret invitation state. It attests that the request-bound hosted authority and exact quota charge were read back and that the capability reached the protected file. Record the public ID so the first invitation can be inspected or revoked without reading its capability. -A refusal means the deployment was dirty, genesis already existed, provider output was ambiguous, the exact zero singleton was not proven, invite issuance failed, or the output could not be protected. Do not retry or overwrite after any genesis attempt. Replace the still-unlaunched deployment and repeat the full fresh-state sequence. +A refusal means the deployment was dirty, another request won bootstrap, provider output was ambiguous, exact authority or quota readback failed, or the output could not be protected. Do not overwrite or discard a populated capability file. Reconcile the exact default deployment first, then recover only through the bootstrap operator with that same file: + +```sh +bun run hosted:bootstrap -- recover \ + --deployment steady-otter-321 \ + --team-id 513923 \ + --project-id 2854545 \ + --deployment-id 7654321 \ + --deployment-url https://steady-otter-321.convex.cloud \ + --invite-file /absolute/private/path/identity-invite +``` + +Recovery accepts only an invoking-user-owned, single-link, mode-`0600` regular file inside the same owned mode-`0700` directory. It rederives the full request binding, invokes only `quota:genesisHostedAuthority`, and requires the same exact three-row readback. It can finish a crash that happened after local custody but before the mutation, and it can reconcile a lost mutation response through exact durable state. It never calls ordinary `authInvites:recordIssue`. A different winner returns `bootstrap_authority_conflict`; the losing file remains intact and must never be passed to `hosted:invites recover`. + +If no populated capability file exists and every pre-bootstrap authority remains empty, replace the still-unlaunched deployment and repeat the full fresh-state sequence. Any other state is an incident and must remain quarantined for inspection. ## Accept the first invite -Read the capability file only into HRA's protected authentication JSON input. Never print it, substitute it into argv, copy it into an environment variable, or route it through a log. Complete the verified-email code flow, confirm the identity and first device are active, then consume and remove the one-time capability file. +Read the capability file only into HRA's protected authentication JSON input. Never print it, substitute it into argv, copy it into an environment variable, or route it through a log. Complete the verified-email code flow and confirm the identity and first device are active. Consuming this specific bound invitation atomically records a durable bootstrap-accepted timestamp in service control. Later friend invitation issuance depends on that durable fact, so maintenance may remove the terminal invitation receipt without relocking the service. Then remove the one-time capability file. Continue launch acceptance with a second pending device approved by the active device, encrypted projection sync in both directions, usage upload cadence, session streaming, command custody, interaction resolution, revocation, and account deletion. Keep hosted invitations disabled and do not move `hra.sh` until every live acceptance and rollback gate in the release plan passes. + +## Operate friend-beta invitations + +Run the friend-beta operator only after the one-shot bootstrap and first-invite acceptance are complete. The server refuses new friend invitations until the durable bootstrap-accepted fact exists. This operator never creates or retries hosted authority. Do not run fresh `hosted:bootstrap` on an initialized deployment; use its dedicated `recover` command only for the original protected bootstrap file. + +Issue one 24-hour identity invite into a new absolute path whose parent is an existing invoking-user-owned mode-`0700` operator directory: + +```sh +bun run hosted:invites -- issue \ + --deployment steady-otter-321 \ + --team-id 513923 \ + --project-id 2854545 \ + --deployment-id 7654321 \ + --deployment-url https://steady-otter-321.convex.cloud \ + --invite-output /absolute/private/path/friend-name.invite +``` + +The output path must not exist. The operator reserves it with no-follow exclusive creation, fixes and verifies mode `0600`, generates the capability locally, and durably commits it before provider access. Convex receives only the capability digest and deterministic public ID through an idempotent mutation. It never receives or returns the bearer capability. The operator never puts the capability in argv, an environment variable, terminal output, provider output, or a temporary file. Success prints one bounded JSON object containing the safe public invite ID and non-secret state. Record that public ID in the private release record, then deliver the capability file through the same protected authentication-input flow used for the first invite. + +If issuance returns an indeterminate refusal after the capability file was committed, restore exact default-target certainty and recover from the same file: + +```sh +bun run hosted:invites -- recover \ + --deployment steady-otter-321 \ + --team-id 513923 \ + --project-id 2854545 \ + --deployment-id 7654321 \ + --deployment-url https://steady-otter-321.convex.cloud \ + --invite-file /absolute/private/path/friend-name.invite +``` + +Recovery accepts only an owned, single-link, mode-`0600` regular file inside an owned mode-`0700` parent, holds and revalidates both inodes, reads the file without following links, and rederives the digest and public ID. It first reads status by that public ID. Existing issued, bound, consumed, or revoked state is returned without replay. If no row exists, it invokes the same idempotent record mutation and then requires a second status readback. A malformed or lost mutation response is therefore reconciled from durable remote state. Failure preserves the file and prints no capability. + +Read status with the public ID: + +```sh +bun run hosted:invites -- status \ + --deployment steady-otter-321 \ + --team-id 513923 \ + --project-id 2854545 \ + --deployment-id 7654321 \ + --deployment-url https://steady-otter-321.convex.cloud \ + --public-id invite_PPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP +``` + +Revoke the same invite when delivery is abandoned or access should end: + +```sh +bun run hosted:invites -- revoke \ + --deployment steady-otter-321 \ + --team-id 513923 \ + --project-id 2854545 \ + --deployment-id 7654321 \ + --deployment-url https://steady-otter-321.convex.cloud \ + --public-id invite_PPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP +``` + +Status and revoke accept only the public ID, never the bearer capability. Revoke reads and validates identity-invite status before mutation. Every operation performs authenticated numeric target readback before and after its bounded Convex call, requires the exact HRA team, project, production deployment, generated name, and URL, and refuses HRA v0 project ID `2680173` and deployment ID `4677913`. Provider stdout and stderr are suppressed; failures return only a static refusal code. + +If issuance is refused after protected custody commits, do not repeat `issue` with a new path. Reconcile the exact default deployment, then use `recover` with the preserved file. Keep the file until a strict result returns its deterministic public ID or the deployment is formally quarantined. diff --git a/docs/live-acceptance.md b/docs/live-acceptance.md new file mode 100644 index 0000000..55e4235 --- /dev/null +++ b/docs/live-acceptance.md @@ -0,0 +1,117 @@ +# Live acceptance + +The release gate uses two complete HRA daemon installations. It does not simulate a second device with two cloud-control objects, replace `HOME`, create temporary macOS users, or write acceptance credentials to Keychain. + +This harness is repository-only. It lives under `scripts/`, is excluded from the npm package, and does not add a state-root, socket, capability, or alternate-installation option to the production CLI. + +## Isolation + +`startLiveAcceptanceRun()` creates one canonical mode-`0700` run directory below the canonical operating-system temporary directory. It creates four distinct direct children: + +- one state root for device A; +- one state root for device B; +- one project directory for device A; +- one project directory for device B. + +Every child is a canonical mode-`0700` directory owned by the invoking user. The harness records the device and inode of each directory before it starts either worker. It refuses any run directory that overlaps the production HRA state root or the invoking home in either direction. + +Each worker receives one strict descriptor through inherited nonterminal file descriptor 3. The descriptor is bounded to 8 KiB and contains the run ID, device name, state root, project directory, exact expected `HOME`, and optional cloud deployment URL. These values never appear in worker arguments or environment variables. The worker arguments contain only the fixed source-worker path. + +File descriptor 4 carries bounded CLI invocations, protected input documents, and internal cleanup commands. It also owns the worker lifetime. Every scenario operation enters the exported HRA `main()` function, passes through the production parser and renderer, and reaches the daemon through the ordinary local transport. A protected command must select `--input-fd 4`; the worker proves that descriptor is nonterminal, consumes exactly one paired document, and rejects stdin, another descriptor, an unused document, `--follow`, and daemon lifecycle commands. Parent death aborts an in-flight local request immediately, closes the queue, and requests bounded daemon shutdown. File descriptor 5 carries bounded typed CLI results and lifecycle acknowledgements. Worker stdout and stderr are closed, so provider output, credentials, paths, and diagnostics cannot escape through process output. + +Each worker supervises sequential full daemon generations. A successful auth completion or account-deletion response that declares `daemonRestartRequired` is delivered first, then the worker waits for complete authority release and starts a new generation before accepting another operation. An unexpected daemon completion after readiness is terminal. Device suspend and resume stop and start a full generation while preserving the worker control process, which allows the scenario to cross the hosted presence boundary without a second state authority. + +Both installations preserve the invoking `HOME` byte for byte. Each account still receives its ordinary isolated `CODEX_HOME`. Acceptance `CODEX_HOME` directories contain this exact configuration: + +```toml +cli_auth_credentials_store = "file" +mcp_oauth_credentials_store = "file" +``` + +The daemon proves both effective values through the pinned Codex `config/read` preflight before login or plugin discovery. It assigns a distinct private `TMPDIR` below each `CODEX_HOME`. HRA secret custody uses `FileSecretBackend` below the corresponding state root. The acceptance composition cannot construct the production `BunSecretBackend`, and it disables desktop switching. + +Before the daemon starts, each worker changes its process working directory to its verified isolated project. Codex therefore loads its account-level credential policy from the same project boundary used by the effective `config/read` checks. The repository checkout and another device's project cannot contribute a startup project layer. + +## Operator driver + +The executable is the release gate. It requires the explicit canonical origin to equal the candidate authority compiled into this checkout. Loopback, a different HTTPS deployment, an omitted value, and a noncanonical spelling all fail before either worker starts. Put this non-secret configuration in a mode-`0600` file: + +```json +{"cloudDeploymentUrl":"https://qualified-hummingbird-537.convex.cloud","operator":{"kind":"terminal"},"version":1} +``` + +Run the complete scenario with that document on a nonterminal descriptor: + +```sh +bun run acceptance:live --scenario-fd 3 3< /protected/path/to/live-acceptance.json +``` + +Terminal mode hides every invite, OTP, auth document, interaction answer, and permission grant. Before rendering provider-controlled login handoff values, it requires an HTTPS URL without credentials or terminal-control scalars and a short uppercase alphanumeric device code. It then waits for the human to acknowledge provider completion. The process prints safe progress to stderr and exactly one final JSON value to stdout. Exit `0` means the full scenario and cleanup passed. It does not mean that two daemons merely became ready. + +The gate also requires a clean Git worktree and resolves the exact `HEAD` commit before starting workers. Passing evidence binds the SHA-256 digest of the configured cloud origin, the package version, and the 40-character source revision. This makes a result from a local fake, a different deployment, a dirty checkout, or a different source revision distinguishable from the intended release candidate. + +Agent runners can select `{"operator":{"kind":"jsonl"}}`. In that mode fixed inherited streaming IPC descriptor 5 emits bounded requests and fixed inherited streaming IPC descriptor 4 accepts one matching response at a time. Requests carry a UUID and one of `protected_input_required`, `device_login_required`, or `progress`. Responses must echo the UUID and be exactly one of: + +```json +{"document":{"email":"person@example.com"},"requestId":"00000000-0000-4000-8000-000000000000","type":"protected_input","version":1} +{"acknowledged":true,"requestId":"00000000-0000-4000-8000-000000000000","type":"device_login","version":1} +``` + +The candidate configuration descriptor cannot reuse descriptor 4 or 5. A closed operator input, an unexpected response type or UUID, a terminal descriptor, an oversized frame, or extra fields fails closed and retains recovery state. `SIGINT` and `SIGTERM` abort protected reads, device calls, polls, presence sleeps, and cleanup waits. Preservation joins any in-flight cleanup before it writes a recovery state, so cleanup and interruption cannot race receipt deletion or overwrite each other's checkpoint. + +The source API remains available for deterministic tests and recovery tooling. `run.device("a")` exposes only the verified project directory, bounded CLI `execute()`, and daemon-generation `suspend()` and `resume()`. It does not expose arbitrary state paths, sockets, capabilities, cloud controls, or a public `LocalCommand` transport. + +## Release scenario + +The executable performs these checks itself. Use two distinct real Codex subscriptions. Reusing one subscription under two labels fails the provider-identity proof. + +1. Consume the one-time HRA identity invite on device A through protected auth input. Complete the email code flow and prove that A becomes the first active keyed device. +2. Add and complete Codex login for two accounts on A. Prove distinct provider identities, isolated `CODEX_HOME` directories, and one exact `observed` usage poll and snapshot bound to each account ID and source revision. +3. Authenticate device B to the same HRA identity. The harness compares an ephemeral in-memory digest of the canonical email in A's invite and code documents with B's email and code documents before each later auth effect; it never persists or emits that digest. Prove that B registers as pending, cannot sync or read encrypted projections, and cannot submit a remote command. +4. Approve B from A, pair B, and prove that B receives the workspace key without receiving A's device credential. +5. Start one session under each Codex account. Use unique non-secret markers. Follow each exact account, session, and turn while active. Prove one uninterrupted provider authority, ordered cursor pages without gaps or terminal errors, a safe reasoning summary, an assistant delta containing the exact marker, and terminal completion. +6. Exercise one `request_user_input` interaction and one permission-request interaction before `/bin/echo hra-live-tool-progress`. Discover each as pending and blocking under the exact session and turn. Resolve it by exact ID and revision through protected input, require the CLI's advanced `response_written` interaction with `responseRecorded: true`, and prove the same interaction's requested, response-prepared, and response-written events occur inside the turn boundary under one provider authority. Bind command start, nonempty output progress, and completed command execution to the same item and turn. Run a bounded, path-free plugin discovery for the exact account, then prove `auth`, `disable`, `enable`, and `install` all receive the exact production-parser `INVALID_INPUT` result and exit code 2. +7. Sync from both devices. Prove that B reads A's assistant marker, receives a pending receipt for an exactly bound `send` command to the A-custodied session, and observes its terminal `applied` status. Then repeatedly sync and pull the exact complete, gap-free A projection until the submitted turn has both the expected assistant marker and its terminal turn summary. The gate does not require sampling the transient `effect_started` state. +8. Prove device presence transitions across a daemon stop, the 45-second offline boundary, and restart. Revoke B from A. Prove exact `UNAVAILABLE` denial for sync, the exact remote session read, and a new remote send. After another presence boundary, prove A sees that exact B device as revoked and offline. +9. Call `run.cleanup()`. Do not remove either local root manually. + +The scenario fails if it does not observe an active polling interval, continuous ordered progress, a reasoning-summary event, same-item tool start/progress/completion, assistant output for both local turns, both exact interactions, terminal turn settlement, pending and applied remote states, a terminal remote assistant result, or any presence and revocation boundary. It compares the two provider identities only in memory and exports the boolean `providerIdentitiesDistinct: true`; it does not export email hashes or other linkable provider commitments. Final evidence otherwise contains only non-secret IDs, timestamps, event-kind sets, marker digests, state transitions, target digest, source revision, package version, and pass outcomes. It never contains provider credentials, emails, invites, OTPs, device codes, raw reasoning, arbitrary tool output, local paths, environment values, socket capabilities, or encrypted workspace keys. + +## Cleanup + +`run.cleanup()` advances a durable checkpoint only after it proves each boundary: + +1. If no HRA identity was created, both installations must prove that no local cloud device exists and cloud deletion is skipped. If A is signed in but B has not registered, B must prove the same canonical email and no device. If B registration committed across a lost response before its public ID reached the receipt, recovery compares B's own signed-in device ID and status with A's sole noncurrent peer, durably records that exact public ID and a stable revocation idempotency key, and only then revokes it. A previously bound pending or active B is revoked with the same key; an already revoked B is re-proved. Any different active or pending peer, mismatched identity, ambiguous device row, or unrelated historical revoked row blocks cleanup. +2. Cloud account deletion reaches fresh terminal `complete` status with effects disabled. A second `auth.status` read proves the same state. +3. Every Codex account on both devices completes logout. If a prior logout response was lost, recovery first runs the required exact `account show` reconciliation and issues no new logout unless that read proves the provider remains signed in. A new account-list read proves no profile remains signed in, login-pending, or recovery-required. +4. Both daemon workers stop. Each child exits successfully, releases its daemon authority with a `stopped` receipt, and removes its socket and capability. +5. The invoking `HOME` is still the exact original value. +6. Every recorded directory still has its original owner, mode, device, inode, canonical location, direct-child relationship, role prefix, and run ID. + +Only then does cleanup assign a random quarantine name inside the owned run directory, atomically rename one direct child, recheck its inode, recursively remove it, and advance the receipt. It repeats this for all four children, removes the empty run directory, then removes the receipt. + +Any failure closes the worker control pipes, preserves the roots, and writes a mode-`0600` recovery receipt beside the run directory. The receipt records the last completed checkpoint, any exact B identity and revocation key learned before failure, the cloud-cleanup mode, and each planned or completed quarantine transition. It contains paths and process IDs but no credentials or bearer capabilities. + +## Recovery + +Wait until every recorded worker PID has exited. Pass the complete receipt through a nonterminal descriptor: + +```sh +bun scripts/live-acceptance.ts --resume-fd 3 3< /protected/path/to/recovery-receipt.json +``` + +The only argument is the descriptor number. The state root, socket, capability, and receipt contents do not enter argv or the environment. + +Recovery treats the mode-`0600` file on disk as authoritative. The caller-provided document is only a protected locator. Recovery opens the exact file without following links, verifies its owner, link count, mode, size, device, and inode before and after the bounded read, and parses the on-disk document. It rejects substituted caller fields. + +It then revalidates the run-ID filename, receipt location, run-root prefix, all four distinct role paths and prefixes, directory identities, canonical temporary parent, and non-overlap with production HRA state and the invoking home. A live PID, stale socket, changed inode, symlink, unknown direct child, incomplete cloud erasure, incomplete Codex logout, or failed shutdown proof leaves the receipt and roots intact. + +If cleanup stopped before daemon shutdown, recovery starts two new attached workers against the same isolated installations and converges from no auth, partial same-identity auth, a lost B registration response, or a bound pending, active, or revoked B before continuing from the last safe cloud or logout checkpoint. If daemon shutdown was already proved, recovery resumes only the recorded quarantine transitions. A crash after a rename or removal is reconciled from the original and planned quarantine paths before any next deletion. + +## Deterministic evidence + +The suite proves strict descriptor parsing, exact candidate selection, canonical private roots, unchanged `HOME`, distinct state and temporary paths, file-only HRA and Codex custody, disabled desktop switching, absence of state authority in worker argv and environment, real CLI and protected-input routing, full-generation suspend/resume, symlink refusal, lost-pair derivation, exact-peer revocation, ambiguous-logout reconciliation, gated quarantine deletion, authoritative on-disk recovery, checkpoint resumption, serialized cleanup interruption, and a real two-subprocess smoke in which both full daemons become ready and stop with released authority and absent socket and capability endpoints. The scenario test drives every release step through a deterministic two-device world, rejects prompt-only result markers, empty usage, local event gaps, wrong remote projection authority, incomplete remote turns, mismatched interaction receipts, mismatched B identity, and terminal-unsafe login handoffs. A real subprocess regression keeps the JSONL input descriptor open, aborts the read, and proves the child exits. Final evidence omits provider identity values, provider-derived hashes, and device-code values. + +```sh +bun test scripts/live-acceptance.test.ts scripts/live-acceptance-scenario.test.ts +``` diff --git a/kb/plans/hra-v1.md b/kb/plans/hra-v1.md index ee48301..c3f0d3e 100644 --- a/kb/plans/hra-v1.md +++ b/kb/plans/hra-v1.md @@ -312,13 +312,13 @@ Published beta quota constants are 16 devices, 32 Codex accounts, 10,000 session ## Repository and release contract - Public repository: `hraness/hra`, numeric repository ID `1343008607` after the source repository is renamed in place. -- Bun package name and binary: `hra`. The beta install source is the tagged GitHub repository. +- Bun package name and binary: `hra`. The beta install source is the exact tarball asset attached to the immutable GitHub release, never a moving branch or raw repository checkout. - License: MIT. Retain required notices for pinned dependencies and generated protocol material. - One Bun 1.3.14 lockfile. - Website: `hra.sh`, generated from the same content contract as `README.md`. - The first website line after the product name is the real install command. -- Release artifacts include the source package, checksums, an SBOM, and generated changelog notes. -- CI runs lint, typecheck, unit, property, integration, secret-shape, package-content, static-site, and clean-install gates on macOS and Linux where the behavior is supported. Tagged releases add an SPDX dependency inventory and checksums. +- Release artifacts include the install tarball, checksums, reviewed notes, an artifact-identity SPDX record bound to the tarball digest, and a separately named Ubuntu 24.04 x64 SPDX inventory of the exact isolated runtime installation accepted before publication. The runtime inventory is platform- and resolution-specific rather than a claim about every consumer installation. +- CI runs lint, typecheck, unit, property, integration, secret-shape, package-content, static-site, and clean-install gates on macOS and Linux where the behavior is supported. A tag-push workflow binds the peeled tag and current main to one commit, installs and inspects the exact tarball, creates both SPDX records and checksums, stages or resumes one exact draft, and reads every draft asset back byte-for-byte. It never publishes. The irreversible local operator accepts only that exact successful workflow run and artifact bundle, proves immutable-release enforcement through the authenticated GitHub CLI keyring, rechecks the tag, main, and canonical deployment marker immediately before undrafting, then verifies the immutable prerelease and downloads the public tarball without GitHub credentials before an isolated exact-URL install. Lost publication responses reconcile only from exact immutable public readback; post-publication failures resume through an acceptance-only action. The existing product currently at `hraness/hra` becomes HRA v0 without changing its runtime storage, Keychain, bundle, migration, deployment, or data identities. Its archive CI verifies remote releases at `hraness/hra-v0`, so the reversible rename of old GitHub repository ID `1334876494` to `hraness/hra-v0` happens first while the new repository remains `hot-codex`. Read back the same rulesets, tags, releases, and asset identities by numeric ID, then push the archive branch, pass the strict `Required` check, merge through the protected PR path, record the provider-selected main commit Q, and pass `Required` again on Q. Only then rename old Vercel project ID `prj_eRfUBHdHkEbvIaB8x7dyyZhBc3wr` to `hra-v0`, bind its exact prior-publication and Q allowlists, deploy Q, and verify the fallback. Rename old Convex project ID `2680173` to name `HRA v0` and slug `hra-v0` in place. Preserve deployment ID `4677913`, immutable releases, tags, data, and all old runtime identifiers. All of these checks complete before the new repository claims `hraness/hra`. @@ -326,13 +326,13 @@ Rename the new GitHub repository ID `1343008607` from `hot-codex` to `hra` and V The domain procedure is traffic-first and numeric-ID-bound. The exact authenticated `/v4/aliases/hra.sh` tuple `(projectId, deploymentId, deployment.id, deployment.url)` is traffic authority. The public `/.well-known/hra.json` marker independently binds product generation, numeric repository ID, version, and exact source commit; it cannot substitute for deployment identity. The checked operator verifies both source and target deployments, aliases `hra.sh` to the target's bare automatic hostname, proves the alias tuple and marker for at most 60 seconds, and automatically restores the last proven source on every failed or ambiguous traffic change. It calls Vercel's single project-domain move endpoint only after traffic is proven, resolves alias and both domain lists with an explicit ambiguity table, and reverses exact target metadata only after restoring source traffic. Rehearse archive P→Q, forward Q→N, reverse N→Q, and final forward movement. Never use `vercel domains add --force`, detach-then-attach, an unreviewed dashboard move, or a name as provider authority. The checked operator sequence lives in `docs/domain-cutover.md`. -Fresh hosted setup accepts exactly six protected values or locally generated secrets through nonterminal input: `SITE_URL`, `JWT_PRIVATE_KEY`, `JWKS`, `HRA_AUTH_HMAC_SECRET`, `HRA_RESEND_API_KEY`, and `HRA_AUTH_EMAIL_FROM`. Every configure, deploy, and bootstrap mutation performs authenticated Convex management readback before and after the command and requires the exact expected numeric team, project, deployment, deployment type, generated deployment name, and deployment URL. Convex interprets the exclusive temporary `CONVEX_DEPLOYMENT=prod:` file used by `convex deploy` as project context, not as an exact nondefault target. The deploy guard therefore additionally requires the exact deployment to report `isDefault: true` and its project to name it as `prodDeploymentName` immediately before and after the push. After Convex resolves deployment credentials, a mandatory pre-push command compares that resolved canonical cloud URL to the exact expected URL and aborts before `runPush` on mismatch. Configuration and bootstrap explicitly address the generated deployment and retain the broader identity guard. Configuration refuses overwrite and provider ambiguity. On that exact fresh deployment, one-shot hard-quota genesis and an exact empty-ledger readback precede every auth, OTP, invitation, device, or application write. The first invite capability goes directly to a new no-follow mode-0600 file; both file content and its parent directory entry are synced before success. It never enters terminal output, argv, an environment variable, or provider logs. The checked operator sequence lives in `docs/hosted-sync.md`. +Fresh hosted setup accepts exactly six protected values or locally generated secrets through nonterminal input: `SITE_URL`, `JWT_PRIVATE_KEY`, `JWKS`, `HRA_AUTH_HMAC_SECRET`, `HRA_RESEND_API_KEY`, and `HRA_AUTH_EMAIL_FROM`. Every configure, deploy, and bootstrap mutation performs authenticated Convex management readback before and after the command and requires the exact expected numeric team, project, deployment, deployment type, generated deployment name, and deployment URL. Convex interprets the exclusive temporary `CONVEX_DEPLOYMENT=prod:` file used by `convex deploy` as project context, not as an exact nondefault target. The deploy guard therefore additionally requires the exact deployment to report `isDefault: true` and its project to name it as `prodDeploymentName` immediately before and after the push. After Convex resolves deployment credentials, a mandatory pre-push command compares that resolved canonical cloud URL to the exact expected URL and aborts before `runPush` on mismatch. Configuration and bootstrap explicitly address the generated deployment and retain the broader identity guard. Configuration refuses overwrite and provider ambiguity. On that exact fresh deployment, one request-bound mutation atomically creates hard quota authority, open generation-zero admission control, a full-digest bootstrap binding, the first invitation, and its exact service quota charge. The first invite capability goes directly to a new no-follow mode-0600 file; both file content and its parent directory entry are synced before provider mutation. It never enters terminal output, argv, an environment variable, or provider logs. A distinct protected-file bootstrap recovery command retries only the same atomic request, never ordinary invitation recording. Consuming the bound first invite durably records bootstrap acceptance in service control, so terminal receipt retention cannot later disable friend issuance. The checked operator sequence lives in `docs/hosted-sync.md`. GitHub redirects from the old `hraness/hra` repository cease when the new repository reuses that name. Before collision, audit and update every mutable old surface: default-branch docs, site links, repository metadata, package metadata, release-download helpers, security/contact paths, and the old Vercel site. Immutable artifacts and prior release bodies cannot all be rewritten, so the v0 fallback hosts a durable compatibility page mapping legacy tags and assets to `hraness/hra-v0` by exact version and commit. The reversible window ends immediately before the first public new-HRA immutable tag or friend-facing install instruction. During staging, repository and project renames may be reversed after readback. Before crossing the commit point, prove old Convex project ID `2680173` still has deployment ID `4677913`, unchanged deployment URL and data, healthy v0 fallback, exact old tag origins, healthy staged new HRA, and a rehearsed domain-only rollback. -After the public commit point, repository names and immutable tags do not roll back. An incident response moves `hra.sh` and Vercel traffic to the healthy fallback, disables new hosted credentials and invitations, and publishes a fixed forward release under `hraness/hra`. Provider numeric IDs and readbacks, not names alone, remain the migration authority. +After the public commit point, repository names and immutable tags do not roll back. An incident response first freezes new hosted credentials and invitations, then moves `hra.sh` and Vercel traffic to the healthy fallback, and publishes a fixed forward release under `hraness/hra`. If traffic reversal fails, hosted admission remains frozen until the incident is resolved. Provider numeric IDs and readbacks, not names alone, remain the migration authority. ## Phases @@ -380,7 +380,7 @@ After the public commit point, repository names and immutable tags do not roll b - Rename the old GitHub repository by numeric ID while `hraness/hra` is otherwise vacant, then publish the protected archive PR and record its actual green main commit. Rename the old Vercel and Convex projects in place, deploy that exact archive commit, and read back their original numeric identities, immutable releases, preserved deployment, data, and fallback. - Rename the new repository and Vercel project to HRA. Create and deploy fresh Convex state and credentials. Link Git by numeric repository identity, install a strict current-head `Required` rule, and require the release workflow to prove the peeled tag commit equals current `origin/main`. - Configure only the six protected hosted values through exact numeric Convex preflight and postflight. Deploy through exclusive project-context selection, matching default-production readbacks, and the in-process resolved-target assertion. Run one-shot hard-quota genesis, prove the singleton authority is hard and empty, then issue the first identity invite to an exclusive capability file with file and parent-directory durability. Complete live two-account and two-device acceptance before traffic movement. -- Verify the new release behind a noncanonical URL, move `hra.sh` with the checked exact-deployment operator and bounded automatic compensation, atomically move its project-domain ownership by numeric ID, verify the exact alias API tuple plus apex and `www`, then publish an unambiguous immutable beta tag, checksums, SBOM, release notes, install flow, and friend-beta instructions. +- Verify the new release behind a noncanonical URL, move `hra.sh` with the checked exact-deployment operator and bounded automatic compensation, atomically move its project-domain ownership by numeric ID, verify the exact alias API tuple plus apex and `www`, then publish an unambiguous immutable beta tag, checksums, artifact-identity and Ubuntu-runtime SPDX records, release notes, install flow, and friend-beta instructions. - Rehearse full staging rollback before the irreversible public commit point and domain-only incident rollback after it. Do not attach the prerelease domain to HRA. ## Acceptance evidence @@ -433,9 +433,9 @@ The beta requires all of these scenarios: | Phase 1 | Implemented locally | The pinned request matrix and digest are exhaustive; safe events, signed cursors, retention gaps, durable typed interactions, write-adjacent unknown resolution, process-generation fencing, and stale-revision rejection have deterministic coverage. Live disposable-account acceptance remains pending. | | Phase 2 | Implemented and gated locally | `hra` provides the persistent line shell, protected input, one-shot JSON, reconnecting JSONL follow, atomic status/events, interaction commands, and read-only plugin discovery. The closeout revision gives daemon and renderer one strict public interaction DTO, refuses terminal protected input before any JSON-mode prompt or effect, and rejects MCP URL elicitation before storage. Package smoke proves the installed binary. | | Phase 3 | Implemented and gated locally | Staggered polling, bounded historical success and failure ledgers, exact velocity windows, durable sliding daily upload anchors, record-and-byte quota capacity proof, identity-scoped A to B to A custody, automatic registration, graceful disconnect, fenced server-time device presence, and observation-only encrypted interaction state have deterministic coverage. Live hosted multi-device proof remains pending. | -| Phase 4 | Implemented; deployment genesis pending | Account deletion, abandoned cleanup, hard aggregate/resource quota, exact Convex Auth accounting, fair maintenance, status-first revocation, and invitation-gated admission pass hostile deterministic suites. A fresh deployment must run one-shot hard-quota genesis before its first auth write. | -| Phase 5 | Closeout implemented; gate rerun pending | Profiles, sessions, interactions, usage, presence, deletion, desktop switching, encrypted sync, compact recovery, shell live updates, generated public contracts, site generation, and package installation passed the earlier repository-wide gate. Receipt-anchored deadlines, exact terminal outcomes, terminal-tail cloud recovery, drain-aware CLI streaming, secret-safe diagnostics, and gap-safe remote rendering are implemented and focused-gated; the complete release gate must be rerun. Real Codex, two-account, and live hosted two-device acceptance remain pending. | -| Phase 6 | Not started | No source publication, current-head CI, new Convex project, namespace rename, new deployment, domain cutover, tag, or release has occurred. | +| Phase 4 | Implemented locally; hosted bootstrap pending | Account deletion, abandoned cleanup, hard aggregate/resource quota, exact Convex Auth accounting, fair maintenance, status-first revocation, invitation-gated admission, atomic request-bound hosted bootstrap, exact lost-response recovery, and durable post-acceptance friend gating pass hostile deterministic suites. The fresh default deployment remains pristine and must receive exact merged source before its first bootstrap write. | +| Phase 5 | Closeout and executable acceptance harness adversarially gated; live run pending | Profiles, sessions, interactions, usage, presence, deletion, desktop switching, encrypted sync, compact recovery, shell live updates, generated public contracts, site generation, and package installation passed the earlier repository-wide gate. The independently reviewed two-installation harness drives the production parser and protected-file-descriptor path, supervises real daemon generations, proves continuous exact-turn authority and HITL response settlement, requires an explicit candidate URL, and emits bounded non-linkable scenario evidence. The complete local gate, real Codex two-account run, and live hosted two-device run remain pending. | +| Phase 6 | Provider namespace staging in progress | Old GitHub repository ID `1334876494`, Vercel project ID `prj_eRfUBHdHkEbvIaB8x7dyyZhBc3wr`, and Convex project ID `2680173` now read back as HRA v0 while preserving their numeric identities. New GitHub repository ID `1343008607` and Vercel project ID `prj_8ciIt9t9foE3utG45frRN7cxckjS` now read back as HRA. Fresh Convex deployment `qualified-hummingbird-537` is the default for project ID `2854545` and was proved to contain no functions, tables, storage, or environment values before promotion. Final source PR and CI, exact source deployment, protected configuration, live acceptance, domain rehearsal and cutover, immutable tag, and release remain pending. | ## Execution evidence @@ -470,6 +470,11 @@ The beta requires all of these scenarios: - 2026-08-23: Clean shutdown sends a best-effort fenced presence disconnect before aborting the cloud transport lifetime. Encrypted compact projections now include observation-only interaction state through terminal revision. Human `remote show` output identifies the execution-device resolution boundary, while the closed JSON event exposes only public ID, kind, state, revision, blocking status, and a bounded safe summary. Public privacy surfaces name both the encrypted metadata and the private values that never upload. - 2026-08-23: The pre-closeout release candidate passed the complete local release gate: ESLint, TypeScript, 650 tests with 12,980 assertions across 65 files, canonical site parity, the production CLI and site builds, public-tree and complete-history sensitive-text scanning, and isolated local and global installation of `hra-0.1.0.tgz`. - 2026-08-23: The final adversarial closeout added absolute receipt-time interaction deadlines, a bounded timeout pump, exact prepared terminal intent, neutral timeout errors, uncertain-write provider quarantine, closed provider diagnostics, argv/runtime diagnostic scrubbing, drain-aware JSONL, abortable local transport, signed-in bounded usage refresh-all, gap-safe remote interaction rendering, terminal-tail cloud append authority, and crash-journaled interaction baselines for compact recovery. Focused domain, storage, Codex, daemon, CLI, cloud, and Convex suites pass; the complete release gate remains pending in this plan entry. +- 2026-08-23: Hosted bootstrap now binds one locally generated 256-bit capability digest to quota, service control, and the first invitation in one Convex transaction. Exact replay is neutral, different concurrent requests admit one winner, raw operator readback recomputes the invitation's Convex byte charge, and a dedicated protected-file recovery path reconciles crashes without calling ordinary invitation issuance. First-invite consumption records durable bootstrap acceptance, and a regression proves later friend issuance still works after terminal receipt cleanup. +- 2026-08-23: The live acceptance gate owns two complete isolated installations without changing `HOME`, enters through the production CLI parser and protected descriptor transport, supervises full daemon stop/restart generations, and drives the two-account and two-device scenario. Cleanup now reconciles lost registration and logout responses, requires the exact complete peer set including revoked history, serializes cancellation with final deletion, and preserves recovery evidence on uncertainty. Continuous local cursor pages, exact remote terminal projection settlement, exact HITL discovery and response events, and JSONL descriptor cancellation have focused regressions. Evidence compares provider identities only in memory and exports one boolean rather than email-derived commitments. An independent frozen-snapshot review found no P0, P1, or P2 issues; 155 broader owned tests, 29 focused harness and package-policy tests with 204 assertions, ESLint, TypeScript, isolated local and global package acceptance, and diff checks pass. Real provider execution remains pending. +- 2026-08-23: Provider namespace staging preserved old HRA numeric identities under HRA v0, established new HRA GitHub and Vercel names, created fresh Convex deployment `qualified-hummingbird-537`, proved it empty by provider readback, and promoted it to the exact default without deploying code or creating data. Final merged-source deployment and every public commit-point action remain pending. +- 2026-08-23: Release distribution now excludes repository-only harness, operator, Convex, site, and test sources from the install tarball; isolated local and global consumers pass help, version, offline doctor, and production-tree checks. The tag workflow builds and accepts the exact tarball, creates separate tarball-identity and Ubuntu 24.04 x64 runtime SPDX records, stages one exact draft, and compares every draft asset with the accepted bytes. It has no publication step. A separate local operator binds the exact repository, workflow run, attempt, tag, commit, notes, checksums, SPDX contracts, draft metadata, artifact bytes, canonical marker, and admin-keyring immutable-release setting before the one-way undraft. The slow marker proof completes before a fresh draft byte comparison, followed by final tag, main, and immutable-setting reads. Publication PATCHes only the verified numeric release ID, and lost-response recovery requires that same ID. The operator then requires exact immutable readback, an anonymous byte-verified download, and isolated installation from the public release URL; ambiguous responses and post-publication failures have distinct recovery states. Independent review found no P0, P1, or P2 release findings; 66 focused release, package, site, and domain tests, ESLint, TypeScript, generated-site parity, isolated package acceptance, and diff checks pass. GitHub provider readback reports immutable releases enabled for repository ID `1343008607`; the actual tag and release remain pending. +- 2026-08-23: Two shared-global full-suite attempts reached the same high-volume SQLite boundary with no failed assertion before the OS killed the Bun process. The exact 55-test storage file passes alone. The public root test script now gives every file a fresh Bun global object and bounds test concurrency to one; the coordinated exact suite passes 859 tests across 80 files. This changes test-process lifetime, not selection or product behavior. The complete exclusive root gate then passes ESLint, TypeScript, all tests, generated-site parity, the CLI and site builds, public-tree checks, and isolated local and global package acceptance. ## Review findings diff --git a/package.json b/package.json index 3e78a58..e259051 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "src", "!src/**/*.test.ts", "!src/**/AGENTS.md", + "!src/cloud/inviteAuthority.ts", "!src/cloud/testAssertions.ts", "README.md", "LICENSE", @@ -41,15 +42,19 @@ "build": "bun run build:cli && bun run build:site", "build:cli": "bun build ./src/cli.ts --target=bun --outdir=dist/cli", "build:site": "bun ./scripts/build-site.ts", - "check": "bun run lint && bun run typecheck && bun test && bun run build:site -- --check && bun run build && bun run check:package", + "check": "bun run lint && bun run typecheck && bun run test && bun run build:site -- --check && bun run build && bun run check:package", "check:package": "bun ./scripts/check-package.ts", + "acceptance:live": "bun ./scripts/live-acceptance.ts", "hosted:bootstrap": "bun ./scripts/bootstrap-hosted-sync.ts", + "hosted:admission": "bun ./scripts/manage-hosted-admission.ts", "hosted:configure": "bun ./scripts/configure-hosted-sync.ts", "hosted:deploy": "bun ./scripts/deploy-hosted-sync.ts", "hosted:domain-cutover": "bun ./scripts/domain-cutover.ts", + "hosted:invites": "bun ./scripts/manage-hosted-invites.ts", + "release:publish": "bun ./scripts/publish-beta-release.ts", "lint": "eslint .", "start": "bun ./src/cli.ts", - "test": "bun test", + "test": "bun test --isolate --max-concurrency=1", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/scripts/bootstrap-hosted-sync.test.ts b/scripts/bootstrap-hosted-sync.test.ts index 3cd6e6f..1bf1dc4 100644 --- a/scripts/bootstrap-hosted-sync.test.ts +++ b/scripts/bootstrap-hosted-sync.test.ts @@ -1,17 +1,36 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { + chmod, + mkdtemp, + readFile, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { getDocumentSize } from "convex/values"; + +import { + digestInviteCapability, + identityInviteLifetimeMs, + invitePublicIdFromCapabilityDigest, +} from "../src/cloud/inviteAuthority"; + import { bootstrapHostedSync, executeHostedBootstrap, parseBootstrapArguments, + readProtectedInviteCapability, + recoverHostedBootstrap, reserveCapabilityFile, type CapabilitySink, } from "./bootstrap-hosted-sync"; import type { CommandRequest, CommandRunner } from "./configure-hosted-sync"; import { + HRA_CONVEX_PROJECT_ID, HRA_CONVEX_TEAM_ID, type ConvexTarget, type ConvexTargetVerifier, @@ -21,7 +40,7 @@ const target: ConvexTarget = { deploymentId: 7_654_321, deploymentName: "steady-otter-321", deploymentUrl: "https://steady-otter-321.convex.cloud", - projectId: 1_234_567, + projectId: HRA_CONVEX_PROJECT_ID, teamId: HRA_CONVEX_TEAM_ID, }; @@ -43,28 +62,81 @@ const exactTargetVerifier: ConvexTargetVerifier = async (value) => { }; const capability = `hra_invite_identity_v1_${"S".repeat(43)}`; -const inviteResult = { - capability, - expiresAt: 1_800_000_000_000, - publicId: `invite_${"P".repeat(32)}`, +const capabilityDigest = await digestInviteCapability(capability, "identity"); +const publicId = invitePublicIdFromCapabilityDigest(capabilityDigest); +const authority = { capability, capabilityDigest, publicId } as const; +const bootstrapAt = 1_799_913_600_000; +const expiresAt = bootstrapAt + identityInviteLifetimeMs; +const bootstrapInvite = { + _creationTime: bootstrapAt, + _id: "bootstrap_invite_row_1", + admissionExpiresAt: expiresAt, + capabilityDigest, + createdAt: bootstrapAt, + expiresAt, + publicId, purpose: "identity", - replay: false, + requestedLifetimeMs: identityInviteLifetimeMs, state: "issued", + updatedAt: bootstrapAt, } as const; -const zeroAuthority = { - _creationTime: 1_799_999_999_000, +const inviteLogicalBytes = getDocumentSize(bootstrapInvite); +const hostedAuthority = { + _creationTime: bootstrapAt, _id: "authority_row_1", enforcement: "hard", identities: 0, key: "global", - logicalBytes: 0, - records: 0, - serviceLogicalBytes: 0, - serviceRecords: 0, - updatedAt: 1_799_999_999_000, + logicalBytes: inviteLogicalBytes, + records: 1, + serviceLogicalBytes: inviteLogicalBytes, + serviceRecords: 1, + updatedAt: bootstrapAt, userLogicalBytes: 0, userRecords: 0, } as const; +const hostedControl = { + _creationTime: bootstrapAt, + _id: "control_row_1", + authAdmissionGeneration: 0, + authAdmissions: "open", + bootstrapCompletedAt: bootstrapAt, + bootstrapInviteCapabilityDigest: capabilityDigest, + bootstrapInviteLifetimeMs: identityInviteLifetimeMs, + bootstrapInvitePublicId: publicId, + key: "global", + updatedAt: bootstrapAt, +} as const; +const emptyAuthority = { + control: [], + invites: [], + maintenance: [], + quota: [], +} as const; +const authorityReadback = { + control: [hostedControl], + invites: [bootstrapInvite], + quota: [hostedAuthority], +} as const; +const genesisResult = { + enforcement: "hard", + invite: { + expiresAt, + publicId, + purpose: "identity", + state: "issued", + }, + replay: false, +} as const; +const publicInviteResult = { + expiresAt, + publicId, + purpose: "identity", + replay: false, + state: "issued", +} as const; +const authorityQuery = "return {quota:await ctx.db.query(\"storageUsageService\").take(2),control:await ctx.db.query(\"serviceControl\").take(2),invites:await ctx.db.query(\"authInvites\").take(2)};"; +const preGenesisQuery = "return {quota:await ctx.db.query(\"storageUsageService\").take(2),control:await ctx.db.query(\"serviceControl\").take(2),maintenance:await ctx.db.query(\"maintenanceState\").take(2),invites:await ctx.db.query(\"authInvites\").take(2)};"; const temporaryDirectories: string[] = []; @@ -110,71 +182,90 @@ const makeFakeSink = (): FakeSink => { }; }; -describe("fresh hosted bootstrap", () => { - test("runs genesis, proves the exact zero singleton, and protects the first invite without observable secrets", async () => { - const requests: CommandRequest[] = []; - const results = [ - { exitCode: 0, stderr: capability, stdout: "[]\n" }, - { exitCode: 0, stderr: capability, stdout: "{\n \"enforcement\": \"hard\"\n}\n" }, - { exitCode: 0, stderr: capability, stdout: `${JSON.stringify([zeroAuthority])}\n` }, - { exitCode: 0, stderr: capability, stdout: `${JSON.stringify(inviteResult)}\n` }, - ] as const; - let resultIndex = 0; - const runner: CommandRunner = async (request) => { +const sequenceRunner = ( + results: readonly Readonly<{ exitCode: number; stderr: string; stdout: string }>[], + requests: CommandRequest[] = [], +): Readonly<{ requests: CommandRequest[]; runner: CommandRunner }> => { + let index = 0; + return { + requests, + runner: async (request) => { requests.push(request); - return results[resultIndex++]!; - }; + const result = results[index]; + index += 1; + if (result === undefined) throw new Error("unexpected command"); + return result; + }, + }; +}; + +describe("fresh hosted bootstrap", () => { + test("atomically creates exact authority and first invite without exposing capability custody", async () => { + const { requests, runner } = sequenceRunner([ + { exitCode: 0, stderr: capability, stdout: `${JSON.stringify(emptyAuthority)}\n` }, + { exitCode: 0, stderr: capability, stdout: `${JSON.stringify(genesisResult)}\n` }, + { exitCode: 0, stderr: capability, stdout: `${JSON.stringify(authorityReadback)}\n` }, + ]); const fakeSink = makeFakeSink(); const stdout: string[] = []; const stderr: string[] = []; + let verifications = 0; const exitCode = await executeHostedBootstrap({ - arguments: [ - ...targetArguments, - "--invite-output", - "/protected/new-invite", - ], + arguments: [...targetArguments, "--invite-output", "/protected/new-invite"], + authorityFactory: async () => authority, environment: { CONVEX_DEPLOY_KEY: capability, HOME: "/safe/operator", PATH: "/safe/bin", + TMPDIR: `/safe/${capability}`, }, reserve: async () => fakeSink.sink, runner, stderr: outputWriter(stderr), stdout: outputWriter(stdout), - verifyTarget: exactTargetVerifier, + verifyTarget: async (value) => { + await exactTargetVerifier(value); + verifications += 1; + }, }); expect(exitCode).toBe(0); expect(fakeSink.commits()).toEqual([capability]); expect(fakeSink.aborts()).toBe(0); - expect(requests).toHaveLength(4); + expect(verifications).toBe(3); expect(requests.map((request) => request.arguments.slice(1))).toEqual([ [ "run", "--inline-query", - "return await ctx.db.query(\"storageUsageService\").take(2);", + preGenesisQuery, "--deployment", target.deploymentName, ], - ["run", "quota:genesisHardAuthority", "{}", "--deployment", target.deploymentName], [ "run", - "--inline-query", - "return await ctx.db.query(\"storageUsageService\").take(2);", + "quota:genesisHostedAuthority", + JSON.stringify({ + capabilityDigest, + lifetimeMs: identityInviteLifetimeMs, + publicId, + }), "--deployment", target.deploymentName, ], [ "run", - "authInvites:issue", - "{\"lifetimeMs\":86400000,\"purpose\":\"identity\"}", + "--inline-query", + authorityQuery, "--deployment", target.deploymentName, ], ]); expect(requests.every((request) => request.stdin === "")).toBe(true); + expect(requests.every((request) => request.outputMaximumBytes === 64 * 1_024)) + .toBe(true); + expect(requests.every((request) => request.timeoutMs === 60_000)).toBe(true); + expect(requests.every((request) => request.environment.TMPDIR === undefined)).toBe(true); const observable = JSON.stringify({ arguments: requests.map((request) => request.arguments), environments: requests.map((request) => request.environment), @@ -182,19 +273,20 @@ describe("fresh hosted bootstrap", () => { stdout, }); expect(observable).not.toContain(capability); - expect(stdout).toEqual([ - "Hosted bootstrap verified hard zero authority and protected the first identity invite.\n", - ]); + expect(JSON.parse(stdout.join(""))).toEqual({ + invite: publicInviteResult, + operation: "bootstrap", + }); expect(stderr).toEqual([]); }); - test("refuses a dirty pre-genesis authority before reserving output or mutating", async () => { - const requests: CommandRequest[] = []; + test("refuses dirty authority before reserving output or mutating", async () => { + const { requests, runner } = sequenceRunner([{ + exitCode: 0, + stderr: capability, + stdout: JSON.stringify({ ...emptyAuthority, quota: [hostedAuthority] }), + }]); let reserved = false; - const runner: CommandRunner = async (request) => { - requests.push(request); - return { exitCode: 0, stderr: capability, stdout: JSON.stringify([zeroAuthority]) }; - }; await expect(bootstrapHostedSync({ inviteOutput: "/protected/new-invite", @@ -210,117 +302,272 @@ describe("fresh hosted bootstrap", () => { expect(reserved).toBe(false); }); - test("refuses an existing or symlink output before genesis", async () => { - const directory = await makeTemporaryDirectory(); - const existing = join(directory, "existing-invite"); - const linkTarget = join(directory, "target"); - const link = join(directory, "linked-invite"); - await writeFile(existing, "occupied", { mode: 0o600 }); - await writeFile(linkTarget, "target", { mode: 0o600 }); - await symlink(linkTarget, link); - - await expect(reserveCapabilityFile(existing)).rejects.toThrow("invite_output_refused"); - await expect(reserveCapabilityFile(link)).rejects.toThrow("invite_output_refused"); - - const requests: CommandRequest[] = []; - const runner: CommandRunner = async (request) => { - requests.push(request); - return { exitCode: 0, stderr: "", stdout: "[]" }; - }; - await expect(bootstrapHostedSync({ - inviteOutput: existing, - runner, - target, - verifyTarget: exactTargetVerifier, - })).rejects.toThrow("invite_output_refused"); - expect(requests).toHaveLength(1); - }); - - test("writes only the capability to a new exclusive regular 0600 file", async () => { - const directory = await makeTemporaryDirectory(); - const output = join(directory, "identity-invite"); - const sink = await reserveCapabilityFile(output); - await sink.commit(capability); - - expect(await readFile(output, "utf8")).toBe(`${capability}\n`); - const metadata = await stat(output); - expect(metadata.isFile()).toBe(true); - expect(metadata.nlink).toBe(1); - expect(metadata.mode & 0o777).toBe(0o600); - await expect(reserveCapabilityFile(output)).rejects.toThrow("invite_output_refused"); + test("reconciles a lost or malformed mutation response only through exact readback", async () => { + for (const mutation of [ + { exitCode: 1, stderr: "transport lost", stdout: "" }, + { exitCode: 0, stderr: "", stdout: "{malformed" }, + ]) { + const { runner } = sequenceRunner([ + { exitCode: 0, stderr: "", stdout: JSON.stringify(emptyAuthority) }, + mutation, + { exitCode: 0, stderr: "", stdout: JSON.stringify(authorityReadback) }, + ]); + const fakeSink = makeFakeSink(); + const result = await bootstrapHostedSync({ + authorityFactory: async () => authority, + inviteOutput: "/protected/new-invite", + reserve: async () => fakeSink.sink, + runner, + target, + verifyTarget: exactTargetVerifier, + }); + expect(result).toEqual({ + invite: { ...publicInviteResult, replay: true }, + operation: "bootstrap", + }); + expect(fakeSink.commits()).toEqual([capability]); + expect(fakeSink.aborts()).toBe(0); + } }); - test("closes on ambiguous genesis and authority results and removes the reserved output", async () => { + test("keeps committed custody and refuses empty, conflicting, or undercharged readback", async () => { const scenarios = [ { - expected: "authority_readback_invalid", - results: [{ exitCode: 0, stderr: capability, stdout: "[]\n{}" }], + expected: "genesis_failed", + mutation: { exitCode: 1, stderr: "lost", stdout: "" }, + readback: { control: [], invites: [], quota: [] }, }, { expected: "genesis_result_invalid", - results: [ - { exitCode: 0, stderr: "", stdout: "[]" }, - { exitCode: 0, stderr: capability, stdout: "{\"enforcement\":\"shadow\"}" }, - ], + mutation: { exitCode: 0, stderr: "", stdout: "{malformed" }, + readback: { control: [], invites: [], quota: [] }, }, { - expected: "authority_readback_invalid", - results: [ - { exitCode: 0, stderr: "", stdout: "[]" }, - { exitCode: 0, stderr: "", stdout: "{\"enforcement\":\"hard\"}" }, - { exitCode: 0, stderr: capability, stdout: JSON.stringify([zeroAuthority, zeroAuthority]) }, - ], + expected: "bootstrap_authority_conflict", + mutation: { exitCode: 1, stderr: "refused", stdout: "" }, + readback: { + ...authorityReadback, + quota: [{ + ...hostedAuthority, + logicalBytes: inviteLogicalBytes - 1, + serviceLogicalBytes: inviteLogicalBytes - 1, + }], + }, }, { - expected: "authority_readback_invalid", - results: [ - { exitCode: 0, stderr: "", stdout: "[]" }, - { exitCode: 0, stderr: "", stdout: "{\"enforcement\":\"hard\"}" }, - { - exitCode: 0, - stderr: capability, - stdout: JSON.stringify([{ ...zeroAuthority, userRecords: 1 }]), - }, - ], - }, - { - expected: "invite_result_invalid", - results: [ - { exitCode: 0, stderr: "", stdout: "[]" }, - { exitCode: 0, stderr: "", stdout: "{\"enforcement\":\"hard\"}" }, - { exitCode: 0, stderr: "", stdout: JSON.stringify([zeroAuthority]) }, - { exitCode: 0, stderr: capability, stdout: `${JSON.stringify(inviteResult)}\n{}` }, - ], + expected: "bootstrap_authority_conflict", + mutation: { exitCode: 1, stderr: "refused", stdout: "" }, + readback: { + ...authorityReadback, + control: [{ ...hostedControl, bootstrapInviteCapabilityDigest: "f".repeat(64) }], + }, }, ] as const; for (const scenario of scenarios) { - let index = 0; + const { runner } = sequenceRunner([ + { exitCode: 0, stderr: "", stdout: JSON.stringify(emptyAuthority) }, + scenario.mutation, + { exitCode: 0, stderr: "", stdout: JSON.stringify(scenario.readback) }, + ]); const fakeSink = makeFakeSink(); - const runner: CommandRunner = async () => scenario.results[index++]!; await expect(bootstrapHostedSync({ + authorityFactory: async () => authority, inviteOutput: "/protected/new-invite", reserve: async () => fakeSink.sink, runner, target, verifyTarget: exactTargetVerifier, })).rejects.toThrow(scenario.expected); - if (scenario.expected === "authority_readback_invalid" && scenario.results.length === 1) { - expect(fakeSink.aborts()).toBe(0); - } else { - expect(fakeSink.aborts()).toBe(1); - } - expect(fakeSink.commits()).toEqual([]); + expect(fakeSink.commits()).toEqual([capability]); + expect(fakeSink.aborts()).toBe(0); + } + }); +}); + +describe("bootstrap recovery", () => { + test("replays only the exact atomic bootstrap authority from the protected file", async () => { + const { requests, runner } = sequenceRunner([ + { + exitCode: 0, + stderr: "", + stdout: JSON.stringify({ ...genesisResult, replay: true }), + }, + { exitCode: 0, stderr: "", stdout: JSON.stringify(authorityReadback) }, + ]); + const stdout: string[] = []; + const stderr: string[] = []; + + const exitCode = await executeHostedBootstrap({ + arguments: [ + "recover", + ...targetArguments, + "--invite-file", + "/protected/bootstrap-invite", + ], + readCapability: async (value) => { + expect(value).toBe("/protected/bootstrap-invite"); + return capability; + }, + runner, + stderr: outputWriter(stderr), + stdout: outputWriter(stdout), + verifyTarget: exactTargetVerifier, + }); + + expect(exitCode).toBe(0); + expect(requests).toHaveLength(2); + expect(requests[0]?.arguments).toContain("quota:genesisHostedAuthority"); + expect(requests[0]?.arguments).not.toContain("authInvites:recordIssue"); + expect(JSON.parse(stdout.join(""))).toEqual({ + invite: { ...publicInviteResult, replay: true }, + operation: "recover", + }); + expect(JSON.stringify({ requests, stderr, stdout })).not.toContain(capability); + }); + + test("recovers a crash before mutation and reconciles a crash after mutation", async () => { + for (const mutation of [ + { exitCode: 0, stderr: "", stdout: JSON.stringify(genesisResult) }, + { exitCode: 1, stderr: "transport lost", stdout: "" }, + ]) { + const { runner } = sequenceRunner([ + mutation, + { exitCode: 0, stderr: "", stdout: JSON.stringify(authorityReadback) }, + ]); + const result = await recoverHostedBootstrap({ + inviteFile: "/protected/bootstrap-invite", + readCapability: async () => capability, + runner, + target, + verifyTarget: exactTargetVerifier, + }); + expect(result.operation).toBe("recover"); + expect(result.invite.replay).toBe(mutation.exitCode !== 0); } }); - test("requires one explicit deployment and one absolute new output path", () => { + test("refuses a concurrent winner with a different full bootstrap binding", async () => { + const otherDigest = "f".repeat(64); + const { runner } = sequenceRunner([ + { exitCode: 1, stderr: "refused", stdout: "" }, + { + exitCode: 0, + stderr: "", + stdout: JSON.stringify({ + ...authorityReadback, + control: [{ + ...hostedControl, + bootstrapInviteCapabilityDigest: otherDigest, + bootstrapInvitePublicId: invitePublicIdFromCapabilityDigest(otherDigest), + }], + }), + }, + ]); + await expect(recoverHostedBootstrap({ + inviteFile: "/protected/loser-invite", + readCapability: async () => capability, + runner, + target, + verifyTarget: exactTargetVerifier, + })).rejects.toThrow("bootstrap_authority_conflict"); + }); +}); + +describe("bootstrap capability custody", () => { + test("writes and rereads only one capability in an owned private directory", async () => { + const directory = await makeTemporaryDirectory(); + const output = join(directory, "identity-invite"); + const sink = await reserveCapabilityFile(output); + await sink.commit(capability); + + expect(await readFile(output, "utf8")).toBe(`${capability}\n`); + expect(await readProtectedInviteCapability(output)).toBe(capability); + const metadata = await stat(output); + expect(metadata.isFile()).toBe(true); + expect(metadata.nlink).toBe(1); + expect(metadata.mode & 0o777).toBe(0o600); + await expect(reserveCapabilityFile(output)).rejects.toThrow("invite_output_refused"); + }); + + test("refuses existing, symlinked, shared-parent, or weakened recovery paths", async () => { + const directory = await makeTemporaryDirectory(); + const existing = join(directory, "existing-invite"); + const linkTarget = join(directory, "target"); + const link = join(directory, "linked-invite"); + await writeFile(existing, `${capability}\n`, { mode: 0o600 }); + await writeFile(linkTarget, `${capability}\n`, { mode: 0o600 }); + await symlink(linkTarget, link); + + await expect(reserveCapabilityFile(existing)).rejects.toThrow("invite_output_refused"); + await expect(reserveCapabilityFile(link)).rejects.toThrow("invite_output_refused"); + await expect(readProtectedInviteCapability(link)).rejects.toThrow("invite_input_refused"); + + await chmod(existing, 0o644); + await expect(readProtectedInviteCapability(existing)) + .rejects.toThrow("invite_input_refused"); + await chmod(existing, 0o600); + await chmod(directory, 0o755); + await expect(readProtectedInviteCapability(existing)) + .rejects.toThrow("invite_input_refused"); + await expect(reserveCapabilityFile(join(directory, "new-invite"))) + .rejects.toThrow("invite_output_refused"); + }); + + test("aborts an uncommitted reservation but preserves committed recovery custody", async () => { + const uncommitted = makeFakeSink(); + const { runner: dirtyRunner } = sequenceRunner([{ + exitCode: 0, + stderr: "", + stdout: JSON.stringify(emptyAuthority), + }]); + await expect(bootstrapHostedSync({ + authorityFactory: async () => ({ ...authority, publicId: "invite_invalid" }), + inviteOutput: "/protected/new-invite", + reserve: async () => uncommitted.sink, + runner: dirtyRunner, + target, + verifyTarget: exactTargetVerifier, + })).rejects.toThrow("invite_result_invalid"); + expect(uncommitted.aborts()).toBe(1); + expect(uncommitted.commits()).toEqual([]); + + const committed = makeFakeSink(); + const { runner: failedRunner } = sequenceRunner([ + { exitCode: 0, stderr: "", stdout: JSON.stringify(emptyAuthority) }, + { exitCode: 1, stderr: "lost", stdout: "" }, + { exitCode: 0, stderr: "", stdout: JSON.stringify({ control: [], invites: [], quota: [] }) }, + ]); + await expect(bootstrapHostedSync({ + authorityFactory: async () => authority, + inviteOutput: "/protected/new-invite", + reserve: async () => committed.sink, + runner: failedRunner, + target, + verifyTarget: exactTargetVerifier, + })).rejects.toThrow("genesis_failed"); + expect(committed.aborts()).toBe(0); + expect(committed.commits()).toEqual([capability]); + }); +}); + +describe("bootstrap command grammar", () => { + test("requires an exact target and one absolute initialize or recovery path", () => { expect(parseBootstrapArguments([ ...targetArguments, "--invite-output", "/protected/new-invite", ])).toEqual({ - inviteOutput: "/protected/new-invite", + operation: { inviteOutput: "/protected/new-invite", kind: "initialize" }, + target, + }); + expect(parseBootstrapArguments([ + "recover", + ...targetArguments, + "--invite-file", + "/protected/bootstrap-invite", + ])).toEqual({ + operation: { inviteFile: "/protected/bootstrap-invite", kind: "recover" }, target, }); expect(() => parseBootstrapArguments([ @@ -328,6 +575,12 @@ describe("fresh hosted bootstrap", () => { "--invite-output", "relative-invite", ])).toThrow("usage_invalid"); + expect(() => parseBootstrapArguments([ + ...targetArguments, + "recover", + "--invite-file", + `/protected/${capability}`, + ])).toThrow("usage_invalid"); expect(() => parseBootstrapArguments([ ...targetArguments.slice(0, 3), String(HRA_CONVEX_TEAM_ID + 1), diff --git a/scripts/bootstrap-hosted-sync.ts b/scripts/bootstrap-hosted-sync.ts index 1daf308..7483425 100644 --- a/scripts/bootstrap-hosted-sync.ts +++ b/scripts/bootstrap-hosted-sync.ts @@ -9,6 +9,14 @@ import { import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { z } from "zod"; +import { getDocumentSize } from "convex/values"; + +import { + digestInviteCapability, + generateInviteAuthority, + identityInviteLifetimeMs, + invitePublicIdFromCapabilityDigest, +} from "../src/cloud/inviteAuthority"; import { buildConvexChildEnvironment, @@ -21,17 +29,27 @@ import { ConvexTargetError, parseConvexTarget, parseConvexTargetArguments, - verifyConvexTarget, + verifyConvexDefaultTarget, type ConvexTarget, type ConvexTargetVerifier, } from "./convex-target"; const convexOutputMaximumBytes = 64 * 1024; -const identityInviteLifetimeMs = 24 * 60 * 60 * 1_000; -const authorityQuery = "return await ctx.db.query(\"storageUsageService\").take(2);"; +const convexTimeoutMs = 60_000; +const inviteCapabilityPattern = + /hra_invite_(?:device|identity)_v1_[A-Za-z0-9_-]{43}/u; +const preGenesisQuery = "return {quota:await ctx.db.query(\"storageUsageService\").take(2),control:await ctx.db.query(\"serviceControl\").take(2),maintenance:await ctx.db.query(\"maintenanceState\").take(2),invites:await ctx.db.query(\"authInvites\").take(2)};"; +const authorityQuery = "return {quota:await ctx.db.query(\"storageUsageService\").take(2),control:await ctx.db.query(\"serviceControl\").take(2),invites:await ctx.db.query(\"authInvites\").take(2)};"; const genesisResultSchema = z.object({ enforcement: z.literal("hard"), + invite: z.object({ + expiresAt: z.number().finite().positive(), + publicId: z.string().regex(/^invite_[A-Za-z0-9_-]{32}$/u), + purpose: z.literal("identity"), + state: z.literal("issued"), + }).strict(), + replay: z.boolean(), }).strict(); const authorityRowSchema = z.object({ @@ -40,33 +58,81 @@ const authorityRowSchema = z.object({ enforcement: z.literal("hard"), identities: z.literal(0), key: z.literal("global"), - logicalBytes: z.literal(0), - records: z.literal(0), - serviceLogicalBytes: z.literal(0), - serviceRecords: z.literal(0), + logicalBytes: z.number().int().positive().safe(), + records: z.literal(1), + serviceLogicalBytes: z.number().int().positive().safe(), + serviceRecords: z.literal(1), updatedAt: z.number().finite().nonnegative(), userLogicalBytes: z.literal(0), userRecords: z.literal(0), }).strict(); +const controlRowSchema = z.object({ + _creationTime: z.number().finite().nonnegative(), + _id: z.string().min(1).max(256), + authAdmissionGeneration: z.literal(0), + authAdmissions: z.literal("open"), + bootstrapCompletedAt: z.number().finite().nonnegative(), + bootstrapInviteCapabilityDigest: z.string().regex(/^[a-f0-9]{64}$/u), + bootstrapInviteLifetimeMs: z.literal(identityInviteLifetimeMs), + bootstrapInvitePublicId: z.string().regex(/^invite_[A-Za-z0-9_-]{32}$/u), + key: z.literal("global"), + updatedAt: z.number().finite().nonnegative(), +}).strict(); + +const bootstrapInviteRowSchema = z.object({ + _creationTime: z.number().finite().nonnegative(), + _id: z.string().min(1).max(256), + admissionExpiresAt: z.number().finite().positive(), + capabilityDigest: z.string().regex(/^[a-f0-9]{64}$/u), + createdAt: z.number().finite().nonnegative(), + expiresAt: z.number().finite().positive(), + publicId: z.string().regex(/^invite_[A-Za-z0-9_-]{32}$/u), + purpose: z.literal("identity"), + requestedLifetimeMs: z.literal(identityInviteLifetimeMs), + state: z.literal("issued"), + updatedAt: z.number().finite().nonnegative(), +}).strict(); + +const emptyAuthoritySchema = z.object({ + control: z.array(z.unknown()).max(2), + invites: z.array(z.unknown()).max(2), + maintenance: z.array(z.unknown()).max(2), + quota: z.array(z.unknown()).max(2), +}).strict(); + +const hostedAuthoritySchema = z.object({ + control: z.tuple([controlRowSchema]), + invites: z.tuple([bootstrapInviteRowSchema]), + quota: z.tuple([authorityRowSchema]), +}).strict(); + const identityInviteCapabilitySchema = z.string() .regex(/^hra_invite_identity_v1_[A-Za-z0-9_-]{43}$/u); const inviteResultSchema = z.object({ - capability: identityInviteCapabilitySchema, expiresAt: z.number().finite().positive(), publicId: z.string().regex(/^invite_[A-Za-z0-9_-]{32}$/u), purpose: z.literal("identity"), - replay: z.literal(false), + replay: z.boolean(), state: z.literal("issued"), }).strict(); +const localAuthoritySchema = z.object({ + capability: identityInviteCapabilitySchema, + capabilityDigest: z.string().regex(/^[a-f0-9]{64}$/u), + publicId: z.string().regex(/^invite_[A-Za-z0-9_-]{32}$/u), +}).strict(); + +type LocalAuthority = z.infer; + type BootstrapFailureCode = | "authority_dirty" + | "bootstrap_authority_conflict" | "authority_readback_invalid" | "genesis_failed" | "genesis_result_invalid" - | "invite_issue_failed" + | "invite_input_refused" | "invite_output_refused" | "invite_result_invalid" | "convex_target_refused" @@ -82,11 +148,27 @@ class BootstrapError extends Error { } } +type BootstrapOperation = + | Readonly<{ inviteOutput: string; kind: "initialize" }> + | Readonly<{ inviteFile: string; kind: "recover" }>; + type BootstrapArguments = Readonly<{ - inviteOutput: string; + operation: BootstrapOperation; target: ConvexTarget; }>; +const parseProtectedAbsolutePath = (value: string | undefined): string => { + if ( + value === undefined + || value.length === 0 + || value.length > 4_096 + || !isAbsolute(value) + || resolve(value) !== value + || inviteCapabilityPattern.test(value) + ) throw new BootstrapError("usage_invalid"); + return value; +}; + export function parseBootstrapArguments(arguments_: readonly string[]): BootstrapArguments { let parsedTarget: ReturnType; try { @@ -94,28 +176,31 @@ export function parseBootstrapArguments(arguments_: readonly string[]): Bootstra } catch { throw new BootstrapError("usage_invalid"); } - let inviteOutput: string | undefined; - for (let index = 0; index < parsedTarget.otherArguments.length; index += 1) { - const argument = parsedTarget.otherArguments[index]; - if (argument === "--invite-output" && inviteOutput === undefined) { - const value = parsedTarget.otherArguments[index + 1]; - if ( - value === undefined - || value.length === 0 - || value.length > 4_096 - || !isAbsolute(value) - || resolve(value) !== value - ) throw new BootstrapError("usage_invalid"); - inviteOutput = value; - index += 1; - continue; - } - throw new BootstrapError("usage_invalid"); + const [commandOrFlag, flagOrValue, maybeValue, ...remaining] = + parsedTarget.otherArguments; + if (remaining.length !== 0) throw new BootstrapError("usage_invalid"); + if (commandOrFlag === "--invite-output" && maybeValue === undefined) { + return { + operation: { + inviteOutput: parseProtectedAbsolutePath(flagOrValue), + kind: "initialize", + }, + target: parsedTarget.target, + }; } - if (inviteOutput === undefined) { - throw new BootstrapError("usage_invalid"); + if ( + commandOrFlag === "recover" + && flagOrValue === "--invite-file" + ) { + return { + operation: { + inviteFile: parseProtectedAbsolutePath(maybeValue), + kind: "recover", + }, + target: parsedTarget.target, + }; } - return { inviteOutput, target: parsedTarget.target }; + throw new BootstrapError("usage_invalid"); } const parseJson = (output: string): unknown => { @@ -138,18 +223,25 @@ const authorityArguments = (deployment: string): readonly string[] => [ deployment, ]; -const genesisArguments = (deployment: string): readonly string[] => [ +const preGenesisArguments = (deployment: string): readonly string[] => [ "run", - "quota:genesisHardAuthority", - "{}", + "--inline-query", + preGenesisQuery, "--deployment", deployment, ]; -const inviteArguments = (deployment: string): readonly string[] => [ +const genesisArguments = ( + deployment: string, + authority: Pick, +): readonly string[] => [ "run", - "authInvites:issue", - JSON.stringify({ lifetimeMs: identityInviteLifetimeMs, purpose: "identity" }), + "quota:genesisHostedAuthority", + JSON.stringify({ + capabilityDigest: authority.capabilityDigest, + lifetimeMs: identityInviteLifetimeMs, + publicId: authority.publicId, + }), "--deployment", deployment, ]; @@ -184,7 +276,11 @@ const matchingProtectedPath = async ( ): Promise => { try { const current = await lstat(path); + const owner = typeof process.getuid === "function" ? process.getuid() : undefined; return current.isFile() + && !current.isSymbolicLink() + && owner !== undefined + && current.uid === owner && current.dev === identity.dev && current.ino === identity.ino && current.nlink === 1 @@ -200,9 +296,14 @@ const matchingDirectoryPath = async ( ): Promise => { try { const current = await lstat(path); + const owner = typeof process.getuid === "function" ? process.getuid() : undefined; return current.isDirectory() + && !current.isSymbolicLink() + && owner !== undefined + && current.uid === owner && current.dev === identity.dev - && current.ino === identity.ino; + && current.ino === identity.ino + && (current.mode & 0o777) === 0o700; } catch { return false; } @@ -239,6 +340,10 @@ export async function reserveCapabilityFile(path: string): Promise { + const name = basename(path); + if (name.length === 0 || name === "." || name === "..") { + throw new BootstrapError("invite_input_refused"); + } + let canonicalParent: string; + try { + canonicalParent = await realpath(dirname(path)); + } catch { + throw new BootstrapError("invite_input_refused"); + } + const canonicalPath = join(canonicalParent, name); + let parentHandle: FileHandle; + try { + parentHandle = await open( + canonicalParent, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + } catch { + throw new BootstrapError("invite_input_refused"); + } + const parentIdentity = await parentHandle.stat().catch(async () => { + await closeQuietly(parentHandle); + throw new BootstrapError("invite_input_refused"); + }); + if (!await matchingDirectoryPath(canonicalParent, parentIdentity)) { + await closeQuietly(parentHandle); + throw new BootstrapError("invite_input_refused"); + } + + let handle: FileHandle; + try { + handle = await open(canonicalPath, constants.O_RDONLY | constants.O_NOFOLLOW); + } catch { + await closeQuietly(parentHandle); + throw new BootstrapError("invite_input_refused"); + } + try { + const before = await handle.stat(); + const owner = typeof process.getuid === "function" ? process.getuid() : undefined; + if ( + owner === undefined + || !before.isFile() + || before.uid !== owner + || before.nlink !== 1 + || (before.mode & 0o777) !== 0o600 + || before.size <= 0 + || before.size > 256 + || !await matchingDirectoryPath(canonicalParent, parentIdentity) + || !await matchingProtectedPath(canonicalPath, before) + ) throw new BootstrapError("invite_input_refused"); + const bytes = await handle.readFile(); + let document: string; + try { + document = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } finally { + bytes.fill(0); + } + const after = await handle.stat(); + if ( + after.dev !== before.dev + || after.ino !== before.ino + || after.size !== before.size + || !await matchingDirectoryPath(canonicalParent, parentIdentity) + || !await matchingProtectedPath(canonicalPath, before) + ) throw new BootstrapError("invite_input_refused"); + const capability = document.endsWith("\n") ? document.slice(0, -1) : ""; + if ( + document !== `${capability}\n` + || !identityInviteCapabilitySchema.safeParse(capability).success + ) throw new BootstrapError("invite_input_refused"); + return capability; + } catch (error: unknown) { + if (error instanceof BootstrapError) throw error; + throw new BootstrapError("invite_input_refused"); + } finally { + await closeQuietly(handle); + await closeQuietly(parentHandle); + } +} + +type BootstrapRuntimeOptions = Readonly<{ environment?: Readonly; - inviteOutput: string; - reserve?: (path: string) => Promise; runner?: CommandRunner; target: ConvexTarget; verifyTarget?: ConvexTargetVerifier; }>; -export async function bootstrapHostedSync(options: BootstrapOptions): Promise { +type BootstrapOptions = BootstrapRuntimeOptions & Readonly<{ + authorityFactory?: () => Promise; + inviteOutput: string; + reserve?: (path: string) => Promise; +}>; + +type BootstrapRecoveryOptions = BootstrapRuntimeOptions & Readonly<{ + inviteFile: string; + readCapability?: (path: string) => Promise; +}>; + +export type HostedBootstrapResult = Readonly<{ + invite: z.infer; + operation: "bootstrap" | "recover"; +}>; + +const validateLocalAuthority = async (value: unknown): Promise => { + const parsed = localAuthoritySchema.safeParse(value); + if (!parsed.success) throw new BootstrapError("invite_result_invalid"); + const digest = await digestInviteCapability(parsed.data.capability, "identity"); + if ( + parsed.data.capabilityDigest !== digest + || parsed.data.publicId !== invitePublicIdFromCapabilityDigest(digest) + ) throw new BootstrapError("invite_result_invalid"); + return parsed.data; +}; + +const readHostedAuthority = ( + value: unknown, + authority: LocalAuthority, +): z.infer => { + const parsed = hostedAuthoritySchema.safeParse(value); + if (!parsed.success) throw new BootstrapError("authority_readback_invalid"); + const control = parsed.data.control[0]; + const invite = parsed.data.invites[0]; + const quota = parsed.data.quota[0]; + const inviteLogicalBytes = getDocumentSize(invite); + if ( + control.bootstrapCompletedAt !== control.updatedAt + || control.bootstrapInviteCapabilityDigest !== authority.capabilityDigest + || control.bootstrapInvitePublicId !== authority.publicId + || invite.capabilityDigest !== authority.capabilityDigest + || invite.publicId !== authority.publicId + || invite.admissionExpiresAt !== invite.expiresAt + || invite.expiresAt - invite.createdAt !== identityInviteLifetimeMs + || invite.updatedAt !== invite.createdAt + || quota.logicalBytes !== inviteLogicalBytes + || quota.serviceLogicalBytes !== inviteLogicalBytes + ) throw new BootstrapError("bootstrap_authority_conflict"); + return invite; +}; + +const authorityRowsSchema = z.object({ + control: z.array(z.unknown()).max(2), + invites: z.array(z.unknown()).max(2), + quota: z.array(z.unknown()).max(2), +}).strict(); + +type BootstrapInvoker = Readonly<{ + invoke: (arguments_: readonly string[]) => Promise; + invokeMutation: (arguments_: readonly string[]) => Promise; + target: ConvexTarget; + verifyTarget: () => Promise; +}>; + +const createBootstrapInvoker = async ( + options: BootstrapRuntimeOptions, +): Promise => { const target = parseConvexTarget(options.target); - const verifyTarget = options.verifyTarget ?? verifyConvexTarget; + const verifyTarget = options.verifyTarget ?? verifyConvexDefaultTarget; await verifyTarget(target); - const environment = buildConvexChildEnvironment(options.environment ?? process.env, []); + const sourceEnvironment = options.environment ?? process.env; + const forbiddenEnvironmentValues = Object.values(sourceEnvironment) + .filter((value): value is string => + value !== undefined && inviteCapabilityPattern.test(value)); + const environment = buildConvexChildEnvironment( + sourceEnvironment, + forbiddenEnvironmentValues, + ); const runner = options.runner ?? runCommand; const invoke = async (arguments_: readonly string[]): Promise => await runner({ @@ -397,54 +655,165 @@ export async function bootstrapHostedSync(options: BootstrapOptions): Promise => { + try { + return await invoke(arguments_); + } finally { + await verifyTarget(target); + } + }; + return { + invoke, + invokeMutation, + target, + verifyTarget: async () => await verifyTarget(target), + }; +}; + +const runHostedGenesis = async ( + invoker: BootstrapInvoker, + authority: LocalAuthority, +): Promise> => { + let mutationReplay: boolean | undefined; + let mutationFailure: Error | undefined; + try { + const genesis = await invoker.invokeMutation( + genesisArguments(invoker.target.deploymentName, authority), + ); + if (genesis.exitCode !== 0) { + mutationFailure = new BootstrapError("genesis_failed"); + } else { + let parsed: ReturnType; + try { + parsed = genesisResultSchema.safeParse(parseJson(genesis.stdout)); + } catch { + parsed = genesisResultSchema.safeParse(undefined); + } + if ( + !parsed.success + || parsed.data.invite.publicId !== authority.publicId + ) { + mutationFailure = new BootstrapError("genesis_result_invalid"); + } else { + mutationReplay = parsed.data.replay; + } + } + } catch (error: unknown) { + if (error instanceof ConvexTargetError) throw error; + mutationFailure = error instanceof Error + ? error + : new BootstrapError("genesis_failed"); + } - const before = await invoke(authorityArguments(target.deploymentName)); + const after = await invoker.invoke(authorityArguments(invoker.target.deploymentName)); + if (after.exitCode !== 0) throw new BootstrapError("authority_readback_invalid"); + const afterValue = parseJson(after.stdout); + let invite: z.infer; + try { + invite = readHostedAuthority(afterValue, authority); + } catch (error: unknown) { + const rows = authorityRowsSchema.safeParse(afterValue); + if ( + rows.success + && ( + rows.data.control.length !== 0 + || rows.data.invites.length !== 0 + || rows.data.quota.length !== 0 + ) + ) throw new BootstrapError("bootstrap_authority_conflict"); + if (mutationFailure !== undefined) throw mutationFailure; + throw error; + } + await invoker.verifyTarget(); + return inviteResultSchema.parse({ + expiresAt: invite.expiresAt, + publicId: invite.publicId, + purpose: invite.purpose, + replay: mutationReplay ?? true, + state: invite.state, + }); +}; + +export async function bootstrapHostedSync( + options: BootstrapOptions, +): Promise { + const invoker = await createBootstrapInvoker(options); + + const before = await invoker.invoke(preGenesisArguments(invoker.target.deploymentName)); if (before.exitCode !== 0) throw new BootstrapError("authority_readback_invalid"); - const beforeValue = parseJson(before.stdout); - if (!Array.isArray(beforeValue)) { + let beforeValue: z.infer; + try { + beforeValue = emptyAuthoritySchema.parse(parseJson(before.stdout)); + } catch { throw new BootstrapError("authority_readback_invalid"); } - if (beforeValue.length !== 0) throw new BootstrapError("authority_dirty"); + if ( + beforeValue.control.length !== 0 + || beforeValue.invites.length !== 0 + || beforeValue.maintenance.length !== 0 + || beforeValue.quota.length !== 0 + ) { + throw new BootstrapError("authority_dirty"); + } const sink = await (options.reserve ?? reserveCapabilityFile)(options.inviteOutput); + let authority: LocalAuthority; try { - const genesis = await invoke(genesisArguments(target.deploymentName)); - if (genesis.exitCode !== 0) throw new BootstrapError("genesis_failed"); - try { - genesisResultSchema.parse(parseJson(genesis.stdout)); - } catch { - throw new BootstrapError("genesis_result_invalid"); - } - - const after = await invoke(authorityArguments(target.deploymentName)); - if (after.exitCode !== 0) throw new BootstrapError("authority_readback_invalid"); - try { - z.tuple([authorityRowSchema]).parse(parseJson(after.stdout)); - } catch { - throw new BootstrapError("authority_readback_invalid"); - } - - const invite = await invoke(inviteArguments(target.deploymentName)); - if (invite.exitCode !== 0) throw new BootstrapError("invite_issue_failed"); - let inviteResult: z.infer; - try { - inviteResult = inviteResultSchema.parse(parseJson(invite.stdout)); - } catch { - throw new BootstrapError("invite_result_invalid"); - } - await verifyTarget(target); - await sink.commit(inviteResult.capability); + authority = await validateLocalAuthority( + await (options.authorityFactory ?? (async () => + await generateInviteAuthority("identity")))(), + ); } catch (error: unknown) { await sink.abort(); throw error; } + try { + await sink.commit(authority.capability); + } catch { + await sink.abort(); + throw new BootstrapError("invite_output_refused"); + } + return { + invite: await runHostedGenesis(invoker, authority), + operation: "bootstrap", + }; +} + +export async function recoverHostedBootstrap( + options: BootstrapRecoveryOptions, +): Promise { + const invoker = await createBootstrapInvoker(options); + let capability: string; + try { + capability = await (options.readCapability ?? readProtectedInviteCapability)( + options.inviteFile, + ); + } catch { + throw new BootstrapError("invite_input_refused"); + } + const capabilityDigest = await digestInviteCapability(capability, "identity"); + const authority = await validateLocalAuthority({ + capability, + capabilityDigest, + publicId: invitePublicIdFromCapabilityDigest(capabilityDigest), + }); + return { + invite: await runHostedGenesis(invoker, authority), + operation: "recover", + }; } type ExecuteOptions = Readonly<{ arguments: readonly string[]; + authorityFactory?: () => Promise; environment?: Readonly; + readCapability?: (path: string) => Promise; reserve?: (path: string) => Promise; runner?: CommandRunner; stderr: Pick; @@ -455,17 +824,29 @@ type ExecuteOptions = Readonly<{ export async function executeHostedBootstrap(options: ExecuteOptions): Promise { try { const arguments_ = parseBootstrapArguments(options.arguments); - await bootstrapHostedSync({ + const runtimeOptions = { ...(options.environment === undefined ? {} : { environment: options.environment }), - inviteOutput: arguments_.inviteOutput, - ...(options.reserve === undefined ? {} : { reserve: options.reserve }), ...(options.runner === undefined ? {} : { runner: options.runner }), target: arguments_.target, ...(options.verifyTarget === undefined ? {} : { verifyTarget: options.verifyTarget }), - }); - options.stdout.write( - "Hosted bootstrap verified hard zero authority and protected the first identity invite.\n", - ); + } as const; + const result = arguments_.operation.kind === "initialize" + ? await bootstrapHostedSync({ + ...runtimeOptions, + ...(options.authorityFactory === undefined + ? {} + : { authorityFactory: options.authorityFactory }), + inviteOutput: arguments_.operation.inviteOutput, + ...(options.reserve === undefined ? {} : { reserve: options.reserve }), + }) + : await recoverHostedBootstrap({ + ...runtimeOptions, + inviteFile: arguments_.operation.inviteFile, + ...(options.readCapability === undefined + ? {} + : { readCapability: options.readCapability }), + }); + options.stdout.write(`${JSON.stringify(result)}\n`); return 0; } catch (error: unknown) { const code = error instanceof BootstrapError diff --git a/scripts/check-installed-package.ts b/scripts/check-installed-package.ts new file mode 100644 index 0000000..d5fca29 --- /dev/null +++ b/scripts/check-installed-package.ts @@ -0,0 +1,17 @@ +import { realpath } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; + +import { assertProductionPackageOnly } from "./package-policy"; + +const [packageRoot, ...remaining] = process.argv.slice(2); +if ( + remaining.length !== 0 + || packageRoot === undefined + || !isAbsolute(packageRoot) + || resolve(packageRoot) !== packageRoot +) { + throw new Error("Expected one absolute installed-package root."); +} +const canonicalRoot = await realpath(packageRoot); +await assertProductionPackageOnly(canonicalRoot); +process.stdout.write("Installed package contains production files only.\n"); diff --git a/scripts/check-package.ts b/scripts/check-package.ts index 8c174f3..dc0106b 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { mkdtemp, mkdir, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join, resolve } from "node:path"; @@ -10,6 +10,7 @@ import { assertPublicText, assertPublicTree, } from "./public-text-policy"; +import { assertProductionPackageOnly } from "./package-policy"; const packageSchema = z.object({ bin: z.object({ hra: z.literal("./src/cli.ts") }).strict(), @@ -74,27 +75,14 @@ const assertExactlyOneJsonValue = (value: string): unknown => { } }; -const assertProductionPackageOnly = async (root: string): Promise => { - const visit = async (path: string): Promise => { - for (const entry of await readdir(path, { withFileTypes: true })) { - const child = join(path, entry.name); - if (entry.isDirectory()) await visit(child); - else if ( - entry.isFile() - && (entry.name === "AGENTS.md" || entry.name.endsWith(".test.ts") || entry.name === "testAssertions.ts") - ) { - throw new Error("The install artifact contains development-only source."); - } - } - }; - await visit(root); -}; - const repositoryRoot = resolve(import.meta.dir, ".."); const packageJson = packageSchema.parse( JSON.parse(await readFile(join(repositoryRoot, "package.json"), "utf8")) as unknown, ); if (!packageJson.files.includes("src")) throw new Error("The package must include src."); +if (!packageJson.files.includes("!src/cloud/inviteAuthority.ts")) { + throw new Error("The package must exclude operator-only invite authority."); +} await assertPublicTree(repositoryRoot); const completeHistory = requireSuccess( diff --git a/scripts/configure-hosted-sync.test.ts b/scripts/configure-hosted-sync.test.ts index 2702089..1ce0e13 100644 --- a/scripts/configure-hosted-sync.test.ts +++ b/scripts/configure-hosted-sync.test.ts @@ -17,6 +17,7 @@ import { type GeneratedHostedSecrets, } from "./configure-hosted-sync"; import { + HRA_CONVEX_PROJECT_ID, HRA_CONVEX_TEAM_ID, type ConvexTarget, type ConvexTargetVerifier, @@ -26,7 +27,7 @@ const target: ConvexTarget = { deploymentId: 7_654_321, deploymentName: "steady-otter-321", deploymentUrl: "https://steady-otter-321.convex.cloud", - projectId: 1_234_567, + projectId: HRA_CONVEX_PROJECT_ID, teamId: HRA_CONVEX_TEAM_ID, }; @@ -239,14 +240,19 @@ describe("fresh hosted configuration", () => { ]; for (const scenario of cases) { let index = 0; + let verifications = 0; const runner: CommandRunner = async () => scenario.results[index++]!; await expect(configureHostedSync({ generate: async () => generatedSentinels, input: validInput, runner, target, - verifyTarget: exactTargetVerifier, + verifyTarget: async (value) => { + await exactTargetVerifier(value); + verifications += 1; + }, })).rejects.toThrow(scenario.expected); + expect(verifications).toBe(scenario.results.length === 1 ? 1 : 2); } }); diff --git a/scripts/configure-hosted-sync.ts b/scripts/configure-hosted-sync.ts index eb19ac9..c74283b 100644 --- a/scripts/configure-hosted-sync.ts +++ b/scripts/configure-hosted-sync.ts @@ -10,7 +10,7 @@ import { ConvexTargetError, parseConvexTarget, parseConvexTargetArguments, - verifyConvexTarget, + verifyConvexDefaultTarget, type ConvexTarget, type ConvexTargetVerifier, } from "./convex-target"; @@ -321,7 +321,7 @@ type ConfigureOptions = Readonly<{ export async function configureHostedSync(options: ConfigureOptions): Promise { const target = parseConvexTarget(options.target); - const verifyTarget = options.verifyTarget ?? verifyConvexTarget; + const verifyTarget = options.verifyTarget ?? verifyConvexDefaultTarget; await verifyTarget(target); const generated = await (options.generate ?? generateHostedSecrets)(); const forbidden = secretValues(options.input, generated); @@ -339,6 +339,16 @@ export async function configureHostedSync(options: ConfigureOptions): Promise => { + try { + return await invoke(arguments_, stdin); + } finally { + await verifyTarget(target); + } + }; const before = await invoke(listArguments(target.deploymentName), ""); if (before.exitCode !== 0) { @@ -349,7 +359,7 @@ export async function configureHostedSync(options: ConfigureOptions): Promise { ...target, teamId: HRA_CONVEX_TEAM_ID + 1, })).toThrow("target_invalid"); + expect(() => parseConvexTarget({ + ...target, + projectId: HRA_CONVEX_PROJECT_ID + 1, + })).toThrow("target_invalid"); expect(() => parseConvexTargetArguments([ ...targetArguments.slice(0, 3), String(HRA_CONVEX_TEAM_ID + 1), diff --git a/scripts/convex-target.ts b/scripts/convex-target.ts index fdefadd..ad30e7a 100644 --- a/scripts/convex-target.ts +++ b/scripts/convex-target.ts @@ -21,6 +21,7 @@ export const HRA_V0_CONVEX_PROJECT_ID = 2_680_173; export const HRA_V0_CONVEX_DEPLOYMENT_ID = 4_677_913; export const HRA_CONVEX_TEAM_ID = 513_923; export const HRA_CONVEX_TEAM_SLUG = "cclrte"; +export const HRA_CONVEX_PROJECT_ID = 2_854_545; const generatedDeploymentNameSchema = z.string() .min(5) @@ -49,12 +50,9 @@ const expectedTargetSchema = z.object({ deploymentId: numericIdentifierSchema, deploymentName: generatedDeploymentNameSchema, deploymentUrl: deploymentUrlSchema, - projectId: numericIdentifierSchema, + projectId: z.literal(HRA_CONVEX_PROJECT_ID), teamId: z.literal(HRA_CONVEX_TEAM_ID), }).strict().superRefine((target, context) => { - if (target.projectId === HRA_V0_CONVEX_PROJECT_ID) { - context.addIssue({ code: "custom", message: "HRA v0 project is forbidden." }); - } if (target.deploymentId === HRA_V0_CONVEX_DEPLOYMENT_ID) { context.addIssue({ code: "custom", message: "HRA v0 deployment is forbidden." }); } diff --git a/scripts/deploy-hosted-sync.test.ts b/scripts/deploy-hosted-sync.test.ts index af4767e..0dd054b 100644 --- a/scripts/deploy-hosted-sync.test.ts +++ b/scripts/deploy-hosted-sync.test.ts @@ -22,6 +22,7 @@ import { resolvedConvexDeployTargetMatches, } from "./assert-convex-deploy-target"; import { + HRA_CONVEX_PROJECT_ID, HRA_CONVEX_TEAM_ID, type ConvexTarget, type ConvexTargetVerifier, @@ -32,7 +33,7 @@ const target: ConvexTarget = { deploymentId: 7_654_321, deploymentName: "steady-otter-321", deploymentUrl: "https://steady-otter-321.convex.cloud", - projectId: 1_234_567, + projectId: HRA_CONVEX_PROJECT_ID, teamId: HRA_CONVEX_TEAM_ID, }; const targetArguments = [ diff --git a/scripts/live-acceptance-installation.ts b/scripts/live-acceptance-installation.ts new file mode 100644 index 0000000..7866766 --- /dev/null +++ b/scripts/live-acceptance-installation.ts @@ -0,0 +1,223 @@ +import { constants } from "node:fs"; +import { open } from "node:fs/promises"; +import { basename, isAbsolute, join, relative, resolve } from "node:path"; + +import { z } from "zod"; + +import { canonicalCloudDeploymentUrl } from "../src/cloud/identity-custody"; +import type { HraInstallation } from "../src/installation"; +import { ensurePrivateDirectory, resolveStatePaths } from "../src/storage/paths"; +import { + FileSecretBackend, + GenerationalSecretCustody, +} from "../src/storage/secret-custody"; + +const normalizedAbsolutePathSchema = z.string() + .min(1) + .max(4_096) + .refine((value) => isAbsolute(value) && resolve(value) === value); + +export const acceptanceInstallationDescriptorSchema = z.object({ + cloudDeploymentUrl: z.string().min(1).max(2_048).refine((value) => { + try { + return canonicalCloudDeploymentUrl(value) === value; + } catch { + return false; + } + }).optional(), + device: z.enum(["a", "b"]), + documentsDirectory: normalizedAbsolutePathSchema, + expectedHomeDirectory: normalizedAbsolutePathSchema, + rootDirectory: normalizedAbsolutePathSchema, + runId: z.string().uuid(), + type: z.literal("hra-live-acceptance-device"), + version: z.literal(1), +}).strict(); + +export type AcceptanceInstallationDescriptor = z.infer< + typeof acceptanceInstallationDescriptorSchema +>; + +const acceptanceCodexConfig = [ + 'cli_auth_credentials_store = "file"', + 'mcp_oauth_credentials_store = "file"', + "", +].join("\n"); + +const maximumAcceptanceCodexConfigBytes = 4_096; +const acceptanceCodexInheritedEnvironmentKeys = [ + "LANG", + "LC_ALL", + "LC_CTYPE", + "LOGNAME", + "PATH", + "SHELL", + "USER", +] as const; + +async function acceptanceCodexEnvironment( + codexHome: string, + expectedHomeDirectory: string, +): Promise>> { + const environment: Record = { + HOME: expectedHomeDirectory, + TMPDIR: await ensurePrivateDirectory(join(codexHome, "tmp")), + }; + for (const key of acceptanceCodexInheritedEnvironmentKeys) { + const value = process.env[key]; + if (value !== undefined) environment[key] = value; + } + return environment; +} + +async function assertAcceptanceCodexConfig(configPath: string): Promise { + const currentUid = process.getuid?.(); + if (currentUid === undefined) { + throw new Error("Live acceptance requires an operating system user ID."); + } + + const handle = await open(configPath, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = await handle.stat(); + if ( + !before.isFile() + || before.nlink !== 1 + || before.uid !== currentUid + || (before.mode & 0o777) !== 0o600 + || before.size > maximumAcceptanceCodexConfigBytes + ) { + throw new Error("Acceptance CODEX_HOME has an unsafe credential-store configuration."); + } + + const contents = Buffer.alloc(maximumAcceptanceCodexConfigBytes + 1); + let length = 0; + while (length < contents.length) { + const read = await handle.read(contents, length, contents.length - length, length); + if (read.bytesRead === 0) break; + length += read.bytesRead; + } + + const after = await handle.stat(); + if ( + !after.isFile() + || after.nlink !== 1 + || after.uid !== currentUid + || (after.mode & 0o777) !== 0o600 + || after.size > maximumAcceptanceCodexConfigBytes + || before.dev !== after.dev + || before.ino !== after.ino + || before.size !== after.size + || length !== after.size + || contents.subarray(0, length).toString("utf8") !== acceptanceCodexConfig + ) { + throw new Error("Acceptance CODEX_HOME has an unexpected credential-store configuration."); + } + } finally { + await handle.close(); + } +} + +async function prepareAcceptanceCodexHome(codexHome: string): Promise { + const canonicalHome = await ensurePrivateDirectory(codexHome); + if (canonicalHome !== resolve(codexHome)) { + throw new Error("Acceptance CODEX_HOME is not canonical."); + } + const configPath = join(canonicalHome, "config.toml"); + try { + const handle = await open( + configPath, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, + 0o600, + ); + try { + await handle.writeFile(acceptanceCodexConfig, "utf8"); + await handle.chmod(0o600); + await handle.sync(); + } finally { + await handle.close(); + } + const directory = await open(canonicalHome, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + await directory.sync(); + } finally { + await directory.close(); + } + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + await assertAcceptanceCodexConfig(configPath); + await ensurePrivateDirectory(join(canonicalHome, "tmp")); +} + +function assertDirectChild(parent: string, child: string, label: string): void { + const relation = relative(parent, child); + if ( + relation === "" + || relation.startsWith("..") + || isAbsolute(relation) + || relation.includes("/") + || relation.includes("\\") + ) throw new Error(`${label} must be one direct child of the acceptance run root.`); +} + +function pathsOverlap(leftInput: string, rightInput: string): boolean { + const left = resolve(leftInput); + const right = resolve(rightInput); + const leftToRight = relative(left, right); + const rightToLeft = relative(right, left); + return left === right + || (!leftToRight.startsWith("..") && !isAbsolute(leftToRight)) + || (!rightToLeft.startsWith("..") && !isAbsolute(rightToLeft)); +} + +export function createAcceptanceInstallation( + descriptorInput: AcceptanceInstallationDescriptor, +): HraInstallation { + const descriptor = acceptanceInstallationDescriptorSchema.parse(descriptorInput); + const runRoot = resolve(descriptor.rootDirectory, ".."); + assertDirectChild(runRoot, descriptor.rootDirectory, "Acceptance state root"); + assertDirectChild(runRoot, descriptor.documentsDirectory, "Acceptance project root"); + if (descriptor.rootDirectory === descriptor.documentsDirectory) { + throw new Error("Acceptance state and project roots must be distinct."); + } + if ( + !basename(runRoot).startsWith(`hra-live-acceptance-${descriptor.runId}-`) + || !basename(descriptor.rootDirectory).startsWith(`device-${descriptor.device}-`) + || !basename(descriptor.documentsDirectory).startsWith(`project-${descriptor.device}-`) + ) throw new Error("Acceptance installation paths do not match their run identity."); + const productionRoot = resolveStatePaths().root; + if ( + pathsOverlap(runRoot, productionRoot) + || pathsOverlap(runRoot, descriptor.expectedHomeDirectory) + ) throw new Error("Acceptance state must not overlap production HRA state or the invoking home."); + + const cloudDeploymentUrl = descriptor.cloudDeploymentUrl === undefined + ? undefined + : canonicalCloudDeploymentUrl(descriptor.cloudDeploymentUrl); + const paths = resolveStatePaths({ rootDirectory: descriptor.rootDirectory }); + const expectedHomeDirectory = descriptor.expectedHomeDirectory; + return { + cloudEnvironment: cloudDeploymentUrl === undefined + ? { HRA_CONVEX_URL: "" } + : { HRA_CONVEX_URL: cloudDeploymentUrl }, + codexEnvironment: async (codexHome) => await acceptanceCodexEnvironment( + codexHome, + expectedHomeDirectory, + ), + credentialStorePreflight: { + cliAuth: "file", + cwd: descriptor.documentsDirectory, + mcpOauth: "file", + }, + createSecretCustody: () => new GenerationalSecretCustody( + paths, + new FileSecretBackend(join(paths.root, "secret-values")), + ), + desktopSwitching: false, + documentsDirectory: descriptor.documentsDirectory, + expectedHomeDirectory, + kind: "live_acceptance", + paths, + prepareCodexHome: prepareAcceptanceCodexHome, + }; +} diff --git a/scripts/live-acceptance-scenario.test.ts b/scripts/live-acceptance-scenario.test.ts new file mode 100644 index 0000000..329f9c2 --- /dev/null +++ b/scripts/live-acceptance-scenario.test.ts @@ -0,0 +1,932 @@ +import { describe, expect, test } from "bun:test"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import { createInterface } from "node:readline/promises"; +import type { Readable, Writable } from "node:stream"; +import { pathToFileURL } from "node:url"; + +import type { PublicInteraction } from "../src/domain/interactions"; +import type { SessionEvent } from "../src/domain/session-events"; +import { DEFAULT_CLOUD_DEPLOYMENT_URL } from "../src/cloud/identity-custody"; +import type { + LiveAcceptanceCliResult, + LiveAcceptanceDevice, + LiveAcceptanceDeviceName, +} from "./live-acceptance"; +import { + liveAcceptanceScenarioConfigurationSchema, + runLiveAcceptanceScenario, + type LiveAcceptanceOperatorRequest, + type LiveAcceptanceScenarioOperator, +} from "./live-acceptance-scenario"; + +const accountA = `acct_${"1".repeat(32)}`; +const accountB = `acct_${"2".repeat(32)}`; +const projectA = `proj_${"3".repeat(32)}`; +const projectB = `proj_${"4".repeat(32)}`; +const sessionA = `sess_${"5".repeat(32)}`; +const sessionB = `sess_${"6".repeat(32)}`; +const deviceAId = `device_${"7".repeat(32)}`; +const deviceBId = `device_${"8".repeat(32)}`; +const commandId = "018bcfe5-6800-7000-8000-000000000001"; +const userInteractionId = "10000000-0000-4000-8000-000000000001"; +const permissionInteractionId = "20000000-0000-4000-8000-000000000001"; +const attestation = { + cloudTargetDigest: "a".repeat(64), + packageVersion: "0.1.0", + sourceRevision: "b".repeat(40), +} as const; + +const success = (command: string, data: unknown): LiveAcceptanceCliResult => ({ + exitCode: 0, + stderr: "", + stdout: `${JSON.stringify({ command, data, ok: true, version: 1 })}\n`, +}); + +const failure = ( + code: "INVALID_INPUT" | "UNAVAILABLE" = "UNAVAILABLE", + exitCode = code === "INVALID_INPUT" ? 2 : 5, +): LiveAcceptanceCliResult => ({ + exitCode, + stderr: "", + stdout: `${JSON.stringify({ + error: { code, message: "Unavailable in the deterministic acceptance world." }, + ok: false, + version: 1, + })}\n`, +}); + +const interaction = (kind: "permission_approval" | "user_input"): PublicInteraction => ({ + blocking: true, + context: { itemId: "item-1", turnId: "turn-1" }, + deadlineAt: 1_000_000, + display: kind === "user_input" + ? { + blocking: true, + kind, + questions: [{ + allowsOther: false, + header: "Acceptance", + id: "acceptance_choice", + options: [{ description: "Continue the test", label: "Continue" }], + question: "Continue?", + secret: false, + }], + summary: "Acceptance user input", + } + : { + allowsSessionScope: true, + kind, + reason: "Acceptance permission proof", + requested: [{ name: "network" }], + summary: "Acceptance permission", + }, + id: kind === "user_input" + ? userInteractionId + : permissionInteractionId, + kind, + requestedAt: 1, + responseRecorded: false, + revision: 1, + sessionId: kind === "user_input" ? sessionA : sessionB, + state: "pending", + terminalAt: null, + updatedAt: 1, + version: 1, +}); + +const event = ( + sessionId: string, + sequence: number, + body: SessionEvent["body"], +): SessionEvent => ({ + accountId: sessionId === sessionA ? accountA : accountB, + body, + providerConnectionId: "30000000-0000-4000-8000-000000000001", + providerGeneration: 1, + recordedAt: sequence, + sequence, + sessionId, + streamEpoch: "40000000-0000-4000-8000-000000000001", + version: 1, +}); + +const eventPage = ( + sessionId: string, + complete: boolean, + marker: string, + requestedCursor: string | null, + sequenceGap = false, +): unknown => { + const interactionId = sessionId === sessionA ? userInteractionId : permissionInteractionId; + const interactionKind = sessionId === sessionA ? "user_input" : "permission_approval"; + const events = (complete + ? [ + event(sessionId, 5, { + interactionId, + revision: 2, + state: "response_prepared", + type: "interaction_state", + }), + event(sessionId, 6, { + interactionId, + revision: 3, + state: "response_written", + type: "interaction_state", + }), + event(sessionId, 7, { + itemId: "tool-1", + outputBytesObserved: 23, + status: "running", + toolKind: "command", + turnId: "turn-1", + type: "tool_progress", + }), + event(sessionId, 8, { + itemId: "tool-1", + itemKind: "commandExecution", + status: "completed", + turnId: "turn-1", + type: "item_completed", + }), + event(sessionId, 9, { + itemId: "assistant-1", + text: marker, + turnId: "turn-1", + type: "assistant_delta", + }), + event(sessionId, 10, { + status: "completed", + turnId: "turn-1", + type: "turn_completed", + }), + ] + : [ + event(sessionId, 1, { turnId: "turn-1", type: "turn_started" }), + event(sessionId, 2, { + blocking: true, + interactionId, + interactionKind, + revision: 1, + summary: "Acceptance interaction", + type: "interaction_requested", + }), + event(sessionId, 3, { + itemId: "reasoning-1", + text: "safe summary", + turnId: "turn-1", + type: "reasoning_summary_delta", + }), + event(sessionId, 4, { + itemId: "tool-1", + itemKind: "commandExecution", + turnId: "turn-1", + type: "item_started", + }), + ]).map((entry) => sequenceGap && entry.sequence >= 3 + ? { ...entry, sequence: entry.sequence + 1 } + : entry) as SessionEvent[]; + return { + events, + gap: null, + nextCursor: complete ? "cursor-terminal" : "cursor-first", + observedThroughCursor: "cursor-observed", + requestedCursor, + retentionFloorCursor: "cursor-floor", + sessionId, + version: 1, + }; +}; + +class FakeWorld { + accountLoginPending = false; + approved = false; + boundPeer: string | undefined; + cleanupComplete = false; + deviceBOnline = true; + deviceBRevoked = false; + readonly eventPolls = new Map(); + readonly messages = new Map(); + emptyUsage = false; + eventSequenceGap = false; + invalidInteractionResolution = false; + omitAssistantEvidence = false; + remoteCommandPolls = 0; + remoteApplied = false; + remoteMarker = ""; + remotePrompt = ""; + remoteProjectionPolls = 0; + remoteProjectionWrongAuthority = false; + remoteTurnNeverCompletes = false; + skipRemoteClaim = false; + unsafeDeviceCode = false; + wrongInteractionTurn = false; + sessionStarts = 0; + accountAdds = 0; +} + +class FakeDevice implements LiveAcceptanceDevice { + readonly calls: readonly string[][] = []; + readonly device: LiveAcceptanceDeviceName; + readonly projectDirectory: string; + readonly #world: FakeWorld; + + constructor(device: LiveAcceptanceDeviceName, world: FakeWorld) { + this.device = device; + this.projectDirectory = `/private/tmp/hra-acceptance-${device}`; + this.#world = world; + } + + async execute( + argvInput: readonly string[], + options: Readonly<{ protectedDocument?: unknown }> = {}, + ): Promise { + const argv = [...argvInput]; + (this.calls as string[][]).push(argv); + const command = `${argv[0]}.${argv[1]}`; + if (command === "project.add") { + return success(command, { project: { id: this.device === "a" ? projectA : projectB } }); + } + if (command === "auth.login") { + if (!Object.hasOwn(options, "protectedDocument") || !argv.includes("--input-fd")) { + throw new Error("Protected auth did not use the CLI descriptor boundary."); + } + return success(command, { signedIn: true }); + } + if (command === "device.pair") { + if (this.device === "a") { + return success(command, { device: { publicId: deviceAId, status: "active" }, paired: true }); + } + return success(command, { + device: { + publicId: deviceBId, + status: this.#world.approved ? "active" : "pending", + }, + paired: this.#world.approved, + }); + } + if (command === "device.list") { + return success(command, { + currentDevicePublicId: deviceAId, + devices: [ + { current: true, online: true, publicId: deviceAId, status: "active" }, + { + current: false, + online: this.#world.deviceBOnline, + publicId: deviceBId, + status: this.#world.deviceBRevoked + ? "revoked" + : this.#world.approved + ? "active" + : "pending", + }, + ], + }); + } + if (command === "device.approve") { + this.#world.approved = true; + return success(command, { device: { publicId: deviceBId, status: "active" } }); + } + if (command === "device.revoke") { + this.#world.deviceBRevoked = true; + this.#world.deviceBOnline = false; + return success(command, { device: { publicId: deviceBId, status: "revoked" } }); + } + if (command === "account.add") { + this.#world.accountAdds += 1; + return success(command, { + account: { id: this.#world.accountAdds === 1 ? accountA : accountB, state: "signed_out" }, + }); + } + if (command === "account.login") { + return success(command, { + account: { id: argv[2], state: "login_pending" }, + login: { + status: "pending", + userCode: this.#world.unsafeDeviceCode ? "ABCD\u001b[2J" : "ABCD-EFGH", + verificationUrl: "https://example.test/device", + }, + }); + } + if (command === "account.show") { + const primary = argv[2] === accountA; + return success(command, { + account: { + id: argv[2], + providerEmail: primary ? "primary@example.test" : "secondary@example.test", + providerPlan: "plus", + state: this.#world.accountLoginPending ? "login_pending" : "signed_in", + }, + }); + } + if (command === "account.usage") { + if (this.#world.emptyUsage) return success(command, { usage: [] }); + const accountId = argv[2]!; + return success(command, { + usage: [{ + account: { id: accountId }, + poll: { observedAt: 10_000, sourceRevision: 1, state: "observed" }, + snapshot: { observedAt: 10_000, sourceRevision: 1 }, + }], + }); + } + if (command === "sync.now") { + if (this.device === "b" && (!this.#world.approved || this.#world.deviceBRevoked)) { + return failure(); + } + return success(command, { online: true }); + } + if (command === "remote.list") { + if (this.device === "b" && (!this.#world.approved || this.#world.deviceBRevoked)) { + return failure(); + } + return success(command, { + sessions: [ + { executionDevicePublicId: deviceAId, publicId: sessionA }, + { executionDevicePublicId: deviceAId, publicId: sessionB }, + ], + truncated: false, + }); + } + if (command === "session.start") { + this.#world.sessionStarts += 1; + return success(command, { + session: { id: this.#world.sessionStarts === 1 ? sessionA : sessionB }, + }); + } + if (command === "session.send") { + this.#world.messages.set(argv[2]!, argv[3]!); + return success(command, { session: { id: argv[2] }, turnId: "turn-1" }); + } + if (command === "interaction.list") { + const found = interaction(argv[2] === sessionA ? "user_input" : "permission_approval"); + return success(command, { + interactions: [{ + ...found, + ...(this.#world.wrongInteractionTurn + ? { context: { ...found.context, turnId: "turn-other" } } + : {}), + }], + }); + } + if (command === "interaction.answer" || command === "interaction.grant") { + if (!Object.hasOwn(options, "protectedDocument")) { + throw new Error("Interaction resolution omitted protected input."); + } + if (this.#world.invalidInteractionResolution) { + return success("interaction.resolve", { interaction: null, responseWritten: true }); + } + const kind = command === "interaction.answer" ? "user_input" : "permission_approval"; + return success("interaction.resolve", { + interaction: { + ...interaction(kind), + responseRecorded: true, + revision: 3, + state: "response_written", + updatedAt: 3, + }, + responseWritten: true, + }); + } + if (command === "session.events") { + const sessionId = argv[2]!; + const polls = (this.#world.eventPolls.get(sessionId) ?? 0) + 1; + this.#world.eventPolls.set(sessionId, polls); + const cursorIndex = argv.indexOf("--cursor"); + const requestedCursor = cursorIndex < 0 ? null : argv[cursorIndex + 1]!; + const marker = this.#world.messages.get(sessionId)?.match( + /hra-live-(?:user-input|permission)-[0-9a-f-]+/u, + )?.[0] ?? ""; + return success(command, eventPage( + sessionId, + polls > 1, + this.#world.omitAssistantEvidence ? "" : marker, + requestedCursor, + this.#world.eventSequenceGap, + )); + } + if (command === "session.show") { + const prompt = this.#world.messages.get(argv[2]!) ?? ""; + const marker = prompt.match(/hra-live-(?:user-input|permission)-[0-9a-f-]+/u)?.[0] ?? ""; + return success(command, { + projection: { + messages: [ + { role: "user", text: prompt, turnId: "turn-1" }, + { + role: "assistant", + text: this.#world.omitAssistantEvidence ? "" : marker, + turnId: "turn-1", + }, + ], + }, + }); + } + if (command === "plugin.list") return success(command, { + account: { id: accountA, state: "signed_in" }, + catalog: { + lifecycle: { + discovery: "available", + enablement: "no_separate_pinned_method", + install: "blocked_compound_upstream_effect", + oauth: "separate_foreground_only", + }, + marketplaceLoadErrorCount: 0, + marketplaces: [{ plugins: [{ id: "acceptance@example" }] }], + }, + }); + if ( + argv[0] === "plugin" + && ["auth", "disable", "enable", "install"].includes(argv[1] ?? "") + ) return failure("INVALID_INPUT", 2); + if (command === "remote.show") { + if (this.device === "b" && (!this.#world.approved || this.#world.deviceBRevoked)) { + return failure(); + } + const prompt = this.#world.messages.get(sessionA) ?? ""; + const localMarker = prompt.match(/hra-live-user-input-[0-9a-f-]+/u)?.[0] ?? ""; + if (this.#world.remoteApplied) this.#world.remoteProjectionPolls += 1; + const remoteTurnSettled = this.#world.remoteApplied + && !this.#world.remoteTurnNeverCompletes + && this.#world.remoteProjectionPolls > 1; + return success(command, { + compactHasRecoveryGap: false, + complete: true, + executionDevicePublicId: deviceAId, + events: [ + { kind: "user_message", sequence: 1, text: prompt, turnId: "turn-1" }, + { kind: "assistant_message", sequence: 2, text: localMarker, turnId: "turn-1" }, + ...(this.#world.remoteApplied + ? [ + { + kind: "user_message", + sequence: 3, + text: this.#world.remotePrompt, + turnId: "turn-remote", + }, + ...(remoteTurnSettled + ? [ + { + kind: "assistant_message", + sequence: 4, + text: this.#world.remoteMarker, + turnId: "turn-remote", + }, + { + filesTouched: [], + gitActions: [], + kind: "turn_summary", + runtimeMs: 1, + sequence: 5, + turnId: "turn-remote", + }, + ] + : []), + ] + : []), + ], + publicId: this.#world.remoteProjectionWrongAuthority ? sessionB : sessionA, + }); + } + if (command === "remote.send") { + if (this.device === "b" && (!this.#world.approved || this.#world.deviceBRevoked)) { + return failure(); + } + this.#world.remoteMarker = argv[3]!.match(/hra-live-remote-[0-9a-f-]+/u)?.[0] ?? ""; + this.#world.remotePrompt = argv[3]!; + return success(command, { + commandPublicId: commandId, + kind: "send", + sessionPublicId: sessionA, + state: "pending", + targetDevicePublicId: deviceAId, + }); + } + if (command === "remote.command") { + this.#world.remoteCommandPolls += 1; + const applied = this.#world.skipRemoteClaim || this.#world.remoteCommandPolls > 1; + if (applied) this.#world.remoteApplied = true; + return success(command, { + commandPublicId: commandId, + kind: "send", + ...(applied ? { resultCode: "APPLIED" } : {}), + sessionPublicId: sessionA, + state: applied ? "applied" : "effect_started", + targetDevicePublicId: deviceAId, + }); + } + throw new Error(`Unexpected fake CLI command: ${command}`); + } + + async resume(): Promise { + this.#world.deviceBOnline = true; + } + + async suspend(): Promise { + this.#world.deviceBOnline = false; + } +} + +class FakeOperator implements LiveAcceptanceScenarioOperator { + deviceLogins = 0; + readonly requests: LiveAcceptanceOperatorRequest[] = []; + + async acknowledgeDeviceLogin(input: unknown, signal: AbortSignal): Promise { + void input; + void signal; + this.deviceLogins += 1; + } + + progress(): void {} + + async protectedDocument( + request: LiveAcceptanceOperatorRequest, + signal: AbortSignal, + ): Promise { + void signal; + this.requests.push(request); + if (request.kind === "device_a_auth_invite") { + return { email: "person@example.test", invite: `hra_invite_${"a".repeat(48)}` }; + } + if (request.kind === "device_a_auth_code" || request.kind === "device_b_auth_code") { + return { code: "01234567", email: "person@example.test" }; + } + if (request.kind === "device_b_auth_email") return { email: "person@example.test" }; + if (request.kind === "user_answers") { + return { answers: { acceptance_choice: { answers: ["Continue"] } } }; + } + return { permissions: ["network"] }; + } +} + +const startFakeScenario = ( + world: FakeWorld, + operator: LiveAcceptanceScenarioOperator, + options: Readonly<{ + remoteCommandDeadlineMs?: number; + signal?: AbortSignal; + sleep?: (milliseconds: number) => Promise; + }> = {}, +): Readonly<{ + devices: Readonly>; + promise: ReturnType; +}> => { + const devices = { + a: new FakeDevice("a", world), + b: new FakeDevice("b", world), + } as const; + let clock = 1_000; + const promise = runLiveAcceptanceScenario({ + bindExpectedRevokedPeer: async (publicId) => { world.boundPeer = publicId; }, + cleanup: async () => { + if (world.boundPeer !== deviceBId || !world.deviceBRevoked) { + throw new Error("Cleanup was not bound to the exact revoked peer."); + } + world.cleanupComplete = true; + }, + device: (name) => devices[name], + runId: "50000000-0000-4000-8000-000000000001", + }, operator, attestation, { + accountLoginDeadlineMs: 1_000, + now: () => clock, + pollIntervalMs: 1, + presenceObservationMarginMs: 0, + remoteCommandDeadlineMs: options.remoteCommandDeadlineMs ?? 1_000, + ...(options.signal === undefined ? {} : { signal: options.signal }), + sleep: options.sleep ?? (async (milliseconds) => { clock += milliseconds; }), + turnDeadlineMs: 1_000, + }); + return { devices, promise }; +}; + +describe("live acceptance release scenario", () => { + test("executes the complete two-device CLI scenario and emits only bounded evidence", async () => { + const world = new FakeWorld(); + const devices = { + a: new FakeDevice("a", world), + b: new FakeDevice("b", world), + }; + const operator = new FakeOperator(); + let clock = 1_000; + const evidence = await runLiveAcceptanceScenario({ + bindExpectedRevokedPeer: async (publicId) => { world.boundPeer = publicId; }, + cleanup: async () => { + if (world.boundPeer !== deviceBId || !world.deviceBRevoked) { + throw new Error("Cleanup was not bound to the exact revoked peer."); + } + world.cleanupComplete = true; + }, + device: (name) => devices[name], + runId: "50000000-0000-4000-8000-000000000001", + }, operator, attestation, { + accountLoginDeadlineMs: 1_000, + now: () => clock, + pollIntervalMs: 1, + presenceObservationMarginMs: 0, + remoteCommandDeadlineMs: 1_000, + sleep: async (milliseconds) => { clock += milliseconds; }, + turnDeadlineMs: 1_000, + }); + + expect(world.cleanupComplete).toBe(true); + expect(world.boundPeer).toBe(deviceBId); + expect(operator.deviceLogins).toBe(2); + expect(operator.requests.map((request) => request.kind)).toEqual([ + "device_a_auth_invite", + "device_a_auth_code", + "device_b_auth_email", + "device_b_auth_code", + "user_answers", + "permission_grant", + ]); + expect(evidence).toMatchObject({ + accountIds: [accountA, accountB], + cloudTargetDigest: "a".repeat(64), + devicePublicIds: [deviceAId, deviceBId], + packageVersion: "0.1.0", + pluginLifecycleEffectsRejected: ["auth", "disable", "enable", "install"], + pluginInstallRejected: true, + presence: ["online", "offline", "online"], + providerIdentitiesDistinct: true, + remoteCommand: { resultCode: "APPLIED", state: "applied" }, + sessionIds: [sessionA, sessionB], + sourceRevision: "b".repeat(40), + status: "passed", + version: 1, + }); + const serialized = JSON.stringify(evidence); + expect(serialized).not.toContain("primary@example.test"); + expect(serialized).not.toContain("secondary@example.test"); + expect(serialized).not.toContain("primary@example"); + expect(serialized).not.toContain("secondary@example"); + for (const email of ["primary@example.test", "secondary@example.test"]) { + expect(serialized).not.toContain( + createHash("sha256").update(email, "utf8").digest("hex"), + ); + } + expect(Object.hasOwn(evidence, "providerIdentityDigests")).toBe(false); + expect(serialized).not.toContain("ABCD-EFGH"); + expect(devices.a.calls.some((argv) => + argv.includes("--input-fd") && argv.includes("4"))).toBe(true); + for (const action of ["auth", "disable", "enable", "install"]) { + expect(devices.a.calls.some((argv) => argv[0] === "plugin" && argv[1] === action)) + .toBe(true); + } + }); + + test("rejects prompt-only markers and empty usage", async () => { + const promptOnly = new FakeWorld(); + promptOnly.omitAssistantEvidence = true; + await expect(startFakeScenario(promptOnly, new FakeOperator()).promise) + .rejects.toThrow("session_stream_evidence_incomplete"); + + const emptyUsage = new FakeWorld(); + emptyUsage.emptyUsage = true; + await expect(startFakeScenario(emptyUsage, new FakeOperator()).promise).rejects.toThrow(); + + }); + + test("accepts an immediate applied receipt but waits for the exact remote turn to settle", async () => { + const immediateApplied = new FakeWorld(); + immediateApplied.skipRemoteClaim = true; + await expect(startFakeScenario(immediateApplied, new FakeOperator()).promise) + .resolves.toMatchObject({ status: "passed" }); + expect(immediateApplied.remoteProjectionPolls).toBeGreaterThan(1); + + const neverSettled = new FakeWorld(); + neverSettled.remoteTurnNeverCompletes = true; + await expect(startFakeScenario(neverSettled, new FakeOperator(), { + remoteCommandDeadlineMs: 5, + }).promise).rejects.toThrow("poll_deadline_exceeded"); + }); + + test("rejects wrong remote projection authority and a local sequence gap", async () => { + const wrongRemoteAuthority = new FakeWorld(); + wrongRemoteAuthority.remoteProjectionWrongAuthority = true; + await expect(startFakeScenario(wrongRemoteAuthority, new FakeOperator()).promise) + .rejects.toThrow("remote_projection_identity_changed"); + + const localGap = new FakeWorld(); + localGap.eventSequenceGap = true; + await expect(startFakeScenario(localGap, new FakeOperator()).promise) + .rejects.toThrow("session_events_unordered"); + }); + + test("requires exact-turn pending interactions and an exact response-written receipt", async () => { + const wrongTurn = new FakeWorld(); + wrongTurn.wrongInteractionTurn = true; + await expect(startFakeScenario(wrongTurn, new FakeOperator()).promise) + .rejects.toThrow("interaction_authority_changed"); + + const noOpResolution = new FakeWorld(); + noOpResolution.invalidInteractionResolution = true; + await expect(startFakeScenario(noOpResolution, new FakeOperator()).promise).rejects.toThrow(); + }); + + test("JSONL operator abort closes the inherited input read and lets the process exit", async () => { + const moduleUrl = pathToFileURL(join(import.meta.dir, "live-acceptance-scenario.ts")).href; + const child = spawn(process.execPath, [ + "-e", + [ + `import { JsonlLiveAcceptanceOperator } from ${JSON.stringify(moduleUrl)};`, + "const operator = new JsonlLiveAcceptanceOperator();", + "const controller = new AbortController();", + "let subscriptions = 0;", + "const signal = {", + " get aborted() { return controller.signal.aborted; },", + " get reason() { return controller.signal.reason; },", + " addEventListener(type, listener, options) {", + " controller.signal.addEventListener(type, listener, options);", + " subscriptions += 1;", + " // The second subscription is installed immediately before the pending input read.", + " if (subscriptions === 2) setImmediate(() => controller.abort());", + " },", + " removeEventListener(type, listener, options) {", + " controller.signal.removeEventListener(type, listener, options);", + " },", + "};", + "try {", + " await operator.protectedDocument({ kind: 'device_a_auth_invite', prompt: 'probe' }, signal);", + " process.exitCode = 2;", + "} catch {", + " if (!controller.signal.aborted) process.exitCode = 3;", + "}", + ].join("\n"), + ], { + cwd: join(import.meta.dir, ".."), + stdio: ["ignore", "ignore", "ignore", "ignore", "pipe", "pipe"], + }); + const operatorOutput = (child.stdio as Array)[5] as + | Readable + | null + | undefined; + if (operatorOutput === undefined || operatorOutput === null) { + throw new Error("Missing JSONL operator output pipe."); + } + const outputLines = createInterface({ input: operatorOutput }); + const outputIterator = outputLines[Symbol.asyncIterator](); + const exitPromise = new Promise>((resolvePromise) => { + child.once("exit", (code, signal) => { + resolvePromise({ code, signal }); + }); + }); + const bounded = async (promise: Promise): Promise => { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((resolvePromise) => { + timeout = setTimeout(() => resolvePromise(null), 3_000); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } + }; + try { + const frame = await bounded(outputIterator.next()); + if (frame === null || frame.done) throw new Error("Missing JSONL operator request."); + expect(JSON.parse(frame.value) as unknown).toMatchObject({ + kind: "device_a_auth_invite", + prompt: "probe", + requestId: expect.any(String), + type: "protected_input_required", + version: 1, + }); + expect(await bounded(exitPromise)).toEqual({ code: 0, signal: null }); + } finally { + outputLines.close(); + operatorOutput.resume(); + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + await exitPromise; + } + } + }, 10_000); + + test("rejects terminal-unsafe provider login handoff before rendering it", async () => { + const world = new FakeWorld(); + world.unsafeDeviceCode = true; + const operator = new FakeOperator(); + await expect(startFakeScenario(world, operator).promise) + .rejects.toThrow("device_user_code_invalid"); + expect(operator.deviceLogins).toBe(0); + }); + + test("rejects a different B cloud identity before B auth has an effect", async () => { + class MismatchedIdentityOperator extends FakeOperator { + override async protectedDocument( + request: LiveAcceptanceOperatorRequest, + signal: AbortSignal, + ): Promise { + const document = await super.protectedDocument(request, signal); + return request.kind === "device_b_auth_email" + ? { email: "different@example.test" } + : document; + } + } + const world = new FakeWorld(); + const started = startFakeScenario(world, new MismatchedIdentityOperator()); + await expect(started.promise).rejects.toThrow("protected_auth_identity_changed"); + expect(started.devices.b.calls.filter((argv) => + argv[0] === "auth" && argv[1] === "login")).toHaveLength(0); + }); + + test("interrupts an operator read before starting the protected auth effect", async () => { + let markReadStarted!: () => void; + const readStarted = new Promise((resolvePromise) => { + markReadStarted = resolvePromise; + }); + class BlockingOperator extends FakeOperator { + override async protectedDocument( + request: LiveAcceptanceOperatorRequest, + signal: AbortSignal, + ): Promise { + if (request.kind !== "device_a_auth_invite") { + return await super.protectedDocument(request, signal); + } + markReadStarted(); + return await new Promise((_resolve, rejectPromise) => { + const abort = () => rejectPromise(new Error("operator_interrupted")); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + }); + } + } + const controller = new AbortController(); + const started = startFakeScenario(new FakeWorld(), new BlockingOperator(), { + signal: controller.signal, + }); + await readStarted; + controller.abort(); + await expect(started.promise).rejects.toThrow("operator_interrupted"); + expect(started.devices.a.calls.filter((argv) => + argv[0] === "auth" && argv[1] === "login")).toHaveLength(0); + }); + + test("interrupts an account-status poll without starting another effect", async () => { + const world = new FakeWorld(); + world.accountLoginPending = true; + const controller = new AbortController(); + const started = startFakeScenario(world, new FakeOperator(), { + signal: controller.signal, + sleep: async () => { controller.abort(); }, + }); + await expect(started.promise).rejects.toThrow("operator_interrupted"); + expect(started.devices.a.calls.filter((argv) => + argv[0] === "account" && argv[1] === "show")).toHaveLength(1); + }); + + test("requires an explicit canonical candidate origin and explicit operator mode", () => { + expect(liveAcceptanceScenarioConfigurationSchema.safeParse({ + cloudDeploymentUrl: "https://EXAMPLE.convex.cloud/", + operator: { kind: "terminal" }, + version: 1, + }).success).toBe(false); + for (const cloudDeploymentUrl of [ + "http://127.0.0.1:3210", + "https://wrong-candidate.convex.cloud", + ]) { + expect(liveAcceptanceScenarioConfigurationSchema.safeParse({ + cloudDeploymentUrl, + operator: { kind: "jsonl" }, + version: 1, + }).success).toBe(false); + } + expect(liveAcceptanceScenarioConfigurationSchema.safeParse({ + operator: { kind: "terminal" }, + version: 1, + }).success).toBe(false); + expect(liveAcceptanceScenarioConfigurationSchema.parse({ + cloudDeploymentUrl: DEFAULT_CLOUD_DEPLOYMENT_URL, + operator: { kind: "jsonl" }, + version: 1, + })).toEqual({ + cloudDeploymentUrl: DEFAULT_CLOUD_DEPLOYMENT_URL, + operator: { kind: "jsonl" }, + version: 1, + }); + }); + + test("the executable refuses to start workers without an explicit scenario descriptor", async () => { + const child = Bun.spawn([ + process.execPath, + join(import.meta.dir, "live-acceptance.ts"), + ], { + cwd: join(import.meta.dir, ".."), + stderr: "pipe", + stdout: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(exitCode).toBe(2); + expect(stdout).toBe(""); + expect(stderr).toContain("explicit candidate configuration"); + }); +}); diff --git a/scripts/live-acceptance-scenario.ts b/scripts/live-acceptance-scenario.ts new file mode 100644 index 0000000..a1c829a --- /dev/null +++ b/scripts/live-acceptance-scenario.ts @@ -0,0 +1,1839 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createWriteStream } from "node:fs"; +import { readSync } from "node:fs"; +import { Socket } from "node:net"; +import type { Readable, Writable } from "node:stream"; +import { Writable as WritableStream } from "node:stream"; +import { createInterface } from "node:readline/promises"; +import { isatty } from "node:tty"; + +import { z } from "zod"; + +import { + canonicalCloudDeploymentUrl, + DEFAULT_CLOUD_DEPLOYMENT_URL, +} from "../src/cloud/identity-custody"; +import { publicInteractionSchema, type PublicInteraction } from "../src/domain/interactions"; +import { sessionEventPageSchema, type SessionEvent } from "../src/domain/session-events"; +import type { + LiveAcceptanceCliResult, + LiveAcceptanceDevice, + LiveAcceptanceDeviceName, + LiveAcceptanceRun, +} from "./live-acceptance"; + +const protectedOperatorInputFd = 4; +const operatorOutputFd = 5; +const scenarioConfigurationMaximumBytes = 8 * 1024; +const operatorFrameMaximumBytes = 64 * 1024; +const accountLoginDeadlineMs = 10 * 60 * 1_000; +const turnDeadlineMs = 15 * 60 * 1_000; +const remoteCommandDeadlineMs = 10 * 60 * 1_000; +const presenceOfflineBoundaryMs = 45_000; +const presenceObservationMarginMs = 2_000; +const defaultPollIntervalMs = 1_000; + +const canonicalCandidateUrlSchema = z.literal(DEFAULT_CLOUD_DEPLOYMENT_URL).refine((value) => { + try { + return canonicalCloudDeploymentUrl(value) === value; + } catch { + return false; + } +}, "The live release gate must target the exact compiled candidate cloud authority."); + +export const liveAcceptanceScenarioConfigurationSchema = z.object({ + cloudDeploymentUrl: canonicalCandidateUrlSchema, + operator: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("terminal") }).strict(), + z.object({ kind: z.literal("jsonl") }).strict(), + ]), + version: z.literal(1), +}).strict(); + +export type LiveAcceptanceScenarioConfiguration = z.infer< + typeof liveAcceptanceScenarioConfigurationSchema +>; + +export type LiveAcceptanceOperatorRequest = Readonly<{ + context?: unknown; + kind: + | "device_a_auth_code" + | "device_a_auth_invite" + | "device_b_auth_code" + | "device_b_auth_email" + | "permission_grant" + | "user_answers"; + prompt: string; +}>; + +export interface LiveAcceptanceScenarioOperator { + acknowledgeDeviceLogin(input: Readonly<{ + accountLabel: string; + userCode: string; + verificationUrl: string; + }>, signal: AbortSignal): Promise; + progress(step: string): void; + protectedDocument( + request: LiveAcceptanceOperatorRequest, + signal: AbortSignal, + ): Promise; +} + +type ScenarioRun = Pick< + LiveAcceptanceRun, + "bindExpectedRevokedPeer" | "cleanup" | "device" | "runId" +>; + +type ScenarioTiming = Readonly<{ + accountLoginDeadlineMs?: number; + now?: () => number; + pollIntervalMs?: number; + presenceObservationMarginMs?: number; + remoteCommandDeadlineMs?: number; + signal?: AbortSignal; + sleep?: (milliseconds: number) => Promise; + turnDeadlineMs?: number; +}>; + +export type LiveAcceptanceEvidence = Readonly<{ + accountIds: readonly [string, string]; + cloudTargetDigest: string; + completedAt: number; + devicePublicIds: readonly [string, string]; + eventKinds: Readonly>; + markerDigests: readonly [string, string, string]; + packageVersion: string; + pluginLifecycleEffectsRejected: readonly ["auth", "disable", "enable", "install"]; + pluginInstallRejected: true; + presence: readonly ["online", "offline", "online"]; + providerIdentitiesDistinct: true; + remoteCommand: Readonly<{ resultCode: "APPLIED"; state: "applied" }>; + runId: string; + sessionIds: readonly [string, string]; + sourceRevision: string; + startedAt: number; + status: "passed"; + version: 1; +}>; + +export type LiveAcceptanceScenarioAttestation = Readonly<{ + cloudTargetDigest: string; + packageVersion: string; + sourceRevision: string; +}>; + +const scenarioAttestationSchema = z.object({ + cloudTargetDigest: z.string().regex(/^[a-f0-9]{64}$/u), + packageVersion: z.string().min(1).max(128), + sourceRevision: z.string().regex(/^[a-f0-9]{40}$/u), +}).strict(); + +const evidenceDigestSchema = z.string().regex(/^[a-f0-9]{64}$/u); +const evidenceIdSchema = z.string().min(1).max(200); +const evidenceTimestampSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); + +const liveAcceptanceEvidenceSchema: z.ZodType = z.object({ + accountIds: z.tuple([evidenceIdSchema, evidenceIdSchema]), + cloudTargetDigest: evidenceDigestSchema, + completedAt: evidenceTimestampSchema, + devicePublicIds: z.tuple([evidenceIdSchema, evidenceIdSchema]), + eventKinds: z.record( + evidenceIdSchema, + z.array(z.string().min(1).max(128)).min(1).max(32), + ), + markerDigests: z.tuple([evidenceDigestSchema, evidenceDigestSchema, evidenceDigestSchema]), + packageVersion: z.string().min(1).max(128), + pluginLifecycleEffectsRejected: z.tuple([ + z.literal("auth"), + z.literal("disable"), + z.literal("enable"), + z.literal("install"), + ]), + pluginInstallRejected: z.literal(true), + presence: z.tuple([z.literal("online"), z.literal("offline"), z.literal("online")]), + providerIdentitiesDistinct: z.literal(true), + remoteCommand: z.object({ + resultCode: z.literal("APPLIED"), + state: z.literal("applied"), + }).strict(), + runId: z.string().uuid(), + sessionIds: z.tuple([evidenceIdSchema, evidenceIdSchema]), + sourceRevision: z.string().regex(/^[a-f0-9]{40}$/u), + startedAt: evidenceTimestampSchema, + status: z.literal("passed"), + version: z.literal(1), +}).strict().superRefine((evidence, context) => { + const distinctPairs = [ + evidence.accountIds, + evidence.devicePublicIds, + evidence.sessionIds, + ]; + if ( + evidence.completedAt < evidence.startedAt + || distinctPairs.some(([left, right]) => left === right) + || Object.keys(evidence.eventKinds).length !== 2 + || evidence.sessionIds.some((sessionId) => !(sessionId in evidence.eventKinds)) + ) context.addIssue({ code: "custom", message: "Live acceptance evidence is incoherent." }); +}); + +class ScenarioFailure extends Error { + constructor(readonly code: string) { + super(code); + this.name = "ScenarioFailure"; + } +} + +const sha256 = (value: string): string => + createHash("sha256").update(value, "utf8").digest("hex"); + +const record = (value: unknown, label: string): Record => { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new ScenarioFailure(`${label}_invalid`); + } + return value as Record; +}; + +const requiredString = (value: unknown, label: string): string => { + if (typeof value !== "string" || value.length === 0 || value.length > 4_096) { + throw new ScenarioFailure(`${label}_invalid`); + } + return value; +}; + +const safeDeviceUserCode = (value: unknown): string => { + const code = requiredString(value, "device_user_code"); + if (!/^[A-Z0-9]{4,12}(?:-[A-Z0-9]{4,12}){0,2}$/u.test(code)) { + throw new ScenarioFailure("device_user_code_invalid"); + } + return code; +}; + +const safeDeviceVerificationUrl = (value: unknown): string => { + const source = requiredString(value, "device_verification_url"); + if (/\p{Cc}|\p{Cf}/u.test(source)) { + throw new ScenarioFailure("device_verification_url_invalid"); + } + let parsed: URL; + try { + parsed = new URL(source); + } catch { + throw new ScenarioFailure("device_verification_url_invalid"); + } + if ( + parsed.protocol !== "https:" + || parsed.username !== "" + || parsed.password !== "" + || parsed.hostname === "" + ) throw new ScenarioFailure("device_verification_url_invalid"); + return parsed.href; +}; + +const throwIfAborted = (signal: AbortSignal): void => { + if (signal.aborted) throw new ScenarioFailure("operator_interrupted"); +}; + +const abortable = async (operation: () => Promise, signal: AbortSignal): Promise => { + throwIfAborted(signal); + return await new Promise((resolvePromise, rejectPromise) => { + const abort = () => settle(false, new ScenarioFailure("operator_interrupted")); + const settle = (ok: boolean, value: unknown): void => { + signal.removeEventListener("abort", abort); + if (ok) resolvePromise(value as T); + else rejectPromise(value); + }; + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + else void operation().then( + (value) => settle(true, value), + (error: unknown) => settle(false, error), + ); + }); +}; + +const cancellableDevice = ( + device: LiveAcceptanceDevice, + signal: AbortSignal, +): LiveAcceptanceDevice => ({ + device: device.device, + execute: async (argv, options) => await abortable( + async () => await device.execute(argv, options), + signal, + ), + projectDirectory: device.projectDirectory, + resume: async () => await abortable(async () => await device.resume(), signal), + suspend: async () => await abortable(async () => await device.suspend(), signal), +}); + +type JsonCliEnvelope = Readonly<{ + command?: string; + data?: unknown; + error?: unknown; + ok: boolean; + version: 1; +}>; + +const parseJsonCliEnvelope = (result: LiveAcceptanceCliResult): JsonCliEnvelope => { + if (result.stderr !== "") throw new ScenarioFailure("cli_json_stderr_nonempty"); + const source = result.stdout.trim(); + if (source.length === 0 || source.includes("\n")) { + throw new ScenarioFailure("cli_json_frame_invalid"); + } + let value: unknown; + try { + value = JSON.parse(source) as unknown; + } catch { + throw new ScenarioFailure("cli_json_invalid"); + } + const root = record(value, "cli_envelope"); + if (root.version !== 1 || typeof root.ok !== "boolean") { + throw new ScenarioFailure("cli_envelope_invalid"); + } + return root as JsonCliEnvelope; +}; + +const executeJson = async ( + device: LiveAcceptanceDevice, + argv: readonly string[], + options?: Readonly<{ protectedDocument?: unknown }>, +): Promise => { + if (!argv.includes("--json")) throw new ScenarioFailure("json_flag_missing"); + const result = await device.execute(argv, options); + const envelope = parseJsonCliEnvelope(result); + if (result.exitCode !== 0 || !envelope.ok || envelope.data === undefined) { + throw new ScenarioFailure(`cli_${argv[0] ?? "unknown"}_failed`); + } + const expectedCommand = argv[0] === "interaction" + && ["answer", "decide", "grant", "submit"].includes(argv[1] ?? "") + ? "interaction.resolve" + : `${argv[0] ?? ""}.${argv[1] ?? ""}`; + if (envelope.command !== expectedCommand) { + throw new ScenarioFailure("cli_command_mismatch"); + } + return envelope.data; +}; + +const executeJsonFailure = async ( + device: LiveAcceptanceDevice, + argv: readonly string[], + expected: Readonly<{ code: "INVALID_INPUT" | "UNAVAILABLE"; exitCode?: number }>, +): Promise => { + if (!argv.includes("--json")) throw new ScenarioFailure("json_flag_missing"); + const result = await device.execute(argv); + const envelope = parseJsonCliEnvelope(result); + const error = envelope.error === undefined ? null : record(envelope.error, "cli_error"); + if ( + result.exitCode === 0 + || envelope.ok + || error?.code !== expected.code + || (expected.exitCode !== undefined && result.exitCode !== expected.exitCode) + ) { + throw new ScenarioFailure(`cli_${argv[0] ?? "unknown"}_unexpected_success`); + } +}; + +const pollUntil = async (input: Readonly<{ + deadlineMs: number; + now: () => number; + operation: () => Promise; + pollIntervalMs: number; + signal: AbortSignal; + sleep: (milliseconds: number) => Promise; +}>): Promise => { + const deadline = input.now() + input.deadlineMs; + for (;;) { + throwIfAborted(input.signal); + const result = await abortable(input.operation, input.signal); + if (result !== null) return result; + if (input.now() >= deadline) throw new ScenarioFailure("poll_deadline_exceeded"); + await abortable(async () => await input.sleep(input.pollIntervalMs), input.signal); + } +}; + +type ProtectedAuthKind = Extract< + LiveAcceptanceOperatorRequest["kind"], + `${string}_auth_${string}` +>; + +const protectedAuthDocumentSchema = z.object({ + email: z.string().email().min(3).max(320), +}).passthrough(); + +const protectedAuth = async ( + device: LiveAcceptanceDevice, + operator: LiveAcceptanceScenarioOperator, + kind: ProtectedAuthKind, + prompt: string, + signal: AbortSignal, + expectedEmailDigest?: string, +): Promise> => { + const document = await operator.protectedDocument({ kind, prompt }, signal); + const parsed = protectedAuthDocumentSchema.parse(document); + const emailDigest = sha256(parsed.email.trim().toLowerCase()); + if (expectedEmailDigest !== undefined && emailDigest !== expectedEmailDigest) { + throw new ScenarioFailure("protected_auth_identity_changed"); + } + const data = await executeJson( + device, + ["auth", "login", "--input-fd", String(protectedOperatorInputFd), "--json"], + { protectedDocument: document }, + ); + return { data, emailDigest }; +}; + +const accountSchema = z.object({ + id: z.string().min(1).max(200), + providerEmail: z.string().email().optional(), + providerPlan: z.string().min(1).max(200).optional(), + state: z.enum(["signed_out", "login_pending", "signed_in", "recovery_required", "removed"]), +}).passthrough(); + +const addAccount = async ( + device: LiveAcceptanceDevice, + label: string, +): Promise => { + const data = record(await executeJson( + device, + ["account", "add", label, "--json"], + ), "account_add"); + return accountSchema.parse(data.account).id; +}; + +const loginAccount = async (input: Readonly<{ + accountId: string; + accountLabel: string; + deadlineMs: number; + device: LiveAcceptanceDevice; + now: () => number; + operator: LiveAcceptanceScenarioOperator; + pollIntervalMs: number; + signal: AbortSignal; + sleep: (milliseconds: number) => Promise; +}>): Promise> => { + const started = record(await executeJson(input.device, [ + "account", + "login", + input.accountId, + "--device-code", + "--json", + ]), "account_login"); + const login = record(started.login, "account_login_handoff"); + if (login.status !== "pending") throw new ScenarioFailure("device_login_not_pending"); + await input.operator.acknowledgeDeviceLogin({ + accountLabel: input.accountLabel, + userCode: safeDeviceUserCode(login.userCode), + verificationUrl: safeDeviceVerificationUrl(login.verificationUrl), + }, input.signal); + return await pollUntil({ + deadlineMs: input.deadlineMs, + now: input.now, + operation: async () => { + const shown = record(await executeJson( + input.device, + ["account", "show", input.accountId, "--json"], + ), "account_show"); + const account = accountSchema.parse(shown.account); + if (account.state === "recovery_required" || account.state === "removed") { + throw new ScenarioFailure("account_login_recovery_required"); + } + return account.state === "signed_in" ? account : null; + }, + pollIntervalMs: input.pollIntervalMs, + signal: input.signal, + sleep: input.sleep, + }); +}; + +const pairSchema = z.object({ + device: z.object({ + publicId: z.string().min(1).max(200), + status: z.enum(["pending", "active", "revoked"]), + }).passthrough(), + paired: z.boolean(), +}).passthrough(); + +const deviceListScenarioSchema = z.object({ + currentDevicePublicId: z.string().min(1).max(200), + devices: z.array(z.object({ + current: z.boolean(), + online: z.boolean(), + publicId: z.string().min(1).max(200), + status: z.enum(["pending", "active", "revoked"]), + }).passthrough()).min(1).max(1_024), +}).passthrough(); + +const projectSchema = z.object({ + id: z.string().min(1).max(200), +}).passthrough(); + +const addProject = async (device: LiveAcceptanceDevice): Promise => { + const data = record(await executeJson(device, [ + "project", + "add", + "--path", + device.projectDirectory, + "--name", + `Acceptance ${device.device.toUpperCase()}`, + "--json", + ]), "project_add"); + return projectSchema.parse(data.project).id; +}; + +const startSession = async ( + device: LiveAcceptanceDevice, + accountId: string, + projectId: string, +): Promise => { + const data = record(await executeJson(device, [ + "session", + "start", + accountId, + "--project", + projectId, + "--preset", + "high", + "--json", + ]), "session_start"); + const session = record(data.session, "session_start_session"); + return requiredString(session.id, "session_id"); +}; + +const sendSessionTurn = async ( + device: LiveAcceptanceDevice, + sessionId: string, + message: string, +): Promise => { + const data = record(await executeJson(device, [ + "session", + "send", + sessionId, + message, + "--json", + ]), "session_send"); + const session = record(data.session, "session_send_session"); + if (session.id !== sessionId) throw new ScenarioFailure("session_send_identity_changed"); + return requiredString(data.turnId, "session_turn_id"); +}; + +const assertObservedUsage = (value: unknown, accountId: string): Readonly<{ + observedAt: number; + sourceRevision: number; +}> => { + const parsed = z.object({ + usage: z.array(z.object({ + account: z.object({ id: z.literal(accountId) }).passthrough(), + poll: z.object({ + observedAt: z.number().int().nonnegative(), + sourceRevision: z.number().int().positive(), + state: z.literal("observed"), + }).passthrough(), + snapshot: z.object({ + observedAt: z.number().int().nonnegative(), + sourceRevision: z.number().int().positive(), + }).passthrough(), + }).passthrough()).length(1), + }).passthrough().parse(value); + const observation = parsed.usage[0]; + if ( + observation === undefined + || observation.poll.sourceRevision !== observation.snapshot.sourceRevision + ) throw new ScenarioFailure("account_usage_observation_invalid"); + return { + observedAt: observation.snapshot.observedAt, + sourceRevision: observation.snapshot.sourceRevision, + }; +}; + +const assertLocalAssistantMarker = ( + value: unknown, + turnId: string, + marker: string, +): void => { + const shown = z.object({ + projection: z.object({ + messages: z.array(z.object({ + role: z.enum(["assistant", "user"]), + text: z.string().max(64_000), + turnId: z.string().min(1).max(512), + }).passthrough()).max(100), + }).passthrough(), + }).passthrough().parse(value); + const assistant = shown.projection.messages.filter((message) => + message.role === "assistant" && message.turnId === turnId); + if ( + assistant.length === 0 + || assistant.reduce( + (count, message) => count + markerOccurrences(message.text, marker), + 0, + ) !== 1 + ) throw new ScenarioFailure("session_marker_missing"); +}; + +const remoteProjectionEventSchema = z.object({ + kind: z.string().min(1).max(128), + sequence: z.number().int().positive(), + text: z.string().max(64_000).optional(), + turnId: z.string().min(1).max(512).optional(), +}).passthrough(); + +const remoteProjectionSchema = z.object({ + compactHasRecoveryGap: z.literal(false), + complete: z.literal(true), + executionDevicePublicId: z.string().min(1).max(200), + events: z.array(remoteProjectionEventSchema).max(10_000), + publicId: z.string().min(1).max(200), + recoveryGap: z.undefined().optional(), +}).passthrough(); + +const remoteProjectionEvents = (value: unknown): readonly z.infer< + typeof remoteProjectionEventSchema +>[] => { + const projection = remoteProjectionSchema.parse(value); + if (projection.events.some((event, index) => + index > 0 && event.sequence <= (projection.events[index - 1]?.sequence ?? -1))) { + throw new ScenarioFailure("remote_projection_unordered"); + } + return projection.events; +}; + +const assertRemoteProjectionIdentity = ( + value: unknown, + sessionId: string, + targetDevicePublicId: string, +): void => { + const projection = remoteProjectionSchema.parse(value); + if ( + projection.publicId !== sessionId + || projection.executionDevicePublicId !== targetDevicePublicId + ) throw new ScenarioFailure("remote_projection_identity_changed"); + remoteProjectionEvents(projection); +}; + +const assertRemoteAssistantMarker = ( + value: unknown, + marker: string, + sessionId: string, + targetDevicePublicId: string, + turnId?: string, +): void => { + assertRemoteProjectionIdentity(value, sessionId, targetDevicePublicId); + const assistant = remoteProjectionEvents(value).filter((event) => + event.kind === "assistant_message" + && (turnId === undefined || event.turnId === turnId)); + if ( + assistant.reduce( + (count, event) => count + markerOccurrences(event.text ?? "", marker), + 0, + ) !== 1 + ) throw new ScenarioFailure("remote_assistant_marker_missing"); +}; + +const remoteCommandTurnSettled = ( + value: unknown, + marker: string, + sessionId: string, + targetDevicePublicId: string, +): boolean => { + assertRemoteProjectionIdentity(value, sessionId, targetDevicePublicId); + const events = remoteProjectionEvents(value); + const submitted = events.filter((event) => + event.kind === "user_message" && markerOccurrences(event.text ?? "", marker) === 1); + if (submitted.length === 0) return false; + if (submitted.length !== 1 || submitted[0]?.turnId === undefined) { + throw new ScenarioFailure("remote_command_submission_missing"); + } + const turnId = submitted[0].turnId; + const assistant = events.filter((event) => + event.kind === "assistant_message" && event.turnId === turnId); + const markerCount = assistant.reduce( + (count, event) => count + markerOccurrences(event.text ?? "", marker), + 0, + ); + if (markerCount === 0) return false; + if (markerCount !== 1) throw new ScenarioFailure("remote_assistant_marker_missing"); + const summaries = events.filter((event) => + event.kind === "turn_summary" && event.turnId === turnId); + if (summaries.length === 0) return false; + if (summaries.length !== 1) throw new ScenarioFailure("remote_turn_terminal_ambiguous"); + const summary = summaries[0]; + if (summary === undefined) throw new ScenarioFailure("remote_turn_terminal_ambiguous"); + const assistantLastSequence = Math.max(...assistant.map((event) => event.sequence)); + if ( + submitted[0].sequence >= assistantLastSequence + || assistantLastSequence >= summary.sequence + ) throw new ScenarioFailure("remote_turn_terminal_unordered"); + return true; +}; + +const remoteCommandBindingSchema = z.object({ + commandPublicId: z.string().min(1).max(200), + kind: z.literal("send"), + sessionPublicId: z.string().min(1).max(200), + state: z.enum([ + "pending", + "prepared", + "effect_started", + "applied", + "failed", + "ambiguous", + "cancelled", + "expired", + ]), + targetDevicePublicId: z.string().min(1).max(200), +}).passthrough(); + +const containsPathBearingKey = (value: unknown): boolean => { + if (Array.isArray(value)) return value.some(containsPathBearingKey); + if (value === null || typeof value !== "object") return false; + return Object.entries(value).some(([key, nested]) => + /cwd|path|root/iu.test(key) || containsPathBearingKey(nested)); +}; + +const assertPluginCatalog = (value: unknown, accountId: string): void => { + const parsed = z.object({ + account: z.object({ id: z.literal(accountId), state: z.literal("signed_in") }).passthrough(), + catalog: z.object({ + lifecycle: z.object({ + discovery: z.literal("available"), + enablement: z.literal("no_separate_pinned_method"), + install: z.literal("blocked_compound_upstream_effect"), + oauth: z.literal("separate_foreground_only"), + }).strict(), + marketplaceLoadErrorCount: z.number().int().nonnegative().max(100), + marketplaces: z.array(z.object({ + plugins: z.array(z.object({ id: z.string().min(1).max(512) }).passthrough()).max(5_000), + }).passthrough()).max(100), + }).passthrough(), + }).passthrough().parse(value); + if (containsPathBearingKey(parsed.catalog)) { + throw new ScenarioFailure("plugin_catalog_path_exposed"); + } +}; + +const pendingInteraction = async ( + device: LiveAcceptanceDevice, + sessionId: string, + turnId: string, + kind: "permission_approval" | "user_input", +): Promise => { + const data = record(await executeJson(device, [ + "interaction", + "list", + sessionId, + "--pending", + "--limit", + "20", + "--json", + ]), "interaction_list"); + const values = z.array(publicInteractionSchema).max(20).parse(data.interactions); + const candidates = values.filter((interaction) => + interaction.kind === kind && interaction.sessionId === sessionId); + if (candidates.length === 0) return null; + const interaction = candidates[0]; + if ( + candidates.length !== 1 + || interaction === undefined + || interaction.context.turnId !== turnId + || interaction.state !== "pending" + || !interaction.blocking + || interaction.responseRecorded + || interaction.terminalAt !== null + ) throw new ScenarioFailure("interaction_authority_changed"); + return interaction; +}; + +const interactionResolutionResultSchema = z.object({ + interaction: publicInteractionSchema, + responseWritten: z.literal(true), +}).strict(); + +const assertInteractionResponseWritten = ( + value: unknown, + pending: PublicInteraction, +): PublicInteraction => { + const result = interactionResolutionResultSchema.parse(value); + const written = result.interaction; + if ( + written.id !== pending.id + || written.sessionId !== pending.sessionId + || written.kind !== pending.kind + || written.context.turnId !== pending.context.turnId + || written.context.itemId !== pending.context.itemId + || written.state !== "response_written" + || written.revision !== pending.revision + 2 + || !written.blocking + || !written.responseRecorded + || written.terminalAt !== null + ) throw new ScenarioFailure("interaction_response_unproven"); + return written; +}; + +const resolveUserInput = async ( + device: LiveAcceptanceDevice, + interaction: PublicInteraction, + operator: LiveAcceptanceScenarioOperator, + signal: AbortSignal, +): Promise => { + if (interaction.display.kind !== "user_input") throw new ScenarioFailure("user_input_invalid"); + const context = { + questions: interaction.display.questions.map((question) => ({ + allowsOther: question.allowsOther, + id: question.id, + options: question.options, + question: question.question, + secret: question.secret, + })), + }; + const result = await executeJson(device, [ + "interaction", + "answer", + interaction.id, + "--revision", + String(interaction.revision), + "--input-fd", + String(protectedOperatorInputFd), + "--json", + ], { + protectedDocument: await operator.protectedDocument({ + context, + kind: "user_answers", + prompt: "Provide exactly {answers:{:{answers:[...]}}} for the displayed question IDs.", + }, signal), + }); + return assertInteractionResponseWritten(result, interaction); +}; + +const resolvePermission = async ( + device: LiveAcceptanceDevice, + interaction: PublicInteraction, + operator: LiveAcceptanceScenarioOperator, + signal: AbortSignal, +): Promise => { + if (interaction.display.kind !== "permission_approval") { + throw new ScenarioFailure("permission_interaction_invalid"); + } + const requested = interaction.display.requested.map((permission) => permission.name); + if (requested.length === 0) throw new ScenarioFailure("permission_interaction_empty"); + const result = await executeJson(device, [ + "interaction", + "grant", + interaction.id, + "--revision", + String(interaction.revision), + "--scope", + "turn", + "--input-fd", + String(protectedOperatorInputFd), + "--json", + ], { + protectedDocument: await operator.protectedDocument({ + context: { requested }, + kind: "permission_grant", + prompt: "Provide exactly {permissions:[...]} using a non-empty subset of the displayed names.", + }, signal), + }); + return assertInteractionResponseWritten(result, interaction); +}; + +type SessionEvidence = Readonly<{ + eventKinds: readonly string[]; +}>; + +const markerOccurrences = (source: string, marker: string): number => { + let count = 0; + let offset = 0; + for (;;) { + const next = source.indexOf(marker, offset); + if (next < 0) return count; + count += 1; + offset = next + marker.length; + } +}; + +const collectSettledSessionEvidence = async (input: Readonly<{ + accountId: string; + deadlineMs: number; + device: LiveAcceptanceDevice; + marker: string; + now: () => number; + pollIntervalMs: number; + interaction: Readonly<{ + id: string; + kind: "permission_approval" | "user_input"; + pendingRevision: number; + writtenRevision: number; + }>; + sessionId: string; + signal: AbortSignal; + sleep: (milliseconds: number) => Promise; + turnId: string; +}>): Promise => { + const observed = new Map(); + let cursor: string | undefined; + let lastSequence = 0; + let sawNonTerminalPoll = false; + return await pollUntil({ + deadlineMs: input.deadlineMs, + now: input.now, + operation: async () => { + const requestedCursor = cursor; + const argv = [ + "session", + "events", + input.sessionId, + "--limit", + "200", + "--wait-ms", + "1000", + ...(requestedCursor === undefined ? [] : ["--cursor", requestedCursor]), + "--json", + ]; + const page = sessionEventPageSchema.parse(await executeJson(input.device, argv)); + if ( + page.sessionId !== input.sessionId + || page.requestedCursor !== (requestedCursor ?? null) + || page.gap !== null + ) { + throw new ScenarioFailure("session_event_cursor_invalid"); + } + for (const event of page.events) { + if ( + event.sequence !== lastSequence + 1 + || event.sessionId !== input.sessionId + || event.accountId !== input.accountId + || event.body.type === "gap" + || event.body.type === "error" + || event.body.type === "protocol_incompatible" + ) { + throw new ScenarioFailure("session_events_unordered"); + } + lastSequence = event.sequence; + observed.set(event.sequence, event); + } + cursor = page.nextCursor; + const events = [...observed.values()].sort((left, right) => left.sequence - right.sequence); + const turnEvents = events.filter((event) => + "turnId" in event.body && event.body.turnId === input.turnId); + const terminalEvents = turnEvents.filter((event) => event.body.type === "turn_completed"); + if (terminalEvents.length === 0) { + sawNonTerminalPoll = true; + return null; + } + const terminalEvent = terminalEvents[0]; + if ( + terminalEvents.length !== 1 + || terminalEvent === undefined + || terminalEvent.body.type !== "turn_completed" + || terminalEvent.body.status !== "completed" + || terminalEvent.sequence !== turnEvents.at(-1)?.sequence + ) throw new ScenarioFailure("session_terminal_invalid"); + const turnStarts = turnEvents.filter((event) => event.body.type === "turn_started"); + if ( + turnStarts.length !== 1 + || turnStarts[0]?.sequence !== turnEvents[0]?.sequence + ) throw new ScenarioFailure("session_turn_start_invalid"); + + const interactionRequested = events.filter((event) => { + const body = event.body; + return body.type === "interaction_requested" + && body.interactionId === input.interaction.id; + }); + const interactionPrepared = events.filter((event) => { + const body = event.body; + return body.type === "interaction_state" + && body.interactionId === input.interaction.id + && body.state === "response_prepared"; + }); + const interactionWritten = events.filter((event) => { + const body = event.body; + return body.type === "interaction_state" + && body.interactionId === input.interaction.id + && body.state === "response_written"; + }); + const requestedBody = interactionRequested[0]?.body; + const preparedBody = interactionPrepared[0]?.body; + const writtenBody = interactionWritten[0]?.body; + const turnStart = turnStarts[0]; + const requestedEvent = interactionRequested[0]; + const preparedEvent = interactionPrepared[0]; + const writtenEvent = interactionWritten[0]; + if ( + interactionRequested.length !== 1 + || interactionPrepared.length !== 1 + || interactionWritten.length !== 1 + || requestedBody?.type !== "interaction_requested" + || requestedBody.interactionKind !== input.interaction.kind + || !requestedBody.blocking + || requestedBody.revision !== input.interaction.pendingRevision + || preparedBody?.type !== "interaction_state" + || preparedBody.revision !== input.interaction.pendingRevision + 1 + || writtenBody?.type !== "interaction_state" + || writtenBody.revision !== input.interaction.writtenRevision + || turnStart === undefined + || requestedEvent === undefined + || preparedEvent === undefined + || writtenEvent === undefined + ) throw new ScenarioFailure("session_interaction_evidence_incomplete"); + if ( + turnStart.sequence >= requestedEvent.sequence + || requestedEvent.sequence >= preparedEvent.sequence + || preparedEvent.sequence >= writtenEvent.sequence + || writtenEvent.sequence >= terminalEvent.sequence + ) throw new ScenarioFailure("session_interaction_evidence_incomplete"); + + const authority = turnEvents[0]; + const authorityEvents = [ + ...turnEvents, + ...interactionRequested, + ...interactionPrepared, + ...interactionWritten, + ]; + if ( + authority === undefined + || authority.providerConnectionId === null + || authorityEvents.some((event) => + event.streamEpoch !== authority.streamEpoch + || event.providerGeneration !== authority.providerGeneration + || event.providerConnectionId !== authority.providerConnectionId) + ) throw new ScenarioFailure("session_event_authority_changed"); + + const reasoning = turnEvents.some((event) => + event.body.type === "reasoning_summary_delta" && event.body.text.length > 0); + const commandStarts = turnEvents.filter((event) => + event.body.type === "item_started" && event.body.itemKind === "commandExecution"); + const commandItem = commandStarts.find((started) => { + if (started.body.type !== "item_started") return false; + const commandItemId = started.body.itemId; + const progress = turnEvents.find((event) => { + const body = event.body; + return body.type === "tool_progress" + && body.itemId === commandItemId + && body.toolKind === "command" + && (body.outputBytesObserved ?? 0) > 0; + }); + const completed = turnEvents.find((event) => { + const body = event.body; + return body.type === "item_completed" + && body.itemId === commandItemId + && body.itemKind === "commandExecution" + && body.status === "completed"; + }); + return progress !== undefined + && completed !== undefined + && started.sequence < progress.sequence + && progress.sequence < completed.sequence + && completed.sequence < terminalEvent.sequence; + }); + const assistantText = turnEvents + .filter((event): event is SessionEvent & { body: Extract } => + event.body.type === "assistant_delta") + .map((event) => event.body.text) + .join(""); + if ( + !sawNonTerminalPoll + || !reasoning + || commandItem === undefined + || markerOccurrences(assistantText, input.marker) !== 1 + ) { + throw new ScenarioFailure("session_stream_evidence_incomplete"); + } + const eventKinds = [...new Set(authorityEvents.map((event) => event.body.type))].sort(); + return { eventKinds }; + }, + pollIntervalMs: input.pollIntervalMs, + signal: input.signal, + sleep: input.sleep, + }); +}; + +const assertDeviceAListAuthority = ( + list: z.infer, + deviceAPublicId: string, + deviceBPublicId: string, +): z.infer["devices"][number] => { + if (deviceAPublicId === deviceBPublicId) { + throw new ScenarioFailure("device_identity_ambiguous"); + } + const current = list.devices.filter((device) => device.current); + const distinctPublicIds = new Set(list.devices.map((device) => device.publicId)); + const deviceA = list.devices.filter((device) => device.publicId === deviceAPublicId); + const deviceB = list.devices.filter((device) => device.publicId === deviceBPublicId); + const currentDevice = current[0]; + const deviceARow = deviceA[0]; + const deviceBRow = deviceB[0]; + if ( + list.currentDevicePublicId !== deviceAPublicId + || list.devices.length !== 2 + || distinctPublicIds.size !== list.devices.length + || current.length !== 1 + || currentDevice === undefined + || currentDevice.publicId !== deviceAPublicId + || currentDevice.status !== "active" + || deviceA.length !== 1 + || deviceARow?.current !== true + || deviceB.length !== 1 + || deviceBRow?.current !== false + ) throw new ScenarioFailure("device_list_authority_changed"); + return deviceBRow; +}; + +export async function runLiveAcceptanceScenario( + run: ScenarioRun, + operator: LiveAcceptanceScenarioOperator, + attestationInput: LiveAcceptanceScenarioAttestation, + timing: ScenarioTiming = {}, +): Promise { + const attestation = scenarioAttestationSchema.parse(attestationInput); + const now = timing.now ?? Date.now; + const sleep = timing.sleep ?? (async (milliseconds: number) => { await Bun.sleep(milliseconds); }); + const pollIntervalMs = timing.pollIntervalMs ?? defaultPollIntervalMs; + const signal = timing.signal ?? new AbortController().signal; + throwIfAborted(signal); + const startedAt = now(); + const deviceA = cancellableDevice(run.device("a"), signal); + const deviceB = cancellableDevice(run.device("b"), signal); + + operator.progress("projects"); + const [projectA] = await Promise.all([addProject(deviceA), addProject(deviceB)]); + + operator.progress("device_a_auth"); + const identityA = await protectedAuth( + deviceA, + operator, + "device_a_auth_invite", + "Provide exactly {email,invite} for the one-time candidate identity invite.", + signal, + ); + await protectedAuth( + deviceA, + operator, + "device_a_auth_code", + "Provide exactly {email,code} for device A's email verification.", + signal, + identityA.emailDigest, + ); + const pairA = pairSchema.parse(await executeJson(deviceA, ["device", "pair", "--json"])); + if (!pairA.paired || pairA.device.status !== "active") { + throw new ScenarioFailure("device_a_pairing_failed"); + } + + operator.progress("codex_accounts"); + const accountA = await addAccount(deviceA, "Acceptance Primary"); + const signedInA = await loginAccount({ + accountId: accountA, + accountLabel: "Acceptance Primary", + deadlineMs: timing.accountLoginDeadlineMs ?? accountLoginDeadlineMs, + device: deviceA, + now, + operator, + pollIntervalMs, + signal, + sleep, + }); + const accountB = await addAccount(deviceA, "Acceptance Secondary"); + const signedInB = await loginAccount({ + accountId: accountB, + accountLabel: "Acceptance Secondary", + deadlineMs: timing.accountLoginDeadlineMs ?? accountLoginDeadlineMs, + device: deviceA, + now, + operator, + pollIntervalMs, + signal, + sleep, + }); + const providerEmailA = requiredString(signedInA.providerEmail, "primary_provider_email"); + const providerEmailB = requiredString(signedInB.providerEmail, "secondary_provider_email"); + if (providerEmailA.trim().toLowerCase() === providerEmailB.trim().toLowerCase()) { + throw new ScenarioFailure("provider_identities_not_distinct"); + } + const [usageA, usageB] = await Promise.all([ + executeJson(deviceA, ["account", "usage", accountA, "--refresh", "--json"]), + executeJson(deviceA, ["account", "usage", accountB, "--refresh", "--json"]), + ]); + assertObservedUsage(usageA, accountA); + assertObservedUsage(usageB, accountB); + + operator.progress("device_b_pending"); + await protectedAuth( + deviceB, + operator, + "device_b_auth_email", + "Provide exactly {email} for the same candidate identity on device B.", + signal, + identityA.emailDigest, + ); + await protectedAuth( + deviceB, + operator, + "device_b_auth_code", + "Provide exactly {email,code} for device B's email verification.", + signal, + identityA.emailDigest, + ); + const pendingPairB = pairSchema.parse(await executeJson(deviceB, ["device", "pair", "--json"])); + const deviceBPublicId = pendingPairB.device.publicId; + await run.bindExpectedRevokedPeer(deviceBPublicId); + throwIfAborted(signal); + if (pendingPairB.paired || pendingPairB.device.status !== "pending") { + throw new ScenarioFailure("device_b_not_pending"); + } + const listedPending = deviceListScenarioSchema.parse(await executeJson( + deviceA, + ["device", "list", "--json"], + )); + if (assertDeviceAListAuthority( + listedPending, + pairA.device.publicId, + deviceBPublicId, + ).status !== "pending") { + throw new ScenarioFailure("device_b_pending_not_visible"); + } + await executeJsonFailure(deviceB, ["sync", "now", "--json"], { code: "UNAVAILABLE" }); + await executeJsonFailure( + deviceB, + ["remote", "list", "--limit", "10", "--json"], + { code: "UNAVAILABLE" }, + ); + await executeJsonFailure(deviceB, [ + "remote", + "send", + `missing-${randomUUID()}`, + "pending devices cannot submit remote commands", + "--json", + ], { code: "UNAVAILABLE" }); + + operator.progress("device_b_approval"); + const approved = record(await executeJson( + deviceA, + ["device", "approve", deviceBPublicId, "--json"], + ), "device_approve"); + const approvedDevice = record(approved.device, "approved_device"); + if (approvedDevice.publicId !== deviceBPublicId || approvedDevice.status !== "active") { + throw new ScenarioFailure("device_b_approval_failed"); + } + const activePairB = pairSchema.parse(await executeJson(deviceB, ["device", "pair", "--json"])); + if (!activePairB.paired || activePairB.device.publicId !== deviceBPublicId || activePairB.device.status !== "active") { + throw new ScenarioFailure("device_b_pairing_failed"); + } + + operator.progress("sessions_and_interactions"); + const sessionA = await startSession(deviceA, accountA, projectA); + const sessionB = await startSession(deviceA, accountB, projectA); + const markerA = `hra-live-user-input-${randomUUID()}`; + const markerB = `hra-live-permission-${randomUUID()}`; + const turnA = await sendSessionTurn( + deviceA, + sessionA, + `Acceptance marker ${markerA}. Call request_user_input with one non-secret question whose ID is acceptance_choice. Wait for the answer, run /bin/echo hra-live-tool-progress with the shell tool, briefly summarize your reasoning, then reply with the marker exactly once.`, + ); + const userInput = await pollUntil({ + deadlineMs: timing.turnDeadlineMs ?? turnDeadlineMs, + now, + operation: async () => await pendingInteraction(deviceA, sessionA, turnA, "user_input"), + pollIntervalMs, + signal, + sleep, + }); + const writtenUserInput = await resolveUserInput(deviceA, userInput, operator, signal); + const sessionEvidencePromiseA = collectSettledSessionEvidence({ + accountId: accountA, + deadlineMs: timing.turnDeadlineMs ?? turnDeadlineMs, + device: deviceA, + interaction: { + id: userInput.id, + kind: "user_input", + pendingRevision: userInput.revision, + writtenRevision: writtenUserInput.revision, + }, + marker: markerA, + now, + pollIntervalMs, + sessionId: sessionA, + signal, + sleep, + turnId: turnA, + }); + void sessionEvidencePromiseA.catch(() => undefined); + + const turnB = await sendSessionTurn( + deviceA, + sessionB, + `Acceptance marker ${markerB}. Use the explicit permission-request mechanism to request the smallest additional permission before running /bin/echo hra-live-tool-progress. Wait for the grant, run that command with the shell tool, briefly summarize your reasoning, then reply with the marker exactly once.`, + ); + const permission = await pollUntil({ + deadlineMs: timing.turnDeadlineMs ?? turnDeadlineMs, + now, + operation: async () => await pendingInteraction( + deviceA, + sessionB, + turnB, + "permission_approval", + ), + pollIntervalMs, + signal, + sleep, + }); + const writtenPermission = await resolvePermission(deviceA, permission, operator, signal); + const sessionEvidencePromiseB = collectSettledSessionEvidence({ + accountId: accountB, + deadlineMs: timing.turnDeadlineMs ?? turnDeadlineMs, + device: deviceA, + interaction: { + id: permission.id, + kind: "permission_approval", + pendingRevision: permission.revision, + writtenRevision: writtenPermission.revision, + }, + marker: markerB, + now, + pollIntervalMs, + sessionId: sessionB, + signal, + sleep, + turnId: turnB, + }); + void sessionEvidencePromiseB.catch(() => undefined); + + const [sessionEvidenceA, sessionEvidenceB] = await Promise.all([ + sessionEvidencePromiseA, + sessionEvidencePromiseB, + ]); + const [shownA, shownB] = await Promise.all([ + executeJson(deviceA, ["session", "show", sessionA, "--detail", "--json"]), + executeJson(deviceA, ["session", "show", sessionB, "--detail", "--json"]), + ]); + assertLocalAssistantMarker(shownA, turnA, markerA); + assertLocalAssistantMarker(shownB, turnB, markerB); + + const pluginCatalog = await executeJson(deviceA, [ + "plugin", + "list", + accountA, + "--project", + projectA, + "--refresh", + "--json", + ]); + assertPluginCatalog(pluginCatalog, accountA); + for (const action of ["auth", "disable", "enable", "install"] as const) { + await executeJsonFailure( + deviceA, + ["plugin", action, "acceptance-probe", "--json"], + { code: "INVALID_INPUT", exitCode: 2 }, + ); + } + + operator.progress("sync_and_remote"); + await executeJson(deviceA, ["sync", "now", "--json"]); + await executeJson(deviceB, ["sync", "now", "--json"]); + const remoteHeads = record(await executeJson( + deviceB, + ["remote", "list", "--limit", "50", "--json"], + ), "remote_list"); + const heads = z.array(z.object({ + executionDevicePublicId: z.string().min(1).max(200), + publicId: z.string().min(1).max(200), + }).passthrough()).max(50).parse(remoteHeads.sessions); + const exactHeads = [sessionA, sessionB].map((sessionId) => + heads.filter((head) => head.publicId === sessionId)); + if (exactHeads.some((matches) => + matches.length !== 1 || matches[0]?.executionDevicePublicId !== pairA.device.publicId)) { + throw new ScenarioFailure("remote_sessions_missing"); + } + const remoteProjection = await executeJson( + deviceB, + ["remote", "show", sessionA, "--json"], + ); + assertRemoteAssistantMarker( + remoteProjection, + markerA, + sessionA, + pairA.device.publicId, + turnA, + ); + const remoteMarker = `hra-live-remote-${randomUUID()}`; + const remoteReceipt = remoteCommandBindingSchema.parse(await executeJson(deviceB, [ + "remote", + "send", + sessionA, + `Reply with ${remoteMarker} exactly once.`, + "--json", + ])); + const commandPublicId = remoteReceipt.commandPublicId; + if ( + remoteReceipt.state !== "pending" + || remoteReceipt.sessionPublicId !== sessionA + || remoteReceipt.targetDevicePublicId !== pairA.device.publicId + ) throw new ScenarioFailure("remote_receipt_invalid"); + const terminalRemote = await pollUntil({ + deadlineMs: timing.remoteCommandDeadlineMs ?? remoteCommandDeadlineMs, + now, + operation: async () => { + const status = remoteCommandBindingSchema.parse(await executeJson( + deviceB, + ["remote", "command", commandPublicId, "--json"], + )); + if ( + status.commandPublicId !== commandPublicId + || status.sessionPublicId !== sessionA + || status.targetDevicePublicId !== pairA.device.publicId + ) throw new ScenarioFailure("remote_command_binding_changed"); + if (status.state === "failed" || status.state === "ambiguous" || status.state === "expired" || status.state === "cancelled") { + throw new ScenarioFailure("remote_command_failed"); + } + return status.state === "applied" ? status : null; + }, + pollIntervalMs, + signal, + sleep, + }); + if ( + terminalRemote.resultCode !== "APPLIED" + || terminalRemote.state !== "applied" + ) throw new ScenarioFailure("remote_result_invalid"); + await pollUntil({ + deadlineMs: timing.remoteCommandDeadlineMs ?? remoteCommandDeadlineMs, + now, + operation: async () => { + await executeJson(deviceA, ["sync", "now", "--json"]); + await executeJson(deviceB, ["sync", "now", "--json"]); + const remoteAfter = await executeJson( + deviceB, + ["remote", "show", sessionA, "--json"], + ); + return remoteCommandTurnSettled( + remoteAfter, + remoteMarker, + sessionA, + pairA.device.publicId, + ) ? true : null; + }, + pollIntervalMs, + signal, + sleep, + }); + + operator.progress("presence_and_revocation"); + const onlineBefore = deviceListScenarioSchema.parse(await executeJson( + deviceA, + ["device", "list", "--json"], + )); + if (!assertDeviceAListAuthority( + onlineBefore, + pairA.device.publicId, + deviceBPublicId, + ).online) { + throw new ScenarioFailure("device_b_not_online_before_suspend"); + } + await deviceB.suspend(); + await abortable(async () => await sleep(presenceOfflineBoundaryMs + ( + timing.presenceObservationMarginMs ?? presenceObservationMarginMs + )), signal); + const offline = deviceListScenarioSchema.parse(await executeJson( + deviceA, + ["device", "list", "--json"], + )); + if (assertDeviceAListAuthority( + offline, + pairA.device.publicId, + deviceBPublicId, + ).online) { + throw new ScenarioFailure("device_b_offline_boundary_failed"); + } + await deviceB.resume(); + await pollUntil({ + deadlineMs: 60_000, + now, + operation: async () => { + const list = deviceListScenarioSchema.parse(await executeJson( + deviceA, + ["device", "list", "--json"], + )); + return assertDeviceAListAuthority( + list, + pairA.device.publicId, + deviceBPublicId, + ).online ? true : null; + }, + pollIntervalMs, + signal, + sleep, + }); + const revoked = record(await executeJson( + deviceA, + ["device", "revoke", deviceBPublicId, "--json"], + ), "device_revoke"); + const revokedDevice = record(revoked.device, "revoked_device"); + if (revokedDevice.publicId !== deviceBPublicId || revokedDevice.status !== "revoked") { + throw new ScenarioFailure("device_b_revocation_failed"); + } + await executeJsonFailure(deviceB, ["sync", "now", "--json"], { code: "UNAVAILABLE" }); + await executeJsonFailure( + deviceB, + ["remote", "show", sessionA, "--json"], + { code: "UNAVAILABLE" }, + ); + await executeJsonFailure(deviceB, [ + "remote", + "send", + sessionA, + "revoked devices cannot submit remote commands", + "--json", + ], { code: "UNAVAILABLE" }); + await abortable(async () => await sleep(presenceOfflineBoundaryMs + ( + timing.presenceObservationMarginMs ?? presenceObservationMarginMs + )), signal); + const revokedOffline = deviceListScenarioSchema.parse(await executeJson( + deviceA, + ["device", "list", "--json"], + )); + const provenRevoked = assertDeviceAListAuthority( + revokedOffline, + pairA.device.publicId, + deviceBPublicId, + ); + if (provenRevoked.status !== "revoked" || provenRevoked.online) { + throw new ScenarioFailure("device_b_revoked_presence_unproven"); + } + + const evidence = liveAcceptanceEvidenceSchema.parse({ + accountIds: [accountA, accountB], + cloudTargetDigest: attestation.cloudTargetDigest, + completedAt: now(), + devicePublicIds: [pairA.device.publicId, deviceBPublicId], + eventKinds: { + [sessionA]: sessionEvidenceA.eventKinds, + [sessionB]: sessionEvidenceB.eventKinds, + }, + markerDigests: [sha256(markerA), sha256(markerB), sha256(remoteMarker)], + packageVersion: attestation.packageVersion, + pluginLifecycleEffectsRejected: ["auth", "disable", "enable", "install"], + pluginInstallRejected: true, + presence: ["online", "offline", "online"], + providerIdentitiesDistinct: true, + remoteCommand: { resultCode: "APPLIED", state: "applied" }, + runId: run.runId, + sessionIds: [sessionA, sessionB], + sourceRevision: attestation.sourceRevision, + startedAt, + status: "passed", + version: 1, + }); + operator.progress("cleanup"); + await run.cleanup({ signal }); + return evidence; +} + +export function readLiveAcceptanceScenarioConfigurationFromFd( + fd: number, +): LiveAcceptanceScenarioConfiguration { + if (!Number.isSafeInteger(fd) || fd < 3 || fd > 255 || isatty(fd)) { + throw new ScenarioFailure("scenario_descriptor_invalid"); + } + const chunks: Buffer[] = []; + let total = 0; + try { + for (;;) { + const remaining = scenarioConfigurationMaximumBytes + 1 - total; + if (remaining <= 0) throw new ScenarioFailure("scenario_descriptor_invalid"); + const chunk = Buffer.allocUnsafe(Math.min(4 * 1024, remaining)); + const count = readSync(fd, chunk, 0, chunk.byteLength, null); + if (count === 0) { + chunk.fill(0); + break; + } + chunks.push(chunk.subarray(0, count)); + total += count; + if (total > scenarioConfigurationMaximumBytes) { + throw new ScenarioFailure("scenario_descriptor_invalid"); + } + } + if (total === 0) throw new ScenarioFailure("scenario_descriptor_invalid"); + const bytes = Buffer.concat(chunks, total); + try { + return liveAcceptanceScenarioConfigurationSchema.parse( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown, + ); + } finally { + bytes.fill(0); + } + } catch (error: unknown) { + if (error instanceof ScenarioFailure) throw error; + throw new ScenarioFailure("scenario_descriptor_invalid"); + } finally { + for (const chunk of chunks) chunk.fill(0); + } +} + +const hiddenTerminalDocument = async ( + prompt: string, + signal: AbortSignal, +): Promise => { + if (!process.stdin.isTTY || !process.stderr.isTTY) { + throw new ScenarioFailure("terminal_operator_unavailable"); + } + const sink = new WritableStream({ + write(_chunk, _encoding, callback) { callback(); }, + }); + const terminal = createInterface({ + historySize: 0, + input: process.stdin, + output: sink, + terminal: true, + }); + process.stderr.write(`${prompt}\nProtected JSON (hidden): `); + try { + const source = await abortable( + async () => await terminal.question("", { signal }), + signal, + ); + const bytes = Buffer.from(source, "utf8"); + try { + if (bytes.byteLength === 0 || bytes.byteLength > operatorFrameMaximumBytes) { + throw new ScenarioFailure("operator_response_invalid"); + } + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; + } finally { + bytes.fill(0); + } + } catch (error: unknown) { + if (error instanceof ScenarioFailure) throw error; + throw new ScenarioFailure("operator_response_invalid"); + } finally { + terminal.close(); + sink.destroy(); + process.stderr.write("\n"); + } +}; + +export class TerminalLiveAcceptanceOperator implements LiveAcceptanceScenarioOperator { + async acknowledgeDeviceLogin(input: Readonly<{ + accountLabel: string; + userCode: string; + verificationUrl: string; + }>, signal: AbortSignal): Promise { + if (!process.stdin.isTTY || !process.stderr.isTTY) { + throw new ScenarioFailure("terminal_operator_unavailable"); + } + process.stderr.write([ + `Complete Codex device login for ${input.accountLabel}.`, + `URL: ${input.verificationUrl}`, + `Code: ${input.userCode}`, + "Press Enter only after the provider confirms completion: ", + ].join("\n")); + const terminal = createInterface({ + historySize: 0, + input: process.stdin, + output: process.stderr, + terminal: true, + }); + try { + await abortable(async () => await terminal.question("", { signal }), signal); + } finally { + terminal.close(); + } + } + + progress(step: string): void { + process.stderr.write(`hra live acceptance: ${step}\n`); + } + + async protectedDocument( + request: LiveAcceptanceOperatorRequest, + signal: AbortSignal, + ): Promise { + const context = request.context === undefined + ? "" + : `\nContext: ${JSON.stringify(request.context)}`; + return await hiddenTerminalDocument(`${request.prompt}${context}`, signal); + } +} + +const operatorResponseSchema = z.union([ + z.object({ + document: z.unknown(), + requestId: z.string().uuid(), + type: z.literal("protected_input"), + version: z.literal(1), + }).strict(), + z.object({ + acknowledged: z.literal(true), + requestId: z.string().uuid(), + type: z.literal("device_login"), + version: z.literal(1), + }).strict(), +]); + +class JsonlFrameReader { + readonly #stream: Readable; + #buffer = Buffer.alloc(0); + #closed = false; + #iterator: AsyncIterator; + + constructor(fd: number) { + if (isatty(fd)) throw new ScenarioFailure("operator_descriptor_invalid"); + // IPC ownership makes a pending pipe read cancellable on Linux; fs.ReadStream does not. + this.#stream = new Socket({ fd, readable: true, writable: false }); + this.#iterator = this.#stream[Symbol.asyncIterator](); + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#buffer.fill(0); + this.#buffer = Buffer.alloc(0); + this.#stream.destroy(); + const returned = this.#iterator.return?.(); + if (returned !== undefined) void returned.catch(() => undefined); + } + + async read(signal: AbortSignal): Promise { + try { + for (;;) { + if (this.#closed) throw new ScenarioFailure("operator_closed"); + const newline = this.#buffer.indexOf(0x0a); + if (newline >= 0) { + const line = this.#buffer.subarray(0, newline); + this.#buffer = this.#buffer.subarray(newline + 1); + if (line.byteLength === 0) throw new ScenarioFailure("operator_response_invalid"); + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(line)) as unknown; + } catch { + throw new ScenarioFailure("operator_response_invalid"); + } finally { + line.fill(0); + } + } + const next = await abortable(async () => await this.#iterator.next(), signal); + if (next.done) throw new ScenarioFailure("operator_closed"); + const chunk = Buffer.isBuffer(next.value) + ? next.value + : Buffer.from(next.value as Uint8Array); + this.#buffer = Buffer.concat([this.#buffer, chunk]); + if (this.#buffer.byteLength > operatorFrameMaximumBytes) { + throw new ScenarioFailure("operator_response_invalid"); + } + } + } catch (error: unknown) { + if (signal.aborted) this.close(); + throw error; + } + } +} + +const writeFrame = async ( + stream: Writable, + value: unknown, + signal?: AbortSignal, +): Promise => { + const frame = `${JSON.stringify(value)}\n`; + if (Buffer.byteLength(frame, "utf8") > operatorFrameMaximumBytes) { + throw new ScenarioFailure("operator_request_invalid"); + } + const write = async (): Promise => { + await new Promise((resolvePromise, rejectPromise) => { + stream.write(frame, (error) => { + if (error === undefined || error === null) resolvePromise(); + else rejectPromise(error); + }); + }); + }; + if (signal === undefined) await write(); + else await abortable(write, signal); +}; + +export class JsonlLiveAcceptanceOperator implements LiveAcceptanceScenarioOperator { + readonly #input: JsonlFrameReader; + readonly #output: Writable; + + constructor() { + if (isatty(protectedOperatorInputFd) || isatty(operatorOutputFd)) { + throw new ScenarioFailure("operator_descriptor_invalid"); + } + this.#input = new JsonlFrameReader(protectedOperatorInputFd); + this.#output = createWriteStream("/dev/null", { + autoClose: true, + fd: operatorOutputFd, + }); + } + + #closeAfterAbort(signal: AbortSignal): void { + if (!signal.aborted) return; + this.#input.close(); + this.#output.destroy(); + } + + async acknowledgeDeviceLogin(input: Readonly<{ + accountLabel: string; + userCode: string; + verificationUrl: string; + }>, signal: AbortSignal): Promise { + const requestId = randomUUID(); + try { + await writeFrame(this.#output, { + ...input, + requestId, + type: "device_login_required", + version: 1, + }, signal); + const response = operatorResponseSchema.parse(await this.#input.read(signal)); + if ( + response.type !== "device_login" + || response.requestId !== requestId + ) throw new ScenarioFailure("operator_response_invalid"); + } catch (error: unknown) { + this.#closeAfterAbort(signal); + throw error; + } + } + + progress(step: string): void { + void writeFrame(this.#output, { + step, + type: "progress", + version: 1, + }).catch(() => undefined); + } + + async protectedDocument( + request: LiveAcceptanceOperatorRequest, + signal: AbortSignal, + ): Promise { + const requestId = randomUUID(); + try { + await writeFrame(this.#output, { + ...(request.context === undefined ? {} : { context: request.context }), + kind: request.kind, + prompt: request.prompt, + requestId, + type: "protected_input_required", + version: 1, + }, signal); + const response = operatorResponseSchema.parse(await this.#input.read(signal)); + if (response.type !== "protected_input" || response.requestId !== requestId) { + throw new ScenarioFailure("operator_response_invalid"); + } + return response.document; + } catch (error: unknown) { + this.#closeAfterAbort(signal); + throw error; + } + } +} + +export function createLiveAcceptanceScenarioOperator( + configuration: LiveAcceptanceScenarioConfiguration, +): LiveAcceptanceScenarioOperator { + return configuration.operator.kind === "terminal" + ? new TerminalLiveAcceptanceOperator() + : new JsonlLiveAcceptanceOperator(); +} + +export const liveAcceptanceScenarioFixedOperatorFds = { + input: protectedOperatorInputFd, + output: operatorOutputFd, +} as const; + +export const liveAcceptanceScenarioPresenceOfflineBoundaryMs = presenceOfflineBoundaryMs; + +export const liveAcceptanceScenarioDeviceNames = ["a", "b"] as const satisfies readonly LiveAcceptanceDeviceName[]; diff --git a/scripts/live-acceptance-worker.ts b/scripts/live-acceptance-worker.ts new file mode 100644 index 0000000..9e12df5 --- /dev/null +++ b/scripts/live-acceptance-worker.ts @@ -0,0 +1,624 @@ +#!/usr/bin/env bun + +import { createReadStream } from "node:fs"; +import { readSync } from "node:fs"; +import type { Readable, Writable } from "node:stream"; +import { createWriteStream } from "node:fs"; +import { isatty } from "node:tty"; + +import { callLocalDaemon } from "../src/daemon/local-transport"; +import { waitForDaemonReady } from "../src/daemon/daemon-startup"; +import { main as cliMain, runDaemon } from "../src/cli"; +import type { Output } from "../src/cli/render"; +import type { CommandResponse } from "../src/domain/contracts"; +import { + createAcceptanceInstallation, + type AcceptanceInstallationDescriptor, +} from "./live-acceptance-installation"; +import { + assertAcceptanceDescriptorLayout, + LIVE_ACCEPTANCE_CONTROL_FD, + LIVE_ACCEPTANCE_CONTROL_MAXIMUM_BYTES, + LIVE_ACCEPTANCE_DESCRIPTOR_FD, + LIVE_ACCEPTANCE_DESCRIPTOR_MAXIMUM_BYTES, + LIVE_ACCEPTANCE_STATUS_FD, + LIVE_ACCEPTANCE_STATUS_MAXIMUM_BYTES, + liveAcceptanceWorkerControlSchema, + liveAcceptanceWorkerStatusSchema, + type LiveAcceptanceWorkerStatus, +} from "./live-acceptance"; + +class WorkerFailure extends Error { + constructor(readonly code: Extract["code"]) { + super(code); + this.name = "WorkerFailure"; + } +} + +function readDescriptor(): unknown { + if (isatty(LIVE_ACCEPTANCE_DESCRIPTOR_FD)) { + throw new WorkerFailure("descriptor_invalid"); + } + const chunks: Buffer[] = []; + let total = 0; + try { + for (;;) { + const remaining = LIVE_ACCEPTANCE_DESCRIPTOR_MAXIMUM_BYTES + 1 - total; + if (remaining <= 0) throw new WorkerFailure("descriptor_invalid"); + const chunk = Buffer.allocUnsafe(Math.min(4 * 1024, remaining)); + const count = readSync( + LIVE_ACCEPTANCE_DESCRIPTOR_FD, + chunk, + 0, + chunk.byteLength, + null, + ); + if (count === 0) { + chunk.fill(0); + break; + } + chunks.push(chunk.subarray(0, count)); + total += count; + if (total > LIVE_ACCEPTANCE_DESCRIPTOR_MAXIMUM_BYTES) { + throw new WorkerFailure("descriptor_invalid"); + } + } + if (total === 0) throw new WorkerFailure("descriptor_invalid"); + const bytes = Buffer.concat(chunks, total); + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; + } finally { + bytes.fill(0); + } + } catch (error: unknown) { + if (error instanceof WorkerFailure) throw error; + throw new WorkerFailure("descriptor_invalid"); + } finally { + for (const chunk of chunks) chunk.fill(0); + } +} + +class StatusWriter { + readonly #stream: Writable; + #tail = Promise.resolve(); + + constructor() { + if (isatty(LIVE_ACCEPTANCE_STATUS_FD)) { + throw new WorkerFailure("status_unavailable"); + } + this.#stream = createWriteStream("/dev/null", { + autoClose: false, + fd: LIVE_ACCEPTANCE_STATUS_FD, + }); + } + + write(statusInput: LiveAcceptanceWorkerStatus): Promise { + const status = liveAcceptanceWorkerStatusSchema.parse(statusInput); + const frame = `${JSON.stringify(status)}\n`; + if (Buffer.byteLength(frame, "utf8") > LIVE_ACCEPTANCE_STATUS_MAXIMUM_BYTES) { + return Promise.reject(new WorkerFailure("status_unavailable")); + } + const operation = this.#tail.then(async () => { + await new Promise((resolvePromise, rejectPromise) => { + this.#stream.write(frame, (error) => { + if (error === undefined || error === null) resolvePromise(); + else rejectPromise(error); + }); + }); + }); + this.#tail = operation.catch(() => undefined); + return operation; + } + + async close(): Promise { + await this.#tail; + await new Promise((resolvePromise) => this.#stream.end(resolvePromise)); + } +} + +type ControlOutcome = "parent_closed" | "stop_requested"; + +const deferred = (): { + promise: Promise; + reject: (reason?: unknown) => void; + resolve: (value: T | PromiseLike) => void; +} => { + let resolvePromise!: (value: T | PromiseLike) => void; + let rejectPromise!: (reason?: unknown) => void; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + return { promise, reject: rejectPromise, resolve: resolvePromise }; +}; + +async function beforeDeadline(operation: Promise, milliseconds: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new WorkerFailure("daemon_failed")), milliseconds); + timer.unref(); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +const responseRequiresRestart = (response: CommandResponse): boolean => + response.ok + && response.data !== null + && typeof response.data === "object" + && "daemonRestartRequired" in response.data + && response.data.daemonRestartRequired === true; + +type GenerationStopReason = "parent_closed" | "restart" | "stop" | "suspend"; + +type DaemonGeneration = { + expectedStop: GenerationStopReason | null; + promise: Promise; +}; + +class DaemonSupervisor { + readonly #descriptor: AcceptanceInstallationDescriptor; + readonly #installation: ReturnType; + readonly #failure = deferred(); + #failureError: Error | undefined; + #generation: DaemonGeneration | undefined; + #suspended = false; + + constructor(descriptor: AcceptanceInstallationDescriptor) { + this.#descriptor = descriptor; + this.#installation = createAcceptanceInstallation(descriptor); + void this.#failure.promise.catch(() => undefined); + } + + get failure(): Promise { + return this.#failure.promise; + } + + async start(): Promise { + if (this.#generation !== undefined || this.#suspended) { + throw new WorkerFailure("daemon_failed"); + } + const generation: DaemonGeneration = { + expectedStop: null, + promise: runDaemon(this.#installation), + }; + this.#generation = generation; + void generation.promise.then( + (exitCode) => { + if (exitCode !== 0 || generation.expectedStop === null) { + this.#fail(new WorkerFailure("daemon_failed")); + } + }, + () => this.#fail(new WorkerFailure("daemon_failed")), + ); + try { + await Promise.race([ + waitForDaemonReady({ + deadlineMs: 30_000, + paths: this.#installation.paths, + queryStatus: async () => await callLocalDaemon({ + command: { kind: "daemon.status" }, + deadlineMs: 750, + paths: this.#installation.paths, + }), + }), + generation.promise.then(() => { throw new WorkerFailure("daemon_failed"); }), + this.#failure.promise, + ]); + } catch (error: unknown) { + generation.expectedStop ??= "stop"; + signalDaemon(); + await beforeDeadline(generation.promise, 30_000).catch(() => undefined); + throw error; + } + if (process.env.HOME !== this.#descriptor.expectedHomeDirectory) { + throw new WorkerFailure("home_changed"); + } + } + + async command( + command: Parameters[0]["command"], + signal: AbortSignal, + ): Promise { + this.#assertRunning(); + const response = await Promise.race([ + callLocalDaemon({ + command, + paths: this.#installation.paths, + signal, + }), + this.#failure.promise, + ]); + if (responseRequiresRestart(response)) { + const generation = this.#generation; + if (generation === undefined || generation.expectedStop !== null) { + throw new WorkerFailure("daemon_failed"); + } + generation.expectedStop = "restart"; + } + return response; + } + + async restartAfterResponse(): Promise { + const generation = this.#generation; + if (generation === undefined || generation.expectedStop !== "restart") return; + await beforeDeadline(generation.promise, 30_000); + if (this.#generation !== generation) throw new WorkerFailure("daemon_failed"); + this.#generation = undefined; + await this.start(); + } + + async suspend(signal: AbortSignal): Promise { + if (this.#suspended || this.#generation === undefined) { + throw new WorkerFailure("control_invalid"); + } + await this.#stopGeneration("suspend", signal, true); + this.#suspended = true; + } + + async resume(): Promise { + if (!this.#suspended || this.#generation !== undefined) { + throw new WorkerFailure("control_invalid"); + } + this.#suspended = false; + try { + await this.start(); + } catch (error: unknown) { + this.#suspended = true; + throw error; + } + } + + async stop(reason: "parent_closed" | "stop", signal: AbortSignal): Promise { + if (this.#suspended) return; + if (reason === "parent_closed" && this.#generation?.expectedStop === "parent_closed") { + await this.#awaitStoppedGeneration(this.#generation); + return; + } + await this.#stopGeneration(reason, signal, reason === "stop"); + } + + beginParentShutdown(): void { + if (this.#suspended) return; + const generation = this.#generation; + if (generation === undefined) return; + if (generation.expectedStop === null) { + generation.expectedStop = "parent_closed"; + signalDaemon(); + } + } + + async #stopGeneration( + reason: GenerationStopReason, + signal: AbortSignal, + throughDaemonCommand: boolean, + ): Promise { + const generation = this.#generation; + if (generation === undefined || generation.expectedStop !== null) { + throw new WorkerFailure("daemon_failed"); + } + generation.expectedStop = reason; + if (throughDaemonCommand) { + const response = await callLocalDaemon({ + command: { kind: "daemon.stop" }, + deadlineMs: 5_000, + paths: this.#installation.paths, + signal, + }); + if (!response.ok) throw new WorkerFailure("daemon_failed"); + } else { + signalDaemon(); + } + await this.#awaitStoppedGeneration(generation); + } + + async #awaitStoppedGeneration(generation: DaemonGeneration): Promise { + const exitCode = await beforeDeadline(generation.promise, 30_000); + if (exitCode !== 0 || this.#generation !== generation) { + throw new WorkerFailure("daemon_failed"); + } + this.#generation = undefined; + } + + #assertRunning(): void { + if (this.#failureError !== undefined) throw this.#failureError; + if (this.#suspended || this.#generation === undefined || this.#generation.expectedStop !== null) { + throw new WorkerFailure("daemon_failed"); + } + } + + #fail(error: Error): void { + if (this.#failureError !== undefined) return; + this.#failureError = error; + this.#failure.reject(error); + } +} + +class CapturedCliOutput implements Output { + #stderr = ""; + #stdout = ""; + + get result(): Readonly<{ stderr: string; stdout: string }> { + return { stderr: this.#stderr, stdout: this.#stdout }; + } + + writeStderr(value: string): void { + this.#stderr = this.#append(this.#stderr, value, 256 * 1024); + } + + writeStdout(value: string): void { + this.#stdout = this.#append(this.#stdout, value, 1024 * 1024); + } + + async writeStdoutAsync(value: string, signal: AbortSignal): Promise { + if (signal.aborted) throw signal.reason; + this.writeStdout(value); + } + + #append(current: string, value: string, maximumBytes: number): string { + const next = current + value; + if (Buffer.byteLength(next, "utf8") > maximumBytes) { + throw new WorkerFailure("status_unavailable"); + } + return next; + } +} + +async function executeCliControl( + control: Extract, { type: "cli" }>, + descriptor: AcceptanceInstallationDescriptor, + supervisor: DaemonSupervisor, + signal: AbortSignal, +): Promise> { + if (control.argv[0] === "daemon" || control.argv[0] === "init") { + throw new WorkerFailure("control_invalid"); + } + const output = new CapturedCliOutput(); + let protectedInputConsumed = false; + let restartRequired = false; + const exitCode = await cliMain(control.argv, output, { + callDaemon: async (command, commandSignal) => { + const combinedSignal = commandSignal === undefined + ? signal + : AbortSignal.any([signal, commandSignal]); + const response = await supervisor.command(command, combinedSignal); + restartRequired ||= responseRequiresRestart(response); + return response; + }, + installation: createAcceptanceInstallation(descriptor), + interactive: false, + isTerminalDescriptor: (fd) => fd !== LIVE_ACCEPTANCE_CONTROL_FD, + readProtectedDocument: async (source) => { + if ( + source.kind !== "fd" + || source.fd !== LIVE_ACCEPTANCE_CONTROL_FD + || control.protectedInput === undefined + || protectedInputConsumed + ) throw new WorkerFailure("control_invalid"); + protectedInputConsumed = true; + return control.protectedInput.document; + }, + }); + if ((control.protectedInput !== undefined) !== protectedInputConsumed) { + throw new WorkerFailure("control_invalid"); + } + return { restartRequired, result: { exitCode, ...output.result } }; +} + +async function handleControl( + control: ReturnType, + descriptor: AcceptanceInstallationDescriptor, + status: StatusWriter, + supervisor: DaemonSupervisor, + signal: AbortSignal, +): Promise { + if (control.type === "stop") { + await supervisor.stop("stop", signal); + return "stop_requested"; + } + if (control.type === "suspend") { + await supervisor.suspend(signal); + await status.write({ + action: "suspend", + requestId: control.requestId, + type: "ack", + version: 1, + }); + return null; + } + if (control.type === "resume") { + await supervisor.resume(); + await status.write({ + action: "resume", + requestId: control.requestId, + type: "ack", + version: 1, + }); + return null; + } + if (control.type === "command") { + const response = await supervisor.command(control.command, signal); + await status.write({ + requestId: control.requestId, + response, + type: "command_result", + version: 1, + }); + if (responseRequiresRestart(response)) await supervisor.restartAfterResponse(); + return null; + } + const cli = await executeCliControl(control, descriptor, supervisor, signal); + await status.write({ + requestId: control.requestId, + result: cli.result, + type: "cli_result", + version: 1, + }); + if (cli.restartRequired) await supervisor.restartAfterResponse(); + return null; +} + +async function consumeControl( + descriptor: AcceptanceInstallationDescriptor, + status: StatusWriter, + supervisor: DaemonSupervisor, +): Promise { + if (isatty(LIVE_ACCEPTANCE_CONTROL_FD)) throw new WorkerFailure("control_invalid"); + const stream: Readable = createReadStream("/dev/null", { + autoClose: false, + fd: LIVE_ACCEPTANCE_CONTROL_FD, + }); + const parentLifetime = new AbortController(); + const completed = deferred(); + let buffered = Buffer.alloc(0); + let pendingFrames = 0; + let tail = Promise.resolve(null); + try { + const readLoop = (async () => { + try { + for await (const unknownChunk of stream) { + const chunk = Buffer.isBuffer(unknownChunk) + ? unknownChunk + : Buffer.from(unknownChunk as Uint8Array); + buffered = Buffer.concat([buffered, chunk]); + if (buffered.byteLength > LIVE_ACCEPTANCE_CONTROL_MAXIMUM_BYTES) { + throw new WorkerFailure("control_invalid"); + } + for (;;) { + const newline = buffered.indexOf(0x0a); + if (newline < 0) break; + const line = buffered.subarray(0, newline); + buffered = buffered.subarray(newline + 1); + if (line.byteLength === 0) throw new WorkerFailure("control_invalid"); + let control: ReturnType; + try { + control = liveAcceptanceWorkerControlSchema.parse( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(line)) as unknown, + ); + } catch { + throw new WorkerFailure("control_invalid"); + } finally { + line.fill(0); + } + pendingFrames += 1; + if (pendingFrames > 128) throw new WorkerFailure("control_invalid"); + tail = tail.then(async (prior) => { + if (prior !== null) return prior; + try { + const outcome = await handleControl( + control, + descriptor, + status, + supervisor, + parentLifetime.signal, + ); + if (outcome !== null) completed.resolve(outcome); + return outcome; + } finally { + pendingFrames -= 1; + } + }); + void tail.catch((error: unknown) => completed.reject(error)); + } + } + if (buffered.byteLength !== 0) throw new WorkerFailure("control_invalid"); + parentLifetime.abort(new Error("The acceptance parent control pipe closed.")); + supervisor.beginParentShutdown(); + completed.resolve("parent_closed"); + } catch (error: unknown) { + completed.reject(error); + } + })(); + const outcome = await Promise.race([completed.promise, supervisor.failure]); + if (outcome === "parent_closed") { + parentLifetime.abort(new Error("The acceptance parent control pipe closed.")); + supervisor.beginParentShutdown(); + } + stream.destroy(); + await readLoop.catch(() => undefined); + await beforeDeadline(tail, 5_000).catch((error: unknown) => { + if (outcome !== "parent_closed") throw error; + }); + return outcome; + } finally { + parentLifetime.abort(new Error("The acceptance control lifetime ended.")); + buffered.fill(0); + stream.destroy(); + } +} + +const signalDaemon = (): void => { + try { + process.kill(process.pid, "SIGTERM"); + } catch { + // The daemon may already have completed its bounded shutdown. + } +}; + +async function workerMain(): Promise { + let status: StatusWriter | undefined; + let descriptor: AcceptanceInstallationDescriptor | undefined; + try { + status = new StatusWriter(); + descriptor = await assertAcceptanceDescriptorLayout(readDescriptor()); + if (process.env.HOME !== descriptor.expectedHomeDirectory) { + throw new WorkerFailure("home_changed"); + } + // Codex resolves its account-level credential-store policy from the app-server + // startup directory. Bind that base config to the same isolated project used by + // every config/read preflight, without changing HOME or carrying the path in argv. + process.chdir(descriptor.documentsDirectory); + if (process.cwd() !== descriptor.documentsDirectory) { + throw new WorkerFailure("layout_invalid"); + } + const supervisor = new DaemonSupervisor(descriptor); + await supervisor.start(); + if (process.env.HOME !== descriptor.expectedHomeDirectory) { + throw new WorkerFailure("home_changed"); + } + await status.write({ + device: descriptor.device, + pid: process.pid, + runId: descriptor.runId, + type: "ready", + version: 1, + }); + const outcome = await consumeControl(descriptor, status, supervisor); + if (outcome === "parent_closed") { + const shutdown = new AbortController(); + await supervisor.stop("parent_closed", shutdown.signal); + } + await status.write({ + device: descriptor.device, + runId: descriptor.runId, + type: "stopped", + version: 1, + }); + await status.close(); + return 0; + } catch (error: unknown) { + signalDaemon(); + const code = error instanceof WorkerFailure + ? error.code + : error instanceof Error && error.message === "home_changed" + ? "home_changed" + : "internal_failure"; + await status?.write({ + code, + ...(descriptor === undefined + ? {} + : { device: descriptor.device, runId: descriptor.runId }), + type: "failed", + version: 1, + }).catch(() => undefined); + await status?.close().catch(() => undefined); + return 1; + } +} + +if (import.meta.main) process.exitCode = await workerMain(); diff --git a/scripts/live-acceptance.test.ts b/scripts/live-acceptance.test.ts new file mode 100644 index 0000000..26b7738 --- /dev/null +++ b/scripts/live-acceptance.test.ts @@ -0,0 +1,854 @@ +import { describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rename, + rm, + symlink, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import type { CommandResponse, LocalCommand } from "../src/domain/contracts"; +import { readDaemonAuthorityReceipt } from "../src/daemon/daemon-lock"; +import { resolveStatePaths } from "../src/storage/paths"; +import { + acceptanceInstallationDescriptorSchema, + createAcceptanceInstallation, + type AcceptanceInstallationDescriptor, +} from "./live-acceptance-installation"; +import { + assertAcceptanceDescriptorLayout, + createLiveAcceptanceLayout, + liveAcceptanceRecoveryReceiptSchema, + liveAcceptanceWorkerLaunch, + LiveAcceptanceStartError, + resumeLiveAcceptanceCleanup, + startLiveAcceptanceRun, + type LiveAcceptanceDeviceName, + type LiveAcceptanceWorker, +} from "./live-acceptance"; + +const deadPidBase = 900_000; + +async function privateTestBase(): Promise { + const root = await mkdtemp(join(await realpath(tmpdir()), "hra-live-acceptance-test-")); + await chmod(root, 0o700); + return await realpath(root); +} + +async function removeOwnedTestBase(root: string): Promise { + const canonical = await realpath(root).catch(() => null); + if (canonical !== null && canonical === root && root.includes("hra-live-acceptance-test-")) { + await rm(root, { force: false, recursive: true }); + } +} + +const response = (data: unknown): CommandResponse => ({ + data, + ok: true, + requestId: randomUUID(), + version: 1, +}); + +type FakeBehavior = Readonly<{ + accountInitialState?: "recovery_required" | "signed_in"; + ambiguousLogout?: boolean; + authStatusGate?: () => Promise; + derivePeerFromDeviceB?: boolean; + extraLivePeer?: boolean; + extraRevokedPeer?: boolean; + failDeletion?: boolean; + failRevocation?: boolean; + noCloudIdentity?: boolean; + omitPeer?: boolean; + peerStatus?: "active" | "pending" | "revoked"; +}>; + +class FakeWorker implements LiveAcceptanceWorker { + readonly commands: LocalCommand[] = []; + readonly device: LiveAcceptanceDeviceName; + readonly pid: number; + readonly projectDirectory: string; + readonly rootDirectory: string; + preserved = false; + stopped = false; + #accountState: "recovery_required" | "signed_in" | "signed_out"; + #cloudDeleted = false; + #peerStatus: "active" | "pending" | "revoked"; + readonly #behavior: FakeBehavior; + + constructor( + descriptor: AcceptanceInstallationDescriptor, + index: number, + behavior: FakeBehavior, + ) { + this.device = descriptor.device; + this.pid = deadPidBase + index; + this.projectDirectory = descriptor.documentsDirectory; + this.rootDirectory = descriptor.rootDirectory; + this.#behavior = behavior; + this.#accountState = behavior.accountInitialState ?? "signed_in"; + this.#peerStatus = behavior.peerStatus ?? "revoked"; + } + + async command(command: LocalCommand): Promise { + this.commands.push(command); + if (command.kind === "device.list") { + return response({ + currentDevicePublicId: "device_current", + devices: [ + { current: true, publicId: "device_current", status: "active" }, + ...(this.#behavior.omitPeer === true + ? [] + : [{ current: false, publicId: "device_revoked", status: this.#peerStatus }]), + ...(this.#behavior.extraLivePeer === true + ? [{ current: false, publicId: "device_unexpected", status: "pending" }] + : []), + ...(this.#behavior.extraRevokedPeer === true + ? [{ current: false, publicId: "device_old_revoked", status: "revoked" }] + : []), + ], + }); + } + if (command.kind === "device.revoke") { + if (command.device !== "device_revoked") throw new Error("unexpected revoke target"); + if (this.#behavior.failRevocation === true) { + return { + error: { code: "UNAVAILABLE", message: "synthetic revocation failure" }, + ok: false, + requestId: randomUUID(), + version: 1, + }; + } + this.#peerStatus = "revoked"; + return response({ device: { publicId: "device_revoked", status: "revoked" } }); + } + if (command.kind === "auth.delete") { + if (this.#behavior.failDeletion === true) { + return { + error: { code: "UNAVAILABLE", message: "synthetic failure" }, + ok: false, + requestId: randomUUID(), + version: 1, + }; + } + this.#cloudDeleted = true; + return response({ + deletion: { + effectsDisabled: true, + state: "complete", + statusFresh: true, + }, + }); + } + if (command.kind === "auth.status") { + await this.#behavior.authStatusGate?.(); + if (this.#behavior.noCloudIdentity === true && !this.#cloudDeleted) { + return response({ configured: true, device: null, signedIn: false }); + } + if (!this.#cloudDeleted) { + if (this.device === "b" && this.#behavior.derivePeerFromDeviceB !== true) { + return response({ configured: true, device: null, signedIn: false }); + } + return response({ + configured: true, + device: this.device === "a" + ? { publicId: "device_current", status: "active" } + : { publicId: "device_revoked", status: this.#peerStatus }, + email: "acceptance@example.test", + signedIn: true, + }); + } + return response({ + configured: true, + deletion: { + effectsDisabled: true, + state: "complete", + statusFresh: true, + }, + signedIn: false, + }); + } + if (command.kind === "account.list") { + if (this.#behavior.ambiguousLogout !== true) return response({ accounts: [] }); + return response({ + accounts: [{ + id: "account_ambiguous", + state: this.#accountState, + }], + }); + } + if (command.kind === "account.logout" && this.#behavior.ambiguousLogout === true) { + this.#accountState = "recovery_required"; + return { + error: { code: "UNAVAILABLE", message: "synthetic lost logout response" }, + ok: false, + requestId: randomUUID(), + version: 1, + }; + } + if (command.kind === "account.show" && this.#behavior.ambiguousLogout === true) { + this.#accountState = "signed_out"; + return response({ + account: { id: "account_ambiguous", state: "signed_out" }, + recovery: { cleared: true, required: false, resolution: "proven_applied" }, + }); + } + return response({ accepted: true }); + } + + async preserve(): Promise { + this.preserved = true; + this.stopped = true; + } + + ready(): Promise { + return Promise.resolve(); + } + + execute(): Promise<{ exitCode: number; stderr: string; stdout: string }> { + return Promise.resolve({ exitCode: 0, stderr: "", stdout: "{}\n" }); + } + + failure(): Promise { + return new Promise(() => undefined); + } + + lifetime(): Promise { + return Promise.resolve(); + } + + resume(): Promise { + this.stopped = false; + return Promise.resolve(); + } + + stop(): Promise { + this.stopped = true; + return Promise.resolve(); + } + + suspend(): Promise { + this.stopped = true; + return Promise.resolve(); + } +} + +const fakeFactory = ( + workers: FakeWorker[], + behavior: FakeBehavior = {}, +): ((descriptor: AcceptanceInstallationDescriptor) => Promise) => + async (descriptor) => { + const worker = new FakeWorker(descriptor, workers.length + 1, behavior); + workers.push(worker); + return worker; + }; + +const fakeShutdownVerifier = async (worker: LiveAcceptanceWorker): Promise => { + expect((worker as FakeWorker).stopped).toBe(true); +}; + +describe("source-only live acceptance isolation", () => { + test("creates two canonical private installations without changing HOME", async () => { + const base = await privateTestBase(); + const originalHomeDirectory = process.env.HOME; + let runRoot: string | undefined; + try { + const layout = await createLiveAcceptanceLayout({ temporaryBaseDirectory: base }); + runRoot = layout.runRoot.path; + expect(process.env.HOME).toBe(originalHomeDirectory); + expect(layout.descriptors.a.rootDirectory).not.toBe(layout.descriptors.b.rootDirectory); + expect(layout.descriptors.a.documentsDirectory).not.toBe(layout.descriptors.b.documentsDirectory); + expect(dirname(layout.descriptors.a.rootDirectory)).toBe(layout.runRoot.path); + expect(dirname(layout.descriptors.b.rootDirectory)).toBe(layout.runRoot.path); + for (const resource of layout.resources) { + const metadata = await lstat(resource.identity.path); + expect(metadata.isDirectory()).toBe(true); + expect(metadata.isSymbolicLink()).toBe(false); + expect(metadata.mode & 0o777).toBe(0o700); + expect(await realpath(resource.identity.path)).toBe(resource.identity.path); + } + + const installationA = createAcceptanceInstallation(layout.descriptors.a); + const installationB = createAcceptanceInstallation(layout.descriptors.b); + expect(installationA.kind).toBe("live_acceptance"); + expect(installationA.cloudEnvironment).toEqual({ HRA_CONVEX_URL: "" }); + expect(installationA.desktopSwitching).toBe(false); + expect(installationA.credentialStorePreflight).toEqual({ + cliAuth: "file", + cwd: layout.descriptors.a.documentsDirectory, + mcpOauth: "file", + }); + + const codexHomeA = join(layout.descriptors.a.rootDirectory, "profiles", "acceptance-a", "codex-home"); + const codexHomeB = join(layout.descriptors.b.rootDirectory, "profiles", "acceptance-b", "codex-home"); + await Promise.all([ + installationA.prepareCodexHome(codexHomeA), + installationB.prepareCodexHome(codexHomeB), + ]); + const [environmentA, environmentB] = await Promise.all([ + installationA.codexEnvironment(codexHomeA), + installationB.codexEnvironment(codexHomeB), + ]); + expect(environmentA?.HOME).toBe(originalHomeDirectory); + expect(environmentB?.HOME).toBe(originalHomeDirectory); + expect(environmentA?.TMPDIR).toBe(join(codexHomeA, "tmp")); + expect(environmentB?.TMPDIR).toBe(join(codexHomeB, "tmp")); + expect(environmentA?.TMPDIR).not.toBe(environmentB?.TMPDIR); + expect(await readFile(join(codexHomeA, "config.toml"), "utf8")).toBe([ + 'cli_auth_credentials_store = "file"', + 'mcp_oauth_credentials_store = "file"', + "", + ].join("\n")); + await installationA.prepareCodexHome(codexHomeA); + await chmod(join(codexHomeA, "config.toml"), 0o644); + await expect(installationA.prepareCodexHome(codexHomeA)) + .rejects.toThrow("unsafe credential-store configuration"); + await chmod(join(codexHomeA, "config.toml"), 0o600); + + const custody = installationA.createSecretCustody(); + expect(await custody.compareAndSwap("acceptance-test", null, "private-value")) + .toMatchObject({ generation: 0, value: "private-value" }); + const secretDirectory = await lstat(join(layout.descriptors.a.rootDirectory, "secret-values")); + expect(secretDirectory.isDirectory()).toBe(true); + expect(secretDirectory.mode & 0o777).toBe(0o700); + expect(process.env.HOME).toBe(originalHomeDirectory); + } finally { + if (runRoot !== undefined) await rm(runRoot, { force: false, recursive: true }).catch(() => undefined); + await removeOwnedTestBase(base); + } + }); + + test("keeps state, sockets, and capabilities out of worker argv and environment", async () => { + const base = await privateTestBase(); + let runRoot: string | undefined; + try { + const layout = await createLiveAcceptanceLayout({ temporaryBaseDirectory: base }); + runRoot = layout.runRoot.path; + const launch = liveAcceptanceWorkerLaunch(layout.descriptors.a); + expect(launch.arguments).toHaveLength(1); + expect(launch.arguments[0].endsWith("/scripts/live-acceptance-worker.ts")).toBe(true); + const serializedLaunch = JSON.stringify({ + arguments: launch.arguments, + environment: launch.environment, + }); + expect(serializedLaunch).not.toContain(layout.descriptors.a.rootDirectory); + expect(serializedLaunch).not.toContain(layout.descriptors.a.documentsDirectory); + expect(serializedLaunch).not.toContain(layout.runId); + expect(launch.environment.HOME).toBe(process.env.HOME); + expect(launch.environment.HRA_CONVEX_URL).toBeUndefined(); + expect(Object.keys(launch.environment).some((key) => + /ROOT|SOCKET|CAPABILITY|CODEX_HOME/u.test(key))).toBe(false); + } finally { + if (runRoot !== undefined) await rm(runRoot, { force: false, recursive: true }).catch(() => undefined); + await removeOwnedTestBase(base); + } + }); + + test("starts and cleanly joins two full daemon subprocesses with HOME unchanged", async () => { + const base = await privateTestBase(); + const originalHomeDirectory = process.env.HOME; + let run: Awaited> | undefined; + try { + run = await startLiveAcceptanceRun({ + cloudDeploymentUrl: "http://127.0.0.1:9", + temporaryBaseDirectory: base, + }); + expect(process.env.HOME).toBe(originalHomeDirectory); + const runRoots = (await readdir(base, { withFileTypes: true })) + .filter((entry) => + entry.isDirectory() + && entry.name.startsWith(`hra-live-acceptance-${run!.runId}-`)) + .map((entry) => join(base, entry.name)); + expect(runRoots).toHaveLength(1); + const runRoot = runRoots[0]!; + const stateRoots = (await readdir(runRoot, { withFileTypes: true })) + .filter((entry) => entry.isDirectory() && entry.name.startsWith("device-")) + .map((entry) => join(runRoot, entry.name)) + .sort(); + expect(stateRoots).toHaveLength(2); + expect(resolveStatePaths({ rootDirectory: stateRoots[0]! }).database) + .not.toBe(resolveStatePaths({ rootDirectory: stateRoots[1]! }).database); + + const deviceA = run.device("a"); + const listed = await deviceA.execute(["project", "list", "--json"]); + expect(listed).toMatchObject({ exitCode: 0, stderr: "" }); + expect(JSON.parse(listed.stdout)).toMatchObject({ + command: "project.list", + data: { projects: [] }, + ok: true, + }); + const added = await deviceA.execute([ + "project", + "add", + "--path", + deviceA.projectDirectory, + "--name", + "Acceptance", + "--json", + ]); + expect(added).toMatchObject({ exitCode: 0, stderr: "" }); + expect(JSON.parse(added.stdout)).toMatchObject({ + command: "project.add", + ok: true, + }); + const protectedRefusal = await deviceA.execute([ + "auth", + "login", + "--input-fd", + "4", + "--json", + ], { protectedDocument: { email: "not-an-email" } }); + expect(protectedRefusal).toMatchObject({ exitCode: 2, stderr: "" }); + expect(JSON.parse(protectedRefusal.stdout)).toMatchObject({ + error: { code: "INVALID_INPUT" }, + ok: false, + }); + + await run.device("b").suspend(); + await run.device("b").resume(); + expect((await run.device("b").execute(["project", "list", "--json"])).exitCode) + .toBe(0); + + await run.preserveForRecovery(); + run = undefined; + expect(process.env.HOME).toBe(originalHomeDirectory); + for (const stateRoot of stateRoots) { + const paths = resolveStatePaths({ rootDirectory: stateRoot }); + const daemonReceipt = await readDaemonAuthorityReceipt(paths); + expect(daemonReceipt?.state).toBe("stopped"); + expect(await lstat(paths.socket).then(() => true).catch(() => false)).toBe(false); + expect(await lstat(paths.capability).then(() => true).catch(() => false)).toBe(false); + } + } finally { + await run?.preserveForRecovery().catch(() => undefined); + await removeOwnedTestBase(base); + } + }, 60_000); + + test("returns protected recovery coordinates when one worker cannot start", async () => { + const base = await privateTestBase(); + const workers: FakeWorker[] = []; + try { + const error = await startLiveAcceptanceRun({ + temporaryBaseDirectory: base, + workerFactory: async (descriptor) => { + if (descriptor.device === "b") throw new Error("synthetic startup failure"); + const worker = new FakeWorker(descriptor, 1, {}); + workers.push(worker); + return worker; + }, + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(LiveAcceptanceStartError); + const startError = error as LiveAcceptanceStartError; + expect(startError.code).toBe("worker_failed"); + expect(startError.recoveryReceiptPath).toBe( + join(base, `.hra-live-acceptance-${startError.runId}.recovery.json`), + ); + expect((await lstat(startError.recoveryReceiptPath)).mode & 0o777).toBe(0o600); + expect(workers).toHaveLength(1); + expect(workers[0]!.preserved).toBe(true); + } finally { + await removeOwnedTestBase(base); + } + }); + + test("rejects descriptor extension and a substituted symlink before worker startup", async () => { + const base = await privateTestBase(); + let runRoot: string | undefined; + try { + const layout = await createLiveAcceptanceLayout({ temporaryBaseDirectory: base }); + runRoot = layout.runRoot.path; + expect(acceptanceInstallationDescriptorSchema.safeParse({ + ...layout.descriptors.a, + socket: "/tmp/attacker.sock", + }).success).toBe(false); + + const original = layout.descriptors.a.rootDirectory; + const quarantine = `${original}.test-quarantine`; + await rename(original, quarantine); + await symlink(layout.descriptors.a.documentsDirectory, original); + await expect(assertAcceptanceDescriptorLayout(layout.descriptors.a)) + .rejects.toThrow("layout_changed"); + await rm(original, { force: false }); + await rename(quarantine, original); + } finally { + if (runRoot !== undefined) await rm(runRoot, { force: false, recursive: true }).catch(() => undefined); + await removeOwnedTestBase(base); + } + }); + + test("proves release cleanup gates before quarantining and deleting both roots", async () => { + const base = await privateTestBase(); + const workers: FakeWorker[] = []; + try { + const run = await startLiveAcceptanceRun({ + shutdownVerifier: fakeShutdownVerifier, + temporaryBaseDirectory: base, + workerFactory: fakeFactory(workers), + }); + expect(workers.map((worker) => worker.device).sort()).toEqual(["a", "b"]); + const runRoot = dirname(workers[0]!.rootDirectory); + await run.bindExpectedRevokedPeer("device_revoked"); + await run.cleanup({ cloudDeletionDeadlineMs: 1_000, cloudDeletionPollMs: 1 }); + expect(workers.every((worker) => worker.stopped)).toBe(true); + expect(workers[0]!.commands.map((command) => command.kind)).toEqual([ + "auth.status", + "device.list", + "auth.delete", + "auth.status", + "account.list", + "account.list", + ]); + expect(workers[1]!.commands.map((command) => command.kind)).toEqual([ + "account.list", + "account.list", + ]); + expect(await lstat(runRoot).then(() => true).catch(() => false)).toBe(false); + } finally { + await removeOwnedTestBase(base); + } + }); + + test("retains a mode-0600 authoritative receipt and resumes from its last safe checkpoint", async () => { + const base = await privateTestBase(); + const firstWorkers: FakeWorker[] = []; + const resumedWorkers: FakeWorker[] = []; + try { + const run = await startLiveAcceptanceRun({ + shutdownVerifier: fakeShutdownVerifier, + temporaryBaseDirectory: base, + workerFactory: fakeFactory(firstWorkers, { failDeletion: true }), + }); + await run.bindExpectedRevokedPeer("device_revoked"); + await expect(run.cleanup({ cloudDeletionDeadlineMs: 10, cloudDeletionPollMs: 1 })) + .rejects.toThrow(); + const receiptPath = join(base, `.hra-live-acceptance-${run.runId}.recovery.json`); + const metadata = await lstat(receiptPath); + expect(metadata.mode & 0o777).toBe(0o600); + const onDisk = liveAcceptanceRecoveryReceiptSchema.parse( + JSON.parse(await readFile(receiptPath, "utf8")) as unknown, + ); + expect(onDisk.phase).toBe("recovery_required"); + expect(onDisk.checkpoint).toBe("cleanup_revocation_proven"); + expect(onDisk.cloudCleanupMode).toBe("delete_identity"); + expect(onDisk.expectedRevocationIdempotencyKey).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ); + expect(onDisk.expectedRevokedPeerPublicId).toBe("device_revoked"); + expect(onDisk.resources.every((resource) => resource.status === "active")).toBe(true); + + const forgedCallerCopy = { + ...onDisk, + checkpoint: "cleanup_daemons_stopped" as const, + phase: "cleanup_daemons_stopped" as const, + workers: onDisk.workers.map((worker) => ({ ...worker, state: "stopped" as const })), + }; + await resumeLiveAcceptanceCleanup(forgedCallerCopy, { + cloudDeletionDeadlineMs: 1_000, + cloudDeletionPollMs: 1, + shutdownVerifier: fakeShutdownVerifier, + workerFactory: fakeFactory(resumedWorkers), + }); + expect(resumedWorkers).toHaveLength(2); + expect(resumedWorkers[0]!.commands[0]?.kind).toBe("auth.delete"); + expect(await lstat(receiptPath).then(() => true).catch(() => false)).toBe(false); + } finally { + await removeOwnedTestBase(base); + } + }); + + test("an unknown direct child blocks deletion before either device root is removed", async () => { + const base = await privateTestBase(); + const workers: FakeWorker[] = []; + try { + const run = await startLiveAcceptanceRun({ + shutdownVerifier: fakeShutdownVerifier, + temporaryBaseDirectory: base, + workerFactory: fakeFactory(workers), + }); + const runRoot = dirname(workers[0]!.rootDirectory); + await mkdir(join(runRoot, "unexpected-entry"), { mode: 0o700 }); + await run.bindExpectedRevokedPeer("device_revoked"); + await expect(run.cleanup({ cloudDeletionDeadlineMs: 1_000, cloudDeletionPollMs: 1 })) + .rejects.toThrow("layout_changed"); + for (const worker of workers) { + expect((await lstat(worker.rootDirectory)).isDirectory()).toBe(true); + } + } finally { + await removeOwnedTestBase(base); + } + }); + + test("admits an unbound singleton identity but refuses every unexpected peer status", async () => { + const base = await privateTestBase(); + const unboundWorkers: FakeWorker[] = []; + const extraPeerWorkers: FakeWorker[] = []; + const extraRevokedWorkers: FakeWorker[] = []; + try { + const unbound = await startLiveAcceptanceRun({ + shutdownVerifier: fakeShutdownVerifier, + temporaryBaseDirectory: base, + workerFactory: fakeFactory(unboundWorkers, { omitPeer: true }), + }); + await expect(unbound.cleanup({ cloudDeletionDeadlineMs: 1_000, cloudDeletionPollMs: 1 })) + .resolves.toBeUndefined(); + + const extraPeer = await startLiveAcceptanceRun({ + shutdownVerifier: fakeShutdownVerifier, + temporaryBaseDirectory: base, + workerFactory: fakeFactory(extraPeerWorkers, { extraLivePeer: true }), + }); + await extraPeer.bindExpectedRevokedPeer("device_revoked"); + await expect(extraPeer.cleanup({ cloudDeletionDeadlineMs: 10, cloudDeletionPollMs: 1 })) + .rejects.toThrow("cloud_revocation_unproven"); + + const extraRevoked = await startLiveAcceptanceRun({ + shutdownVerifier: fakeShutdownVerifier, + temporaryBaseDirectory: base, + workerFactory: fakeFactory(extraRevokedWorkers, { extraRevokedPeer: true }), + }); + await extraRevoked.bindExpectedRevokedPeer("device_revoked"); + await expect(extraRevoked.cleanup({ cloudDeletionDeadlineMs: 10, cloudDeletionPollMs: 1 })) + .rejects.toThrow("cloud_revocation_unproven"); + expect(unboundWorkers.every((worker) => worker.stopped)).toBe(true); + expect(extraPeerWorkers.every((worker) => worker.preserved)).toBe(true); + expect(extraRevokedWorkers.every((worker) => worker.preserved)).toBe(true); + } finally { + await removeOwnedTestBase(base); + } + }); + + test("converges a durably bound pending peer through one exact revoke before deletion", async () => { + const base = await privateTestBase(); + const workers: FakeWorker[] = []; + try { + const run = await startLiveAcceptanceRun({ + shutdownVerifier: fakeShutdownVerifier, + temporaryBaseDirectory: base, + workerFactory: fakeFactory(workers, { peerStatus: "pending" }), + }); + await run.bindExpectedRevokedPeer("device_revoked"); + await run.cleanup({ cloudDeletionDeadlineMs: 1_000, cloudDeletionPollMs: 1 }); + const revokes = workers[0]!.commands.filter((command) => command.kind === "device.revoke"); + expect(revokes).toHaveLength(1); + expect(revokes[0]).toMatchObject({ + device: "device_revoked", + kind: "device.revoke", + }); + expect((revokes[0] as Extract).idempotencyKey) + .toMatch(/^[0-9a-f-]{36}$/u); + expect(workers[0]!.commands.map((command) => command.kind)).toContain("auth.delete"); + } finally { + await removeOwnedTestBase(base); + } + }); + + test("recovers the lost pair response by deriving and durably binding B before revoke", async () => { + const base = await privateTestBase(); + const firstWorkers: FakeWorker[] = []; + const resumedWorkers: FakeWorker[] = []; + try { + const run = await startLiveAcceptanceRun({ + shutdownVerifier: fakeShutdownVerifier, + temporaryBaseDirectory: base, + workerFactory: fakeFactory(firstWorkers, { + derivePeerFromDeviceB: true, + failRevocation: true, + peerStatus: "pending", + }), + }); + await expect(run.cleanup({ cloudDeletionDeadlineMs: 10, cloudDeletionPollMs: 1 })) + .rejects.toThrow(); + const receipt = liveAcceptanceRecoveryReceiptSchema.parse( + JSON.parse(await readFile(run.recoveryReceiptPath, "utf8")) as unknown, + ); + expect(receipt.checkpoint).toBe("workers_ready"); + expect(receipt.expectedRevokedPeerPublicId).toBe("device_revoked"); + expect(receipt.expectedRevocationIdempotencyKey).toMatch(/^[0-9a-f-]{36}$/u); + expect(firstWorkers[1]!.commands.map((command) => command.kind)).toContain("auth.status"); + expect(firstWorkers[0]!.commands.find((command) => command.kind === "device.revoke")) + .toMatchObject({ device: "device_revoked" }); + + await resumeLiveAcceptanceCleanup(receipt, { + cloudDeletionDeadlineMs: 1_000, + cloudDeletionPollMs: 1, + shutdownVerifier: fakeShutdownVerifier, + workerFactory: fakeFactory(resumedWorkers, { + derivePeerFromDeviceB: true, + peerStatus: "pending", + }), + }); + expect(resumedWorkers[0]!.commands.find((command) => command.kind === "device.revoke")) + .toMatchObject({ device: "device_revoked" }); + expect(await lstat(run.recoveryReceiptPath).then(() => true).catch(() => false)) + .toBe(false); + } finally { + await removeOwnedTestBase(base); + } + }); + + test("reconciles an indeterminate Codex logout on resume without replaying it", async () => { + const base = await privateTestBase(); + const firstWorkers: FakeWorker[] = []; + const resumedWorkers: FakeWorker[] = []; + try { + const run = await startLiveAcceptanceRun({ + shutdownVerifier: fakeShutdownVerifier, + temporaryBaseDirectory: base, + workerFactory: fakeFactory(firstWorkers, { ambiguousLogout: true }), + }); + await run.bindExpectedRevokedPeer("device_revoked"); + await expect(run.cleanup({ cloudDeletionDeadlineMs: 1_000, cloudDeletionPollMs: 1 })) + .rejects.toThrow("worker_failed"); + const receipt = liveAcceptanceRecoveryReceiptSchema.parse( + JSON.parse(await readFile(run.recoveryReceiptPath, "utf8")) as unknown, + ); + expect(receipt.checkpoint).toBe("cleanup_cloud_erased"); + expect(firstWorkers.every((worker) => + worker.commands.filter((command) => command.kind === "account.logout").length === 1)) + .toBe(true); + + await resumeLiveAcceptanceCleanup(receipt, { + shutdownVerifier: fakeShutdownVerifier, + workerFactory: fakeFactory(resumedWorkers, { + accountInitialState: "recovery_required", + ambiguousLogout: true, + }), + }); + expect(resumedWorkers.every((worker) => + worker.commands.filter((command) => command.kind === "account.show").length === 1)) + .toBe(true); + expect(resumedWorkers.every((worker) => + worker.commands.every((command) => command.kind !== "account.logout"))) + .toBe(true); + expect(await lstat(run.recoveryReceiptPath).then(() => true).catch(() => false)) + .toBe(false); + } finally { + await removeOwnedTestBase(base); + } + }); + + test("passes interruption into resumed quarantine deletion and retains every root", async () => { + const base = await privateTestBase(); + const workers: FakeWorker[] = []; + const controller = new AbortController(); + try { + const run = await startLiveAcceptanceRun({ + shutdownVerifier: async () => { controller.abort(); }, + temporaryBaseDirectory: base, + workerFactory: fakeFactory(workers), + }); + await run.bindExpectedRevokedPeer("device_revoked"); + await expect(run.cleanup({ + cloudDeletionDeadlineMs: 1_000, + cloudDeletionPollMs: 1, + signal: controller.signal, + })).rejects.toThrow("operator_interrupted"); + const receipt = liveAcceptanceRecoveryReceiptSchema.parse( + JSON.parse(await readFile(run.recoveryReceiptPath, "utf8")) as unknown, + ); + expect(receipt.checkpoint).toBe("cleanup_daemons_stopped"); + expect(receipt.resources.every((resource) => resource.status === "active")).toBe(true); + + await expect(resumeLiveAcceptanceCleanup(receipt, { signal: controller.signal })) + .rejects.toThrow("operator_interrupted"); + expect(await lstat(run.recoveryReceiptPath).then(() => true).catch(() => false)) + .toBe(true); + for (const resource of receipt.resources) { + expect((await lstat(resource.identity.path)).isDirectory()).toBe(true); + } + } finally { + await removeOwnedTestBase(base); + } + }); + + test("serializes interruption with in-flight cleanup and preserves one resumable receipt", async () => { + const base = await privateTestBase(); + const workers: FakeWorker[] = []; + let releaseAuthStatus!: () => void; + let markAuthStatusStarted!: () => void; + const authStatusGate = new Promise((resolvePromise) => { + releaseAuthStatus = resolvePromise; + }); + const authStatusStarted = new Promise((resolvePromise) => { + markAuthStatusStarted = resolvePromise; + }); + try { + const run = await startLiveAcceptanceRun({ + shutdownVerifier: fakeShutdownVerifier, + temporaryBaseDirectory: base, + workerFactory: fakeFactory(workers, { + authStatusGate: async () => { + markAuthStatusStarted(); + await authStatusGate; + }, + }), + }); + await run.bindExpectedRevokedPeer("device_revoked"); + const controller = new AbortController(); + const cleanup = run.cleanup({ signal: controller.signal }); + void cleanup.catch(() => undefined); + await authStatusStarted; + controller.abort(); + run.requestAbort(); + const preservation = run.preserveForRecovery("operator_interrupted"); + releaseAuthStatus(); + await expect(cleanup).rejects.toThrow("operator_interrupted"); + await expect(preservation).resolves.toBe("recovery_required"); + const receipt = liveAcceptanceRecoveryReceiptSchema.parse( + JSON.parse(await readFile(run.recoveryReceiptPath, "utf8")) as unknown, + ); + expect(receipt.failureCode).toBe("operator_interrupted"); + expect(receipt.expectedRevokedPeerPublicId).toBe("device_revoked"); + expect(receipt.resources.every((resource) => resource.status === "active")).toBe(true); + expect(workers.every((worker) => worker.preserved)).toBe(true); + } finally { + releaseAuthStatus(); + await removeOwnedTestBase(base); + } + }); + + test("recovers a worker-start failure with no cloud identity and no peer binding", async () => { + const base = await privateTestBase(); + const firstWorkers: FakeWorker[] = []; + const recoveredWorkers: FakeWorker[] = []; + try { + const error = await startLiveAcceptanceRun({ + temporaryBaseDirectory: base, + workerFactory: async (descriptor) => { + if (descriptor.device === "b") throw new Error("synthetic startup failure"); + const worker = new FakeWorker(descriptor, 1, { noCloudIdentity: true }); + firstWorkers.push(worker); + return worker; + }, + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(LiveAcceptanceStartError); + const startError = error as LiveAcceptanceStartError; + const locator = liveAcceptanceRecoveryReceiptSchema.parse( + JSON.parse(await readFile(startError.recoveryReceiptPath, "utf8")) as unknown, + ); + await resumeLiveAcceptanceCleanup(locator, { + shutdownVerifier: fakeShutdownVerifier, + workerFactory: fakeFactory(recoveredWorkers, { noCloudIdentity: true }), + }); + expect(recoveredWorkers).toHaveLength(2); + expect(recoveredWorkers[0]!.commands.map((command) => command.kind)).toEqual([ + "auth.status", + "account.list", + "account.list", + ]); + expect(await lstat(startError.recoveryReceiptPath).then(() => true).catch(() => false)) + .toBe(false); + } finally { + await removeOwnedTestBase(base); + } + }); +}); diff --git a/scripts/live-acceptance.ts b/scripts/live-acceptance.ts new file mode 100644 index 0000000..dfe322d --- /dev/null +++ b/scripts/live-acceptance.ts @@ -0,0 +1,2512 @@ +#!/usr/bin/env bun + +import { createHash, randomUUID } from "node:crypto"; +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { constants, readSync, type Stats } from "node:fs"; +import { + chmod, + lstat, + mkdtemp, + open, + readdir, + realpath, + rename, + rm, + rmdir, + stat, + unlink, +} from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import type { Readable, Writable } from "node:stream"; +import { isatty } from "node:tty"; + +import { z } from "zod"; + +import { + acceptanceInstallationDescriptorSchema, + type AcceptanceInstallationDescriptor, +} from "./live-acceptance-installation"; +import { + commandResponseSchema, + localCommandSchema, + type CommandResponse, + type LocalCommand, +} from "../src/domain/contracts"; +import { DaemonLock, readDaemonAuthorityReceipt } from "../src/daemon/daemon-lock"; +import { DEFAULT_CLOUD_DEPLOYMENT_URL } from "../src/cloud/identity-custody"; +import { resolveStatePaths } from "../src/storage/paths"; +import { HRA_VERSION } from "../src/version"; + +export const LIVE_ACCEPTANCE_DESCRIPTOR_FD = 3; +export const LIVE_ACCEPTANCE_CONTROL_FD = 4; +export const LIVE_ACCEPTANCE_STATUS_FD = 5; +export const LIVE_ACCEPTANCE_DESCRIPTOR_MAXIMUM_BYTES = 8 * 1024; +export const LIVE_ACCEPTANCE_CONTROL_MAXIMUM_BYTES = 256 * 1024; +export const LIVE_ACCEPTANCE_STATUS_MAXIMUM_BYTES = 2 * 1024 * 1024; + +const workerStartupDeadlineMs = 30_000; +const workerCommandDeadlineMs = 90_000; +const workerShutdownDeadlineMs = 30_000; +const cloudDeletionDeadlineMs = 15 * 60 * 1_000; +const cloudDeletionPollMs = 1_000; + +const deviceSchema = z.enum(["a", "b"]); +export type LiveAcceptanceDeviceName = z.infer; + +const requestIdSchema = z.string().uuid(); + +const cliArgumentSchema = z.string().min(1).max(16 * 1024); + +export const liveAcceptanceCliResultSchema = z.object({ + exitCode: z.number().int().min(0).max(255), + stderr: z.string().max(256 * 1024), + stdout: z.string().max(1024 * 1024), +}).strict(); + +export type LiveAcceptanceCliResult = z.infer; + +export const liveAcceptanceWorkerControlSchema = z.discriminatedUnion("type", [ + z.object({ + argv: z.array(cliArgumentSchema).min(1).max(64), + protectedInput: z.object({ document: z.unknown() }).strict().optional(), + requestId: requestIdSchema, + type: z.literal("cli"), + version: z.literal(1), + }).strict(), + z.object({ + command: localCommandSchema, + requestId: requestIdSchema, + type: z.literal("command"), + version: z.literal(1), + }).strict(), + z.object({ + requestId: requestIdSchema, + type: z.literal("suspend"), + version: z.literal(1), + }).strict(), + z.object({ + requestId: requestIdSchema, + type: z.literal("resume"), + version: z.literal(1), + }).strict(), + z.object({ + requestId: requestIdSchema, + type: z.literal("stop"), + version: z.literal(1), + }).strict(), +]).superRefine((value, context) => { + if (value.type !== "cli") return; + const serializedBytes = value.argv.reduce( + (total, argument) => total + Buffer.byteLength(argument, "utf8"), + 0, + ); + if (serializedBytes > 64 * 1024) { + context.addIssue({ code: "custom", message: "CLI arguments are oversized." }); + } + if (value.argv.includes("--follow")) { + context.addIssue({ code: "custom", message: "Unbounded CLI following is unavailable." }); + } + if (value.argv.includes("--input-stdin")) { + context.addIssue({ code: "custom", message: "Protected input must use inherited descriptor 4." }); + } + const inputFd = value.argv.indexOf("--input-fd"); + const requestsProtectedInput = inputFd >= 0 + && value.argv[inputFd + 1] === String(LIVE_ACCEPTANCE_CONTROL_FD); + if ((value.protectedInput !== undefined) !== requestsProtectedInput) { + context.addIssue({ code: "custom", message: "Protected input and descriptor selection must agree." }); + } + if (inputFd >= 0 && !requestsProtectedInput) { + context.addIssue({ code: "custom", message: "Only inherited descriptor 4 carries protected input." }); + } +}); + +export type LiveAcceptanceWorkerControl = z.infer< + typeof liveAcceptanceWorkerControlSchema +>; + +export const liveAcceptanceWorkerStatusSchema = z.discriminatedUnion("type", [ + z.object({ + device: deviceSchema, + pid: z.number().int().positive(), + runId: z.string().uuid(), + type: z.literal("ready"), + version: z.literal(1), + }).strict(), + z.object({ + requestId: requestIdSchema, + result: liveAcceptanceCliResultSchema, + type: z.literal("cli_result"), + version: z.literal(1), + }).strict(), + z.object({ + requestId: requestIdSchema, + response: commandResponseSchema, + type: z.literal("command_result"), + version: z.literal(1), + }).strict(), + z.object({ + action: z.enum(["resume", "suspend"]), + requestId: requestIdSchema, + type: z.literal("ack"), + version: z.literal(1), + }).strict(), + z.object({ + device: deviceSchema, + runId: z.string().uuid(), + type: z.literal("stopped"), + version: z.literal(1), + }).strict(), + z.object({ + code: z.enum([ + "control_invalid", + "daemon_failed", + "descriptor_invalid", + "home_changed", + "internal_failure", + "layout_invalid", + "status_unavailable", + ]), + device: deviceSchema.optional(), + runId: z.string().uuid().optional(), + type: z.literal("failed"), + version: z.literal(1), + }).strict(), +]); + +export type LiveAcceptanceWorkerStatus = z.infer< + typeof liveAcceptanceWorkerStatusSchema +>; + +const resourceRoleSchema = z.enum([ + "device_a", + "device_b", + "project_a", + "project_b", +]); + +type ResourceRole = z.infer; + +const resourceStatusSchema = z.enum([ + "active", + "quarantine_planned", + "quarantined", + "deleted", +]); + +const directoryIdentitySchema = z.object({ + device: z.number().int().nonnegative(), + inode: z.number().int().positive(), + mode: z.literal(0o700), + owner: z.number().int().nonnegative(), + path: z.string().min(1).max(4_096).refine(isAbsolute), +}).strict(); + +type DirectoryIdentity = z.infer; + +const cleanupResourceSchema = z.object({ + identity: directoryIdentitySchema, + quarantinePath: z.string().min(1).max(4_096).refine(isAbsolute).optional(), + role: resourceRoleSchema, + status: resourceStatusSchema, +}).strict(); + +const workerReceiptSchema = z.object({ + device: deviceSchema, + pid: z.number().int().positive(), + state: z.enum(["starting", "ready", "stopped", "failed"]), +}).strict(); + +const cleanupCheckpointSchema = z.enum([ + "prepared", + "workers_starting", + "workers_ready", + "cleanup_revocation_proven", + "cleanup_cloud_erased", + "cleanup_codex_logged_out", + "cleanup_daemons_stopped", + "cleanup_quarantined", +]); + +const recoveryPhaseSchema = z.union([ + cleanupCheckpointSchema, + z.literal("recovery_required"), +]); + +const recoveryFailureCodeSchema = z.enum([ + "account_logout_unproven", + "cloud_deletion_unproven", + "cloud_revocation_unproven", + "daemon_shutdown_unproven", + "home_changed", + "layout_changed", + "operator_interrupted", + "worker_failed", +]); + +export const liveAcceptanceRecoveryReceiptSchema = z.object({ + checkpoint: cleanupCheckpointSchema, + cloudCleanupMode: z.enum(["delete_identity", "no_identity"]).optional(), + cloudDeploymentUrl: z.string().min(1).max(2_048).optional(), + createdAt: z.number().int().nonnegative(), + expectedHomeDirectory: z.string().min(1).max(4_096).refine(isAbsolute), + expectedRevocationIdempotencyKey: z.string().uuid().optional(), + expectedRevokedPeerPublicId: z.string().min(1).max(200).optional(), + failureCode: recoveryFailureCodeSchema.optional(), + phase: recoveryPhaseSchema, + receiptPath: z.string().min(1).max(4_096).refine(isAbsolute), + resources: z.array(cleanupResourceSchema).length(4), + runId: z.string().uuid(), + runRoot: directoryIdentitySchema, + updatedAt: z.number().int().nonnegative(), + version: z.literal(1), + workers: z.array(workerReceiptSchema).max(2), +}).strict(); + +export type LiveAcceptanceRecoveryReceipt = z.infer< + typeof liveAcceptanceRecoveryReceiptSchema +>; + +export class LiveAcceptanceError extends Error { + constructor(readonly code: z.infer | "input_invalid" | "worker_protocol_invalid") { + super(code); + this.name = "LiveAcceptanceError"; + } +} + +export class LiveAcceptanceStartError extends LiveAcceptanceError { + constructor( + code: z.infer, + readonly recoveryReceiptPath: string, + readonly runId: string, + ) { + super(code); + this.name = "LiveAcceptanceStartError"; + } +} + +type ResourceLayout = Readonly<{ + identity: DirectoryIdentity; + role: ResourceRole; +}>; + +export type LiveAcceptanceLayout = Readonly<{ + descriptors: Readonly>; + expectedHomeDirectory: string; + receiptPath: string; + resources: readonly ResourceLayout[]; + runId: string; + runRoot: DirectoryIdentity; +}>; + +export interface LiveAcceptanceWorker { + readonly device: LiveAcceptanceDeviceName; + readonly pid: number; + readonly projectDirectory: string; + command(command: LocalCommand): Promise; + execute( + argv: readonly string[], + options?: Readonly<{ protectedDocument?: unknown }>, + ): Promise; + failure(): Promise; + lifetime(): Promise; + preserve(): Promise; + ready(): Promise; + resume(): Promise; + stop(): Promise; + suspend(): Promise; +} + +export type LiveAcceptanceDevice = Readonly<{ + device: LiveAcceptanceDeviceName; + projectDirectory: string; + execute( + argv: readonly string[], + options?: Readonly<{ protectedDocument?: unknown }>, + ): Promise; + resume(): Promise; + suspend(): Promise; +}>; + +export type LiveAcceptanceWorkerFactory = ( + descriptor: AcceptanceInstallationDescriptor, +) => Promise; + +type StartOptions = Readonly<{ + cloudDeploymentUrl?: string; + shutdownVerifier?: ( + worker: LiveAcceptanceWorker, + descriptor: AcceptanceInstallationDescriptor, + ) => Promise; + temporaryBaseDirectory?: string; + workerFactory?: LiveAcceptanceWorkerFactory; +}>; + +type CleanupOptions = Readonly<{ + cloudDeletionDeadlineMs?: number; + cloudDeletionPollMs?: number; + signal?: AbortSignal; +}>; + +type MutableReceipt = LiveAcceptanceRecoveryReceipt; + +const checkpointOrder = new Map( + cleanupCheckpointSchema.options.map((checkpoint, index) => [checkpoint, index]), +); + +const checkpointAtLeast = ( + checkpoint: z.infer, + expected: z.infer, +): boolean => (checkpointOrder.get(checkpoint) ?? -1) >= (checkpointOrder.get(expected) ?? -1); + +const deferred = (): { + promise: Promise; + reject: (reason?: unknown) => void; + resolve: (value: T | PromiseLike) => void; +} => { + let resolvePromise!: (value: T | PromiseLike) => void; + let rejectPromise!: (reason?: unknown) => void; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + return { promise, reject: rejectPromise, resolve: resolvePromise }; +}; + +const deferredSignal = (): { + promise: Promise; + reject: (reason?: unknown) => void; + resolve: () => void; +} => { + const signal = deferred(); + return { + promise: signal.promise.then(() => undefined), + reject: signal.reject, + resolve: () => signal.resolve(true), + }; +}; + +const boundedDeadline = async ( + operation: Promise, + deadlineMs: number, + code: LiveAcceptanceError["code"], +): Promise => { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new LiveAcceptanceError(code)), deadlineMs); + timer.unref(); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +}; + +const processIsAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error: unknown) { + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } +}; + +const currentOwner = (): number => { + const owner = typeof process.getuid === "function" ? process.getuid() : undefined; + if (owner === undefined) throw new LiveAcceptanceError("layout_changed"); + return owner; +}; + +const assertNormalizedAbsolute = (value: string): string => { + if (!isAbsolute(value) || resolve(value) !== value) { + throw new LiveAcceptanceError("layout_changed"); + } + return value; +}; + +const isContainedDirectChild = (parent: string, child: string): boolean => { + const relation = relative(parent, child); + return relation !== "" + && !relation.startsWith("..") + && !isAbsolute(relation) + && !relation.includes("/") + && !relation.includes("\\"); +}; + +const pathsOverlap = (leftInput: string, rightInput: string): boolean => { + const left = resolve(leftInput); + const right = resolve(rightInput); + const leftToRight = relative(left, right); + const rightToLeft = relative(right, left); + return left === right + || (!leftToRight.startsWith("..") && !isAbsolute(leftToRight)) + || (!rightToLeft.startsWith("..") && !isAbsolute(rightToLeft)); +}; + +function assertSafeAcceptanceLocation( + runRoot: string, + expectedHomeDirectory: string, +): void { + const productionRoot = resolveStatePaths().root; + if ( + pathsOverlap(runRoot, productionRoot) + || pathsOverlap(runRoot, expectedHomeDirectory) + ) throw new LiveAcceptanceError("layout_changed"); +} + +function assertReceiptLayoutShape(receipt: LiveAcceptanceRecoveryReceipt): void { + const runRoot = assertNormalizedAbsolute(receipt.runRoot.path); + const expectedHomeDirectory = assertNormalizedAbsolute(receipt.expectedHomeDirectory); + const receiptPath = assertNormalizedAbsolute(receipt.receiptPath); + if ( + expectedHomeDirectory !== homedir() + || process.env.HOME !== expectedHomeDirectory + || dirname(receiptPath) !== dirname(runRoot) + || basename(receiptPath) !== `.hra-live-acceptance-${receipt.runId}.recovery.json` + || !basename(runRoot).startsWith(`hra-live-acceptance-${receipt.runId}-`) + ) throw new LiveAcceptanceError("layout_changed"); + assertSafeAcceptanceLocation(runRoot, expectedHomeDirectory); + if ( + pathsOverlap(receiptPath, runRoot) + || pathsOverlap(receiptPath, expectedHomeDirectory) + || pathsOverlap(receiptPath, resolveStatePaths().root) + ) throw new LiveAcceptanceError("layout_changed"); + + const expectedPrefixes: Readonly> = { + device_a: "device-a-", + device_b: "device-b-", + project_a: "project-a-", + project_b: "project-b-", + }; + const roles = new Set(); + const paths = new Set(); + const identities = new Set(); + for (const resource of receipt.resources) { + const resourcePath = assertNormalizedAbsolute(resource.identity.path); + if ( + roles.has(resource.role) + || paths.has(resourcePath) + || identities.has(`${String(resource.identity.device)}:${String(resource.identity.inode)}`) + || !isContainedDirectChild(runRoot, resourcePath) + || !basename(resourcePath).startsWith(expectedPrefixes[resource.role]) + || pathsOverlap(resourcePath, expectedHomeDirectory) + || pathsOverlap(resourcePath, resolveStatePaths().root) + ) throw new LiveAcceptanceError("layout_changed"); + roles.add(resource.role); + paths.add(resourcePath); + identities.add(`${String(resource.identity.device)}:${String(resource.identity.inode)}`); + if (resource.quarantinePath !== undefined) { + const quarantine = assertNormalizedAbsolute(resource.quarantinePath); + if ( + !isContainedDirectChild(runRoot, quarantine) + || !basename(quarantine).startsWith(`.hra-quarantine-${resource.role}-`) + || paths.has(quarantine) + ) throw new LiveAcceptanceError("layout_changed"); + paths.add(quarantine); + } + } + if (roles.size !== resourceRoleSchema.options.length) { + throw new LiveAcceptanceError("layout_changed"); + } + if ( + (receipt.expectedRevokedPeerPublicId === undefined) + !== (receipt.expectedRevocationIdempotencyKey === undefined) + || ( + checkpointAtLeast(receipt.checkpoint, "cleanup_revocation_proven") + && receipt.cloudCleanupMode === undefined + ) + || ( + receipt.cloudCleanupMode === "no_identity" + && receipt.expectedRevokedPeerPublicId !== undefined + ) + ) throw new LiveAcceptanceError("layout_changed"); + if (checkpointAtLeast(receipt.checkpoint, "workers_ready")) { + const workerDevices = new Set(receipt.workers.map((worker) => worker.device)); + const workerPids = new Set(receipt.workers.map((worker) => worker.pid)); + if ( + receipt.workers.length !== 2 + || workerDevices.size !== 2 + || !workerDevices.has("a") + || !workerDevices.has("b") + || workerPids.size !== 2 + || ( + checkpointAtLeast(receipt.checkpoint, "cleanup_daemons_stopped") + && receipt.workers.some((worker) => worker.state !== "stopped") + ) + ) throw new LiveAcceptanceError("layout_changed"); + } +} + +async function assertReceiptLayoutRuntime(receipt: LiveAcceptanceRecoveryReceipt): Promise { + assertReceiptLayoutShape(receipt); + const parent = dirname(receipt.runRoot.path); + if (await realpath(parent) !== parent) throw new LiveAcceptanceError("layout_changed"); +} + +async function observePrivateDirectory(path: string): Promise { + const normalized = assertNormalizedAbsolute(path); + const metadata = await lstat(normalized); + if ( + !metadata.isDirectory() + || metadata.isSymbolicLink() + || metadata.uid !== currentOwner() + || (metadata.mode & 0o777) !== 0o700 + ) throw new LiveAcceptanceError("layout_changed"); + const canonical = await realpath(normalized); + if (canonical !== normalized) throw new LiveAcceptanceError("layout_changed"); + return directoryIdentitySchema.parse({ + device: metadata.dev, + inode: metadata.ino, + mode: 0o700, + owner: metadata.uid, + path: normalized, + }); +} + +async function assertDirectoryIdentity(identity: DirectoryIdentity): Promise { + const current = await observePrivateDirectory(identity.path); + if ( + current.device !== identity.device + || current.inode !== identity.inode + || current.owner !== identity.owner + ) throw new LiveAcceptanceError("layout_changed"); +} + +async function createPrivateTemporaryDirectory(prefix: string): Promise { + const path = await mkdtemp(prefix); + await chmod(path, 0o700); + return await observePrivateDirectory(path); +} + +async function syncDirectory(path: string): Promise { + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +class AtomicRecoveryReceipt { + #identity: Readonly<{ device: number; inode: number }>; + #value: MutableReceipt; + + private constructor( + value: MutableReceipt, + identity: Readonly<{ device: number; inode: number }>, + ) { + this.#value = value; + this.#identity = identity; + } + + static async create(value: MutableReceipt): Promise { + const parsed = liveAcceptanceRecoveryReceiptSchema.parse(value); + await assertReceiptLayoutRuntime(parsed); + const parent = dirname(parsed.receiptPath); + await observePrivateDirectory(parsed.runRoot.path); + const handle = await open( + parsed.receiptPath, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, + 0o600, + ); + try { + await handle.writeFile(JSON.stringify(parsed), "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + await chmod(parsed.receiptPath, 0o600); + await syncDirectory(parent); + const metadata = await AtomicRecoveryReceipt.#observeFile(parsed.receiptPath); + return new AtomicRecoveryReceipt(parsed, metadata); + } + + static async open(value: unknown): Promise { + const locator = liveAcceptanceRecoveryReceiptSchema.parse(value); + assertReceiptLayoutShape(locator); + const handle = await open(locator.receiptPath, constants.O_RDONLY | constants.O_NOFOLLOW); + let parsed: LiveAcceptanceRecoveryReceipt; + let identity: Readonly<{ device: number; inode: number }>; + try { + const before = await handle.stat(); + AtomicRecoveryReceipt.#assertSafeFileMetadata(before); + if (before.size > 32 * 1024) throw new LiveAcceptanceError("layout_changed"); + const bytes = await handle.readFile(); + try { + parsed = liveAcceptanceRecoveryReceiptSchema.parse( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown, + ); + } finally { + bytes.fill(0); + } + const after = await handle.stat(); + AtomicRecoveryReceipt.#assertSafeFileMetadata(after); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size) { + throw new LiveAcceptanceError("layout_changed"); + } + identity = { device: after.dev, inode: after.ino }; + } finally { + await handle.close(); + } + if ( + parsed.receiptPath !== locator.receiptPath + || parsed.runId !== locator.runId + ) throw new LiveAcceptanceError("layout_changed"); + await assertReceiptLayoutRuntime(parsed); + return new AtomicRecoveryReceipt(parsed, identity); + } + + get value(): MutableReceipt { + return this.#value; + } + + async update( + transform: (current: MutableReceipt) => MutableReceipt, + ): Promise { + const next = liveAcceptanceRecoveryReceiptSchema.parse(transform(this.#value)); + assertReceiptLayoutShape(next); + if ( + next.receiptPath !== this.#value.receiptPath + || next.runId !== this.#value.runId + || next.createdAt !== this.#value.createdAt + ) throw new LiveAcceptanceError("layout_changed"); + await this.#assertCurrent(); + const parent = dirname(next.receiptPath); + const temporary = join(parent, `.${basename(next.receiptPath)}.${randomUUID()}.tmp`); + const handle = await open( + temporary, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, + 0o600, + ); + try { + await handle.writeFile(JSON.stringify(next), "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await chmod(temporary, 0o600); + await this.#assertCurrent(); + await rename(temporary, next.receiptPath); + await syncDirectory(parent); + } catch (error: unknown) { + await unlink(temporary).catch(() => undefined); + throw error; + } + this.#identity = await AtomicRecoveryReceipt.#observeFile(next.receiptPath); + this.#value = next; + return next; + } + + async remove(): Promise { + await this.#assertCurrent(); + await unlink(this.#value.receiptPath); + await syncDirectory(dirname(this.#value.receiptPath)); + } + + async #assertCurrent(): Promise { + const current = await AtomicRecoveryReceipt.#observeFile(this.#value.receiptPath); + if ( + current.device !== this.#identity.device + || current.inode !== this.#identity.inode + ) throw new LiveAcceptanceError("layout_changed"); + } + + static async #observeFile(path: string): Promise> { + const metadata = await lstat(assertNormalizedAbsolute(path)); + AtomicRecoveryReceipt.#assertSafeFileMetadata(metadata); + if (metadata.isSymbolicLink() || metadata.size > 32 * 1024) { + throw new LiveAcceptanceError("layout_changed"); + } + return { device: metadata.dev, inode: metadata.ino }; + } + + static #assertSafeFileMetadata(metadata: Stats): void { + if ( + !metadata.isFile() + || metadata.nlink !== 1 + || metadata.uid !== currentOwner() + || (metadata.mode & 0o777) !== 0o600 + ) throw new LiveAcceptanceError("layout_changed"); + } +} + +export async function createLiveAcceptanceLayout( + options: Pick = {}, +): Promise { + const expectedHomeDirectory = process.env.HOME; + if ( + expectedHomeDirectory === undefined + || expectedHomeDirectory !== homedir() + || !isAbsolute(expectedHomeDirectory) + || resolve(expectedHomeDirectory) !== expectedHomeDirectory + ) throw new LiveAcceptanceError("home_changed"); + + const temporaryBase = await realpath(options.temporaryBaseDirectory ?? tmpdir()); + const baseMetadata = await stat(temporaryBase); + if (!baseMetadata.isDirectory()) throw new LiveAcceptanceError("layout_changed"); + assertSafeAcceptanceLocation(temporaryBase, expectedHomeDirectory); + + const runId = randomUUID(); + const runRoot = await createPrivateTemporaryDirectory( + join(temporaryBase, `hra-live-acceptance-${runId}-`), + ); + assertSafeAcceptanceLocation(runRoot.path, expectedHomeDirectory); + + try { + const resources = await Promise.all([ + createPrivateTemporaryDirectory(join(runRoot.path, "device-a-")) + .then((identity) => ({ identity, role: "device_a" as const })), + createPrivateTemporaryDirectory(join(runRoot.path, "device-b-")) + .then((identity) => ({ identity, role: "device_b" as const })), + createPrivateTemporaryDirectory(join(runRoot.path, "project-a-")) + .then((identity) => ({ identity, role: "project_a" as const })), + createPrivateTemporaryDirectory(join(runRoot.path, "project-b-")) + .then((identity) => ({ identity, role: "project_b" as const })), + ]); + for (const resource of resources) { + if (!isContainedDirectChild(runRoot.path, resource.identity.path)) { + throw new LiveAcceptanceError("layout_changed"); + } + } + const byRole = new Map(resources.map((resource) => [resource.role, resource.identity])); + const stateA = byRole.get("device_a"); + const stateB = byRole.get("device_b"); + const projectA = byRole.get("project_a"); + const projectB = byRole.get("project_b"); + if (stateA === undefined || stateB === undefined || projectA === undefined || projectB === undefined) { + throw new LiveAcceptanceError("layout_changed"); + } + const descriptor = ( + device: LiveAcceptanceDeviceName, + rootDirectory: string, + documentsDirectory: string, + ): AcceptanceInstallationDescriptor => acceptanceInstallationDescriptorSchema.parse({ + ...(options.cloudDeploymentUrl === undefined + ? {} + : { cloudDeploymentUrl: options.cloudDeploymentUrl }), + device, + documentsDirectory, + expectedHomeDirectory, + rootDirectory, + runId, + type: "hra-live-acceptance-device", + version: 1, + }); + const receiptPath = join(temporaryBase, `.hra-live-acceptance-${runId}.recovery.json`); + const layout = { + descriptors: { + a: descriptor("a", stateA.path, projectA.path), + b: descriptor("b", stateB.path, projectB.path), + }, + expectedHomeDirectory, + receiptPath, + resources, + runId, + runRoot, + } satisfies LiveAcceptanceLayout; + assertReceiptLayoutShape(initialReceipt(layout)); + return layout; + } catch (error: unknown) { + await rm(runRoot.path, { force: false, recursive: true }).catch(() => undefined); + throw error; + } +} + +export async function assertAcceptanceDescriptorLayout( + descriptorInput: unknown, +): Promise { + const descriptor = acceptanceInstallationDescriptorSchema.parse(descriptorInput); + if (process.env.HOME !== descriptor.expectedHomeDirectory) { + throw new LiveAcceptanceError("home_changed"); + } + const state = await observePrivateDirectory(descriptor.rootDirectory); + const project = await observePrivateDirectory(descriptor.documentsDirectory); + const runRootPath = dirname(state.path); + if ( + dirname(project.path) !== runRootPath + || !isContainedDirectChild(runRootPath, state.path) + || !isContainedDirectChild(runRootPath, project.path) + || state.path === project.path + || !basename(runRootPath).startsWith(`hra-live-acceptance-${descriptor.runId}-`) + || !basename(state.path).startsWith(`device-${descriptor.device}-`) + || !basename(project.path).startsWith(`project-${descriptor.device}-`) + ) throw new LiveAcceptanceError("layout_changed"); + await observePrivateDirectory(runRootPath); + assertSafeAcceptanceLocation(runRootPath, descriptor.expectedHomeDirectory); + return descriptor; +} + +const safeWorkerEnvironment = (expectedHomeDirectory: string): NodeJS.ProcessEnv => { + const allowed = [ + "LANG", + "LC_ALL", + "LC_CTYPE", + "LOGNAME", + "PATH", + "SHELL", + "USER", + ] as const; + const environment: NodeJS.ProcessEnv = { HOME: expectedHomeDirectory }; + for (const key of allowed) { + const value = process.env[key]; + if (value !== undefined) environment[key] = value; + } + return environment; +}; + +export type LiveAcceptanceWorkerLaunch = Readonly<{ + arguments: readonly [string]; + cwd: string; + environment: NodeJS.ProcessEnv; + executable: string; +}>; + +export function liveAcceptanceWorkerLaunch( + descriptorInput: AcceptanceInstallationDescriptor, +): LiveAcceptanceWorkerLaunch { + const descriptor = acceptanceInstallationDescriptorSchema.parse(descriptorInput); + return { + arguments: [join(import.meta.dir, "live-acceptance-worker.ts")], + cwd: resolve(import.meta.dir, ".."), + environment: safeWorkerEnvironment(descriptor.expectedHomeDirectory), + executable: process.execPath, + }; +} + +const asWritable = (value: unknown): Writable => { + if ( + value === null + || typeof value !== "object" + || !("write" in value) + || !("end" in value) + ) throw new LiveAcceptanceError("worker_failed"); + return value as Writable; +}; + +const asReadable = (value: unknown): Readable => { + if ( + value === null + || typeof value !== "object" + || !("on" in value) + ) throw new LiveAcceptanceError("worker_failed"); + return value as Readable; +}; + +const writeStreamDocument = async (stream: Writable, document: string, close: boolean): Promise => { + await new Promise((resolvePromise, rejectPromise) => { + const settle = (error?: Error | null): void => { + if (error === undefined || error === null) resolvePromise(); + else rejectPromise(error); + }; + if (close) stream.end(document, settle); + else stream.write(document, settle); + }); +}; + +type WorkerPending = + | Readonly<{ + kind: "ack"; + action: "resume" | "suspend"; + result: ReturnType; + }> + | Readonly<{ + kind: "cli"; + result: ReturnType>; + }> + | Readonly<{ + kind: "command"; + result: ReturnType>; + }>; + +class ProcessWorker implements LiveAcceptanceWorker { + readonly device: LiveAcceptanceDeviceName; + readonly pid: number; + readonly projectDirectory: string; + readonly #child: ChildProcess; + readonly #control: Writable; + readonly #descriptor: AcceptanceInstallationDescriptor; + readonly #lifetime = deferredSignal(); + readonly #ready = deferredSignal(); + readonly #stopped = deferredSignal(); + readonly #pending = new Map(); + #statusBuffer = Buffer.alloc(0); + #receivedStopped = false; + #receivedReady = false; + #statusEnded = false; + #terminalError: Error | undefined; + #stopOperation: Promise | undefined; + + private constructor( + descriptor: AcceptanceInstallationDescriptor, + child: ChildProcess, + control: Writable, + status: Readable, + ) { + if (child.pid === undefined) throw new LiveAcceptanceError("worker_failed"); + this.device = descriptor.device; + this.pid = child.pid; + this.projectDirectory = descriptor.documentsDirectory; + this.#descriptor = descriptor; + this.#child = child; + this.#control = control; + void this.#lifetime.promise.catch(() => undefined); + void this.#ready.promise.catch(() => undefined); + void this.#stopped.promise.catch(() => undefined); + status.on("data", (chunk: Buffer | string) => { + try { + this.#consumeStatus(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } catch { + this.#fail(new LiveAcceptanceError("worker_protocol_invalid")); + } + }); + status.once("error", () => this.#fail(new LiveAcceptanceError("worker_protocol_invalid"))); + status.once("end", () => { + this.#statusEnded = true; + if (this.#statusBuffer.byteLength !== 0 || !this.#receivedStopped) { + this.#fail(new LiveAcceptanceError("worker_protocol_invalid")); + } + }); + child.once("error", () => this.#fail(new LiveAcceptanceError("worker_failed"))); + child.once("exit", (code, signal) => { + if (code !== 0 || signal !== null) this.#fail(new LiveAcceptanceError("worker_failed")); + }); + child.once("close", (code, signal) => { + if (code !== 0 || signal !== null || !this.#receivedStopped || !this.#statusEnded) { + this.#fail(new LiveAcceptanceError("worker_failed")); + return; + } + this.#lifetime.resolve(); + }); + } + + static async start(descriptorInput: AcceptanceInstallationDescriptor): Promise { + const descriptor = acceptanceInstallationDescriptorSchema.parse(descriptorInput); + const launch = liveAcceptanceWorkerLaunch(descriptor); + const child = spawn(launch.executable, [...launch.arguments], { + cwd: launch.cwd, + env: launch.environment, + stdio: ["ignore", "ignore", "ignore", "pipe", "pipe", "pipe"], + }); + try { + const extendedStdio = child.stdio as Array; + const descriptorPipe = asWritable(extendedStdio[LIVE_ACCEPTANCE_DESCRIPTOR_FD]); + const control = asWritable(extendedStdio[LIVE_ACCEPTANCE_CONTROL_FD]); + const status = asReadable(extendedStdio[LIVE_ACCEPTANCE_STATUS_FD]); + const worker = new ProcessWorker(descriptor, child, control, status); + const serialized = `${JSON.stringify(descriptor)}\n`; + if (Buffer.byteLength(serialized, "utf8") > LIVE_ACCEPTANCE_DESCRIPTOR_MAXIMUM_BYTES) { + throw new LiveAcceptanceError("input_invalid"); + } + await writeStreamDocument(descriptorPipe, serialized, true); + return worker; + } catch (error: unknown) { + for (const pipe of (child.stdio as Array).slice(3)) { + pipe?.destroy(); + } + child.kill("SIGTERM"); + throw error; + } + } + + async ready(): Promise { + this.#assertHealthy(); + await boundedDeadline( + Promise.race([ + this.#ready.promise, + this.#lifetime.promise.then(() => { throw new LiveAcceptanceError("worker_failed"); }), + ]), + workerStartupDeadlineMs, + "worker_failed", + ); + this.#assertHealthy(); + } + + async command(commandInput: LocalCommand): Promise { + await this.ready(); + this.#assertHealthy(); + const command = localCommandSchema.parse(commandInput); + const requestId = randomUUID(); + const result = deferred(); + this.#pending.set(requestId, { kind: "command", result }); + const frame = `${JSON.stringify({ command, requestId, type: "command", version: 1 })}\n`; + try { + await this.#writeControl(frame, requestId); + return await boundedDeadline(result.promise, workerCommandDeadlineMs, "worker_failed"); + } finally { + this.#pending.delete(requestId); + } + } + + async execute( + argvInput: readonly string[], + options: Readonly<{ protectedDocument?: unknown }> = {}, + ): Promise { + await this.ready(); + this.#assertHealthy(); + const requestId = randomUUID(); + const result = deferred(); + const hasProtectedDocument = Object.hasOwn(options, "protectedDocument"); + if (hasProtectedDocument && options.protectedDocument === undefined) { + throw new LiveAcceptanceError("input_invalid"); + } + const control = liveAcceptanceWorkerControlSchema.parse({ + argv: [...argvInput], + ...(hasProtectedDocument + ? { protectedInput: { document: options.protectedDocument } } + : {}), + requestId, + type: "cli", + version: 1, + }); + const frame = `${JSON.stringify(control)}\n`; + this.#pending.set(requestId, { kind: "cli", result }); + try { + await this.#writeControl(frame, requestId); + return await boundedDeadline(result.promise, workerCommandDeadlineMs, "worker_failed"); + } finally { + this.#pending.delete(requestId); + } + } + + async suspend(): Promise { + await this.#workerAction("suspend"); + } + + async resume(): Promise { + await this.#workerAction("resume"); + } + + lifetime(): Promise { + return this.#lifetime.promise; + } + + failure(): Promise { + return this.#lifetime.promise.then( + () => new Promise(() => undefined), + (error: unknown) => Promise.reject(error), + ); + } + + async stop(): Promise { + if (this.#stopOperation !== undefined) return await this.#stopOperation; + this.#stopOperation = (async () => { + this.#assertHealthy(); + const requestId = randomUUID(); + const frame = `${JSON.stringify({ requestId, type: "stop", version: 1 })}\n`; + await this.#writeControl(frame); + await boundedDeadline(this.#stopped.promise, workerShutdownDeadlineMs, "daemon_shutdown_unproven"); + await boundedDeadline(this.#lifetime.promise, workerShutdownDeadlineMs, "daemon_shutdown_unproven"); + if (this.#terminalError !== undefined) throw this.#terminalError; + })(); + return await this.#stopOperation; + } + + async preserve(): Promise { + if (this.#child.exitCode !== null || this.#child.signalCode !== null) return; + this.#control.end(); + await boundedDeadline( + this.#lifetime.promise.catch(() => undefined), + workerShutdownDeadlineMs, + "daemon_shutdown_unproven", + ).catch(() => undefined); + } + + async #workerAction(action: "resume" | "suspend"): Promise { + await this.ready(); + this.#assertHealthy(); + const requestId = randomUUID(); + const result = deferredSignal(); + this.#pending.set(requestId, { action, kind: "ack", result }); + const frame = `${JSON.stringify({ requestId, type: action, version: 1 })}\n`; + try { + await this.#writeControl(frame, requestId); + await boundedDeadline(result.promise, workerShutdownDeadlineMs, "worker_failed"); + } finally { + this.#pending.delete(requestId); + } + this.#assertHealthy(); + } + + async #writeControl(frame: string, requestId?: string): Promise { + if (Buffer.byteLength(frame, "utf8") > LIVE_ACCEPTANCE_CONTROL_MAXIMUM_BYTES) { + if (requestId !== undefined) this.#pending.delete(requestId); + throw new LiveAcceptanceError("input_invalid"); + } + try { + await writeStreamDocument(this.#control, frame, false); + } catch { + const error = new LiveAcceptanceError("worker_failed"); + this.#fail(error); + throw error; + } + } + + #consumeStatus(chunk: Buffer): void { + this.#statusBuffer = Buffer.concat([this.#statusBuffer, chunk]); + if (this.#statusBuffer.byteLength > LIVE_ACCEPTANCE_STATUS_MAXIMUM_BYTES) { + throw new LiveAcceptanceError("worker_protocol_invalid"); + } + for (;;) { + const newline = this.#statusBuffer.indexOf(0x0a); + if (newline < 0) break; + const line = this.#statusBuffer.subarray(0, newline); + this.#statusBuffer = this.#statusBuffer.subarray(newline + 1); + if (line.byteLength === 0) throw new LiveAcceptanceError("worker_protocol_invalid"); + try { + const frame = liveAcceptanceWorkerStatusSchema.parse( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(line)) as unknown, + ); + this.#acceptStatus(frame); + } finally { + line.fill(0); + } + } + } + + #acceptStatus(frame: LiveAcceptanceWorkerStatus): void { + if (frame.type === "ready") { + if ( + this.#receivedReady + || this.#receivedStopped + || frame.device !== this.device + || frame.runId !== this.#descriptor.runId + || frame.pid !== this.pid + ) throw new LiveAcceptanceError("worker_protocol_invalid"); + this.#receivedReady = true; + this.#ready.resolve(); + return; + } + if (frame.type === "command_result") { + const pending = this.#pending.get(frame.requestId); + if (pending?.kind !== "command") throw new LiveAcceptanceError("worker_protocol_invalid"); + this.#pending.delete(frame.requestId); + pending.result.resolve(frame.response); + return; + } + if (frame.type === "cli_result") { + const pending = this.#pending.get(frame.requestId); + if (pending?.kind !== "cli") throw new LiveAcceptanceError("worker_protocol_invalid"); + this.#pending.delete(frame.requestId); + pending.result.resolve(frame.result); + return; + } + if (frame.type === "ack") { + const pending = this.#pending.get(frame.requestId); + if (pending?.kind !== "ack" || pending.action !== frame.action) { + throw new LiveAcceptanceError("worker_protocol_invalid"); + } + this.#pending.delete(frame.requestId); + pending.result.resolve(); + return; + } + if (frame.type === "stopped") { + if (frame.device !== this.device || frame.runId !== this.#descriptor.runId) { + throw new LiveAcceptanceError("worker_protocol_invalid"); + } + if (this.#receivedStopped) throw new LiveAcceptanceError("worker_protocol_invalid"); + this.#receivedStopped = true; + this.#stopped.resolve(); + return; + } + this.#fail(new LiveAcceptanceError("worker_failed")); + } + + #fail(error: Error): void { + if (this.#terminalError !== undefined) return; + this.#terminalError = error; + this.#ready.reject(error); + this.#stopped.reject(error); + this.#lifetime.reject(error); + for (const pending of this.#pending.values()) pending.result.reject(error); + this.#pending.clear(); + this.#control.end(); + } + + #assertHealthy(): void { + if (this.#terminalError !== undefined) throw this.#terminalError; + if (this.#child.exitCode !== null || this.#child.signalCode !== null) { + throw new LiveAcceptanceError("worker_failed"); + } + } +} + +const initialReceipt = ( + layout: LiveAcceptanceLayout, + workers: readonly LiveAcceptanceWorker[] = [], +): MutableReceipt => { + const now = Date.now(); + return liveAcceptanceRecoveryReceiptSchema.parse({ + checkpoint: workers.length === 0 ? "prepared" : "workers_starting", + ...(layout.descriptors.a.cloudDeploymentUrl === undefined + ? {} + : { cloudDeploymentUrl: layout.descriptors.a.cloudDeploymentUrl }), + createdAt: now, + expectedHomeDirectory: layout.expectedHomeDirectory, + phase: workers.length === 0 ? "prepared" : "workers_starting", + receiptPath: layout.receiptPath, + resources: layout.resources.map((resource) => ({ + identity: resource.identity, + role: resource.role, + status: "active", + })), + runId: layout.runId, + runRoot: layout.runRoot, + updatedAt: now, + version: 1, + workers: workers.map((worker) => ({ + device: worker.device, + pid: worker.pid, + state: "starting", + })), + }); +}; + +const updatePhase = async ( + receipt: AtomicRecoveryReceipt, + phase: z.infer, + transform: (current: MutableReceipt) => Partial = () => ({}), +): Promise => { + await receipt.update((current) => ({ + ...current, + ...transform(current), + checkpoint: phase, + failureCode: undefined, + phase, + updatedAt: Date.now(), + })); +}; + +const requireOk = (response: CommandResponse): unknown => { + if (!response.ok) throw new LiveAcceptanceError("worker_failed"); + return response.data; +}; + +const deviceListSchema = z.object({ + currentDevicePublicId: z.string().min(1).max(200), + devices: z.array(z.object({ + current: z.boolean(), + publicId: z.string().min(1).max(200), + status: z.enum(["pending", "active", "revoked"]), + }).passthrough()).min(1).max(1_024), +}).passthrough(); + +const cleanupAuthStatusSchema = z.object({ + configured: z.literal(true), + deletion: z.object({ + effectsDisabled: z.literal(true), + state: z.enum(["pending", "draining", "complete"]), + statusFresh: z.boolean(), + }).passthrough().optional(), + device: z.object({ + publicId: z.string().min(1).max(200), + status: z.enum(["pending", "active", "revoked"]).optional(), + }).passthrough().nullable().optional(), + email: z.string().email().min(3).max(320).optional(), + signedIn: z.boolean(), +}).passthrough(); + +const deletionSchema = z.object({ + deletion: z.object({ + effectsDisabled: z.literal(true), + state: z.enum(["pending", "draining", "complete"]), + statusFresh: z.boolean(), + }).passthrough(), +}).passthrough(); + +const authDeletionStatusSchema = z.object({ + configured: z.literal(true), + deletion: z.object({ + effectsDisabled: z.literal(true), + state: z.literal("complete"), + statusFresh: z.literal(true), + }).passthrough(), + signedIn: z.literal(false), +}).passthrough(); + +const accountListSchema = z.object({ + accounts: z.array(z.object({ + id: z.string().min(1).max(200), + state: z.enum([ + "signed_out", + "login_pending", + "signed_in", + "recovery_required", + "removed", + ]), + }).passthrough()).max(64), +}).strict(); + +const cleanupAccountSchema = z.object({ + id: z.string().min(1).max(200), + state: z.enum([ + "signed_out", + "login_pending", + "signed_in", + "recovery_required", + "removed", + ]), +}).passthrough(); + +const cleanupAccountResultSchema = z.object({ + account: cleanupAccountSchema, +}).passthrough(); + +const throwIfCleanupAborted = (signal?: AbortSignal): void => { + if (signal?.aborted) throw new LiveAcceptanceError("operator_interrupted"); +}; + +const waitForCleanupDelay = async ( + milliseconds: number, + signal?: AbortSignal, +): Promise => { + if (signal === undefined) { + await Bun.sleep(milliseconds); + return; + } + const activeSignal = signal; + throwIfCleanupAborted(activeSignal); + await new Promise((resolvePromise, rejectPromise) => { + const timer = setTimeout(settle, milliseconds); + const abort = () => settle(new LiveAcceptanceError("operator_interrupted")); + function settle(error?: Error): void { + clearTimeout(timer); + activeSignal.removeEventListener("abort", abort); + if (error === undefined) resolvePromise(); + else rejectPromise(error); + } + activeSignal.addEventListener("abort", abort, { once: true }); + if (activeSignal.aborted) abort(); + }); +}; + +function assertCurrentDeviceAndUniquePeers( + listed: z.infer, +): void { + const current = listed.devices.filter((device) => device.current); + if ( + current.length !== 1 + || current[0]?.publicId !== listed.currentDevicePublicId + || current[0].status !== "active" + || new Set(listed.devices.map((device) => device.publicId)).size !== listed.devices.length + ) throw new LiveAcceptanceError("cloud_revocation_unproven"); +} + +async function convergeCloudCleanupAdmission( + workerA: LiveAcceptanceWorker, + workerB: LiveAcceptanceWorker, + expectedInput: Readonly<{ idempotencyKey: string; publicId: string }> | null, + persistDerivedPeer: ( + expected: Readonly<{ idempotencyKey: string; publicId: string }>, + ) => Promise, + signal?: AbortSignal, +): Promise<"delete_identity" | "no_identity"> { + let expected = expectedInput; + throwIfCleanupAborted(signal); + const auth = cleanupAuthStatusSchema.parse(requireOk( + await workerA.command({ kind: "auth.status" }), + )); + throwIfCleanupAborted(signal); + if (auth.deletion !== undefined) { + if (auth.signedIn || expected !== null) { + throw new LiveAcceptanceError("cloud_revocation_unproven"); + } + return "delete_identity"; + } + if (!auth.signedIn) { + if (auth.device !== undefined && auth.device !== null) { + throw new LiveAcceptanceError("cloud_revocation_unproven"); + } + if (expected !== null) throw new LiveAcceptanceError("cloud_revocation_unproven"); + const authB = cleanupAuthStatusSchema.parse(requireOk( + await workerB.command({ kind: "auth.status" }), + )); + if (authB.signedIn || (authB.device !== undefined && authB.device !== null)) { + throw new LiveAcceptanceError("cloud_revocation_unproven"); + } + return "no_identity"; + } + if (auth.email === undefined) throw new LiveAcceptanceError("cloud_revocation_unproven"); + + let listed = deviceListSchema.parse(requireOk(await workerA.command({ kind: "device.list" }))); + throwIfCleanupAborted(signal); + assertCurrentDeviceAndUniquePeers(listed); + const peers = listed.devices.filter((device) => !device.current); + if (expected === null) { + const authB = cleanupAuthStatusSchema.parse(requireOk( + await workerB.command({ kind: "auth.status" }), + )); + if (authB.signedIn) { + if (authB.email !== auth.email) { + throw new LiveAcceptanceError("cloud_revocation_unproven"); + } + if (authB.device === undefined || authB.device === null) { + if (peers.length !== 0) { + throw new LiveAcceptanceError("cloud_revocation_unproven"); + } + return "delete_identity"; + } + const candidates = listed.devices.filter((device) => + !device.current && device.publicId === authB.device?.publicId); + const candidate = candidates[0]; + if ( + candidates.length !== 1 + || candidate === undefined + || authB.device.status !== candidate.status + || peers.length !== 1 + ) throw new LiveAcceptanceError("cloud_revocation_unproven"); + expected = { idempotencyKey: randomUUID(), publicId: candidate.publicId }; + // Persist the exact peer authority before honoring a concurrent abort or + // issuing the revocation effect. Recovery can then converge a lost pair + // response without guessing which hosted device belongs to this run. + await persistDerivedPeer(expected); + throwIfCleanupAborted(signal); + } else if ( + (authB.device !== undefined && authB.device !== null) + || peers.length !== 0 + ) { + throw new LiveAcceptanceError("cloud_revocation_unproven"); + } + } + if (expected === null) return "delete_identity"; + + if (peers.length !== 1 || peers[0]?.publicId !== expected.publicId) { + throw new LiveAcceptanceError("cloud_revocation_unproven"); + } + + const peer = listed.devices.filter((device) => device.publicId === expected.publicId); + const peerEntry = peer[0]; + if ( + expected.publicId === listed.currentDevicePublicId + || peer.length !== 1 + || peerEntry === undefined + || peerEntry.current + ) throw new LiveAcceptanceError("cloud_revocation_unproven"); + if (peerEntry.status !== "revoked") { + const revoked = z.object({ + device: z.object({ + publicId: z.literal(expected.publicId), + status: z.literal("revoked"), + }).passthrough(), + }).passthrough().parse(requireOk(await workerA.command({ + device: expected.publicId, + idempotencyKey: expected.idempotencyKey, + kind: "device.revoke", + }))); + if (revoked.device.publicId !== expected.publicId) { + throw new LiveAcceptanceError("cloud_revocation_unproven"); + } + throwIfCleanupAborted(signal); + listed = deviceListSchema.parse(requireOk(await workerA.command({ kind: "device.list" }))); + assertCurrentDeviceAndUniquePeers(listed); + const peersAfterRevocation = listed.devices.filter((device) => !device.current); + if (peersAfterRevocation.length !== 1 + || peersAfterRevocation[0]?.publicId !== expected.publicId) { + throw new LiveAcceptanceError("cloud_revocation_unproven"); + } + } + const provenPeer = listed.devices.filter((device) => device.publicId === expected.publicId); + if ( + provenPeer.length !== 1 + || provenPeer[0]?.current + || provenPeer[0]?.status !== "revoked" + ) throw new LiveAcceptanceError("cloud_revocation_unproven"); + return "delete_identity"; +} + +async function eraseCloudIdentity( + worker: LiveAcceptanceWorker, + options: CleanupOptions, +): Promise { + const deadline = Date.now() + (options.cloudDeletionDeadlineMs ?? cloudDeletionDeadlineMs); + let complete = false; + while (Date.now() <= deadline) { + throwIfCleanupAborted(options.signal); + const result = deletionSchema.parse(requireOk(await worker.command({ + acknowledgeErasure: true, + kind: "auth.delete", + }))); + throwIfCleanupAborted(options.signal); + if ( + result.deletion.state === "complete" + && result.deletion.statusFresh + ) { + complete = true; + break; + } + await waitForCleanupDelay( + options.cloudDeletionPollMs ?? cloudDeletionPollMs, + options.signal, + ); + } + if (!complete) throw new LiveAcceptanceError("cloud_deletion_unproven"); + throwIfCleanupAborted(options.signal); + authDeletionStatusSchema.parse(requireOk(await worker.command({ kind: "auth.status" }))); +} + +async function logoutEveryCodexAccount( + worker: LiveAcceptanceWorker, + signal?: AbortSignal, +): Promise { + throwIfCleanupAborted(signal); + let listed = accountListSchema.parse(requireOk(await worker.command({ kind: "account.list" }))); + for (const account of listed.accounts) { + throwIfCleanupAborted(signal); + let current = cleanupAccountSchema.parse(account); + if (current.state === "login_pending") { + const canceled = cleanupAccountResultSchema.parse(requireOk(await worker.command({ + kind: "account.login-cancel", + account: current.id, + }))); + if (canceled.account.id !== current.id) { + throw new LiveAcceptanceError("account_logout_unproven"); + } + current = canceled.account; + } + if (current.state === "recovery_required") { + const reconciled = cleanupAccountResultSchema.parse(requireOk(await worker.command({ + account: current.id, + kind: "account.show", + }))); + if (reconciled.account.id !== current.id) { + throw new LiveAcceptanceError("account_logout_unproven"); + } + current = reconciled.account; + } + if (current.state === "signed_in") { + requireOk(await worker.command({ + account: current.id, + idempotencyKey: randomUUID(), + kind: "account.logout", + })); + } else if (current.state !== "signed_out" && current.state !== "removed") { + throw new LiveAcceptanceError("account_logout_unproven"); + } + } + throwIfCleanupAborted(signal); + listed = accountListSchema.parse(requireOk(await worker.command({ kind: "account.list" }))); + if (listed.accounts.some((account) => account.state !== "signed_out" && account.state !== "removed")) { + throw new LiveAcceptanceError("account_logout_unproven"); + } +} + +async function assertAbsent(path: string): Promise { + if (await pathExists(path)) throw new LiveAcceptanceError("daemon_shutdown_unproven"); +} + +async function proveWorkerShutdown( + worker: LiveAcceptanceWorker, + descriptor: AcceptanceInstallationDescriptor, +): Promise { + if (processIsAlive(worker.pid)) throw new LiveAcceptanceError("daemon_shutdown_unproven"); + const paths = resolveStatePaths({ rootDirectory: descriptor.rootDirectory }); + if (await DaemonLock.isAuthorityHeld(paths)) { + throw new LiveAcceptanceError("daemon_shutdown_unproven"); + } + const receipt = await readDaemonAuthorityReceipt(paths); + if (receipt?.state !== "stopped" || receipt.pid !== worker.pid) { + throw new LiveAcceptanceError("daemon_shutdown_unproven"); + } + await Promise.all([assertAbsent(paths.socket), assertAbsent(paths.capability)]); +} + +async function markRecoveryRequired( + receipt: AtomicRecoveryReceipt, + code: z.infer, + workers: readonly LiveAcceptanceWorker[], +): Promise { + await receipt.update((current) => ({ + ...current, + failureCode: code, + phase: "recovery_required", + updatedAt: Date.now(), + workers: current.workers.map((entry) => { + const worker = workers.find((candidate) => candidate.device === entry.device); + if (worker === undefined) return entry; + return { + ...entry, + state: entry.state === "stopped" + ? "stopped" + : processIsAlive(worker.pid) + ? entry.state + : "failed", + }; + }), + })); +} + +function failureCode(error: unknown): z.infer { + if (error instanceof LiveAcceptanceError) { + if (recoveryFailureCodeSchema.safeParse(error.code).success) { + return error.code as z.infer; + } + if (error.code === "worker_protocol_invalid") return "worker_failed"; + } + return "worker_failed"; +} + +async function reconcileAndDeleteResource( + receipt: AtomicRecoveryReceipt, + role: ResourceRole, + signal?: AbortSignal, +): Promise { + throwIfCleanupAborted(signal); + await assertReceiptLayoutRuntime(receipt.value); + let current = receipt.value.resources.find((resource) => resource.role === role); + if (current === undefined) throw new LiveAcceptanceError("layout_changed"); + const runRoot = receipt.value.runRoot.path; + if (current.status === "active") { + await assertDirectoryIdentity(current.identity); + const quarantinePath = join( + runRoot, + `.hra-quarantine-${role}-${randomUUID()}`, + ); + if (!isContainedDirectChild(runRoot, quarantinePath) || await pathExists(quarantinePath)) { + throw new LiveAcceptanceError("layout_changed"); + } + await receipt.update((value) => ({ + ...value, + resources: value.resources.map((resource) => resource.role === role + ? { ...resource, quarantinePath, status: "quarantine_planned" } + : resource), + updatedAt: Date.now(), + })); + throwIfCleanupAborted(signal); + current = receipt.value.resources.find((resource) => resource.role === role); + } + if (current === undefined) throw new LiveAcceptanceError("layout_changed"); + if (current.status === "quarantine_planned") { + const quarantinePath = current.quarantinePath; + if (quarantinePath === undefined || !isContainedDirectChild(runRoot, quarantinePath)) { + throw new LiveAcceptanceError("layout_changed"); + } + const sourceExists = await pathExists(current.identity.path); + const quarantineExists = await pathExists(quarantinePath); + if (sourceExists && quarantineExists) throw new LiveAcceptanceError("layout_changed"); + if (sourceExists) { + throwIfCleanupAborted(signal); + await assertReceiptLayoutRuntime(receipt.value); + await assertDirectoryIdentity(current.identity); + await rename(current.identity.path, quarantinePath); + await syncDirectory(runRoot); + } + if (await pathExists(quarantinePath)) { + await assertReceiptLayoutRuntime(receipt.value); + const quarantined = await observePrivateDirectory(quarantinePath); + if ( + quarantined.device !== current.identity.device + || quarantined.inode !== current.identity.inode + || quarantined.owner !== current.identity.owner + ) throw new LiveAcceptanceError("layout_changed"); + await receipt.update((value) => ({ + ...value, + resources: value.resources.map((resource) => resource.role === role + ? { ...resource, status: "quarantined" } + : resource), + updatedAt: Date.now(), + })); + } else { + await receipt.update((value) => ({ + ...value, + resources: value.resources.map((resource) => resource.role === role + ? { ...resource, status: "deleted" } + : resource), + updatedAt: Date.now(), + })); + } + current = receipt.value.resources.find((resource) => resource.role === role); + } + if (current === undefined) throw new LiveAcceptanceError("layout_changed"); + if (current.status === "quarantined") { + const quarantinePath = current.quarantinePath; + if (quarantinePath === undefined || !isContainedDirectChild(runRoot, quarantinePath)) { + throw new LiveAcceptanceError("layout_changed"); + } + if (await pathExists(quarantinePath)) { + throwIfCleanupAborted(signal); + await assertReceiptLayoutRuntime(receipt.value); + const quarantined = await observePrivateDirectory(quarantinePath); + if ( + quarantined.device !== current.identity.device + || quarantined.inode !== current.identity.inode + || quarantined.owner !== current.identity.owner + ) throw new LiveAcceptanceError("layout_changed"); + await rm(quarantinePath, { force: false, recursive: true }); + await syncDirectory(runRoot); + } + await assertAbsentForCleanup(quarantinePath); + await receipt.update((value) => ({ + ...value, + resources: value.resources.map((resource) => resource.role === role + ? { ...resource, status: "deleted" } + : resource), + updatedAt: Date.now(), + })); + current = receipt.value.resources.find((resource) => resource.role === role); + } + if (current?.status !== "deleted") throw new LiveAcceptanceError("layout_changed"); + await assertAbsentForCleanup(current.identity.path); + if (current.quarantinePath !== undefined) await assertAbsentForCleanup(current.quarantinePath); +} + +async function assertAbsentForCleanup(path: string): Promise { + if (await pathExists(path)) throw new LiveAcceptanceError("layout_changed"); +} + +async function assertNoUnknownRunChildren(receipt: LiveAcceptanceRecoveryReceipt): Promise { + if (!await pathExists(receipt.runRoot.path)) return; + const allowed = new Set(); + for (const resource of receipt.resources) { + if (resource.status === "active" || resource.status === "quarantine_planned") { + allowed.add(basename(resource.identity.path)); + } + if ( + resource.quarantinePath !== undefined + && (resource.status === "quarantine_planned" || resource.status === "quarantined") + ) allowed.add(basename(resource.quarantinePath)); + } + const entries = await readdir(receipt.runRoot.path); + if (entries.some((entry) => !allowed.has(entry))) { + throw new LiveAcceptanceError("layout_changed"); + } +} + +async function deleteVerifiedLayout( + receipt: AtomicRecoveryReceipt, + signal?: AbortSignal, +): Promise { + throwIfCleanupAborted(signal); + await assertReceiptLayoutRuntime(receipt.value); + if (!await pathExists(receipt.value.runRoot.path)) { + if (receipt.value.resources.some((resource) => resource.status !== "deleted")) { + throw new LiveAcceptanceError("layout_changed"); + } + await receipt.remove(); + return; + } + await assertDirectoryIdentity(receipt.value.runRoot); + await assertNoUnknownRunChildren(receipt.value); + for (const role of resourceRoleSchema.options) { + throwIfCleanupAborted(signal); + await assertReceiptLayoutRuntime(receipt.value); + await assertNoUnknownRunChildren(receipt.value); + await reconcileAndDeleteResource(receipt, role, signal); + } + throwIfCleanupAborted(signal); + await updatePhase(receipt, "cleanup_quarantined"); + await assertDirectoryIdentity(receipt.value.runRoot); + const remaining = await readdir(receipt.value.runRoot.path); + if (remaining.length !== 0) throw new LiveAcceptanceError("layout_changed"); + throwIfCleanupAborted(signal); + await assertReceiptLayoutRuntime(receipt.value); + await rmdir(receipt.value.runRoot.path); + await syncDirectory(dirname(receipt.value.runRoot.path)); + await assertAbsentForCleanup(receipt.value.runRoot.path); + await receipt.remove(); +} + +export class LiveAcceptanceRun { + readonly recoveryReceiptPath: string; + readonly runId: string; + readonly #layout: LiveAcceptanceLayout; + readonly #receipt: AtomicRecoveryReceipt; + readonly #workers: Readonly>; + readonly #shutdownVerifier: NonNullable; + #abortRequested = false; + #cleanupOperation: Promise | undefined; + #preservationOperation: Promise<"cleanup_complete" | "recovery_required"> | undefined; + #preservedWorkers: Promise | undefined; + #terminalState: "active" | "cleanup_complete" | "recovery_required" = "active"; + + private constructor( + layout: LiveAcceptanceLayout, + receipt: AtomicRecoveryReceipt, + workers: Readonly>, + shutdownVerifier: NonNullable, + ) { + this.runId = layout.runId; + this.recoveryReceiptPath = layout.receiptPath; + this.#layout = layout; + this.#receipt = receipt; + this.#workers = workers; + this.#shutdownVerifier = shutdownVerifier; + } + + static async start(options: StartOptions = {}): Promise { + const originalHome = process.env.HOME; + const layout = await createLiveAcceptanceLayout(options); + if (process.env.HOME !== originalHome) throw new LiveAcceptanceError("home_changed"); + const receipt = await AtomicRecoveryReceipt.create(initialReceipt(layout)); + const factory = options.workerFactory + ?? (async (descriptor) => await ProcessWorker.start(descriptor)); + const started: LiveAcceptanceWorker[] = []; + try { + const results = await Promise.allSettled([ + factory(layout.descriptors.a), + factory(layout.descriptors.b), + ]); + for (const result of results) { + if (result.status === "fulfilled") started.push(result.value); + } + await receipt.update((current) => ({ + ...current, + checkpoint: "workers_starting", + phase: "workers_starting", + updatedAt: Date.now(), + workers: started.map((worker) => ({ + device: worker.device, + pid: worker.pid, + state: "starting", + })), + })); + const failed = results.find((result) => result.status === "rejected"); + if (failed?.status === "rejected") throw failed.reason; + const workerA = results[0].status === "fulfilled" ? results[0].value : undefined; + const workerB = results[1].status === "fulfilled" ? results[1].value : undefined; + if (workerA === undefined || workerB === undefined) { + throw new LiveAcceptanceError("worker_failed"); + } + await Promise.all(started.map(async (worker) => await worker.ready())); + if (process.env.HOME !== originalHome) throw new LiveAcceptanceError("home_changed"); + await receipt.update((current) => ({ + ...current, + checkpoint: "workers_ready", + phase: "workers_ready", + updatedAt: Date.now(), + workers: current.workers.map((worker) => ({ ...worker, state: "ready" })), + })); + return new LiveAcceptanceRun( + layout, + receipt, + { a: workerA, b: workerB }, + options.shutdownVerifier ?? proveWorkerShutdown, + ); + } catch (error: unknown) { + await Promise.allSettled(started.map(async (worker) => await worker.preserve())); + const code = failureCode(error); + await markRecoveryRequired(receipt, code, started).catch(() => undefined); + throw new LiveAcceptanceStartError(code, layout.receiptPath, layout.runId); + } + } + + static async resume( + receipt: AtomicRecoveryReceipt, + options: Pick = {}, + ): Promise { + const layout = layoutFromReceipt(receipt.value); + if (receipt.value.workers.some((worker) => processIsAlive(worker.pid))) { + throw new LiveAcceptanceError("daemon_shutdown_unproven"); + } + if (receipt.value.resources.some((resource) => resource.status !== "active")) { + throw new LiveAcceptanceError("layout_changed"); + } + const factory = options.workerFactory + ?? (async (descriptor) => await ProcessWorker.start(descriptor)); + const started: LiveAcceptanceWorker[] = []; + try { + const results = await Promise.allSettled([ + factory(layout.descriptors.a), + factory(layout.descriptors.b), + ]); + for (const result of results) { + if (result.status === "fulfilled") started.push(result.value); + } + await receipt.update((current) => ({ + ...current, + phase: "recovery_required", + updatedAt: Date.now(), + workers: started.map((worker) => ({ + device: worker.device, + pid: worker.pid, + state: "starting", + })), + })); + const failed = results.find((result) => result.status === "rejected"); + if (failed?.status === "rejected") throw failed.reason; + const workerA = results[0].status === "fulfilled" ? results[0].value : undefined; + const workerB = results[1].status === "fulfilled" ? results[1].value : undefined; + if (workerA === undefined || workerB === undefined) { + throw new LiveAcceptanceError("worker_failed"); + } + await Promise.all(started.map(async (worker) => await worker.ready())); + const recoveredCheckpoint = checkpointAtLeast(receipt.value.checkpoint, "workers_ready") + ? receipt.value.checkpoint + : "workers_ready"; + await receipt.update((current) => ({ + ...current, + checkpoint: recoveredCheckpoint, + failureCode: undefined, + phase: recoveredCheckpoint, + updatedAt: Date.now(), + workers: current.workers.map((worker) => ({ ...worker, state: "ready" })), + })); + return new LiveAcceptanceRun( + layout, + receipt, + { a: workerA, b: workerB }, + options.shutdownVerifier ?? proveWorkerShutdown, + ); + } catch (error: unknown) { + await Promise.allSettled(started.map(async (worker) => await worker.preserve())); + await markRecoveryRequired(receipt, failureCode(error), started).catch(() => undefined); + throw error; + } + } + + device(device: LiveAcceptanceDeviceName): LiveAcceptanceDevice { + const worker = this.#workers[device]; + return { + device, + execute: async (argv, options) => await worker.execute(argv, options), + projectDirectory: worker.projectDirectory, + resume: async () => await worker.resume(), + suspend: async () => await worker.suspend(), + }; + } + + async bindExpectedRevokedPeer(publicIdInput: string): Promise { + if ( + this.#abortRequested + || this.#cleanupOperation !== undefined + || this.#terminalState !== "active" + || checkpointAtLeast( + this.#receipt.value.checkpoint, + "cleanup_revocation_proven", + ) + ) throw new LiveAcceptanceError("cloud_revocation_unproven"); + const publicId = z.string().min(1).max(200).parse(publicIdInput); + const idempotencyKey = randomUUID(); + await this.#receipt.update((current) => { + if ( + current.expectedRevokedPeerPublicId !== undefined + && current.expectedRevokedPeerPublicId !== publicId + ) throw new LiveAcceptanceError("cloud_revocation_unproven"); + return { + ...current, + expectedRevocationIdempotencyKey: + current.expectedRevocationIdempotencyKey ?? idempotencyKey, + expectedRevokedPeerPublicId: publicId, + updatedAt: Date.now(), + }; + }); + } + + workerFailure(): Promise { + return Promise.race([ + this.#workers.a.failure(), + this.#workers.b.failure(), + ]); + } + + async cleanup(options: CleanupOptions = {}): Promise { + if (this.#terminalState === "cleanup_complete") return; + if (this.#terminalState === "recovery_required" || this.#abortRequested) { + throw new LiveAcceptanceError(this.#receipt.value.failureCode ?? "operator_interrupted"); + } + if (this.#cleanupOperation !== undefined) return await this.#cleanupOperation; + this.#cleanupOperation = this.#performCleanup(options); + return await this.#cleanupOperation; + } + + requestAbort(): void { + if (this.#terminalState !== "active") return; + this.#abortRequested = true; + void this.#preserveWorkers(); + } + + async #performCleanup(options: CleanupOptions): Promise { + const originalHome = process.env.HOME; + try { + throwIfCleanupAborted(options.signal); + if (!checkpointAtLeast(this.#receipt.value.checkpoint, "cleanup_revocation_proven")) { + const expectedRevokedPeerPublicId = this.#receipt.value.expectedRevokedPeerPublicId; + const expectedRevocationIdempotencyKey = + this.#receipt.value.expectedRevocationIdempotencyKey; + if ( + (expectedRevokedPeerPublicId === undefined) + !== (expectedRevocationIdempotencyKey === undefined) + ) { + throw new LiveAcceptanceError("cloud_revocation_unproven"); + } + const cloudCleanupMode = await convergeCloudCleanupAdmission( + this.#workers.a, + this.#workers.b, + expectedRevokedPeerPublicId === undefined + ? null + : expectedRevocationIdempotencyKey === undefined + ? null + : { + idempotencyKey: expectedRevocationIdempotencyKey, + publicId: expectedRevokedPeerPublicId, + }, + async (derived) => { + await this.#receipt.update((current) => { + if ( + current.expectedRevokedPeerPublicId !== undefined + || current.expectedRevocationIdempotencyKey !== undefined + ) { + if ( + current.expectedRevokedPeerPublicId !== derived.publicId + || current.expectedRevocationIdempotencyKey !== derived.idempotencyKey + ) throw new LiveAcceptanceError("cloud_revocation_unproven"); + return current; + } + return { + ...current, + expectedRevocationIdempotencyKey: derived.idempotencyKey, + expectedRevokedPeerPublicId: derived.publicId, + updatedAt: Date.now(), + }; + }); + }, + options.signal, + ); + throwIfCleanupAborted(options.signal); + await updatePhase(this.#receipt, "cleanup_revocation_proven", () => ({ + cloudCleanupMode, + })); + } + if (!checkpointAtLeast(this.#receipt.value.checkpoint, "cleanup_cloud_erased")) { + if (this.#receipt.value.cloudCleanupMode === undefined) { + throw new LiveAcceptanceError("cloud_deletion_unproven"); + } + if (this.#receipt.value.cloudCleanupMode === "delete_identity") { + await eraseCloudIdentity(this.#workers.a, options); + } + throwIfCleanupAborted(options.signal); + await updatePhase(this.#receipt, "cleanup_cloud_erased"); + } + if (!checkpointAtLeast(this.#receipt.value.checkpoint, "cleanup_codex_logged_out")) { + await Promise.all([ + logoutEveryCodexAccount(this.#workers.a, options.signal), + logoutEveryCodexAccount(this.#workers.b, options.signal), + ]); + throwIfCleanupAborted(options.signal); + await updatePhase(this.#receipt, "cleanup_codex_logged_out"); + } + if (!checkpointAtLeast(this.#receipt.value.checkpoint, "cleanup_daemons_stopped")) { + throwIfCleanupAborted(options.signal); + await Promise.all([this.#workers.a.stop(), this.#workers.b.stop()]); + await Promise.all([ + this.#shutdownVerifier(this.#workers.a, this.#layout.descriptors.a), + this.#shutdownVerifier(this.#workers.b, this.#layout.descriptors.b), + ]); + await this.#receipt.update((current) => ({ + ...current, + checkpoint: "cleanup_daemons_stopped", + phase: "cleanup_daemons_stopped", + updatedAt: Date.now(), + workers: current.workers.map((worker) => ({ ...worker, state: "stopped" })), + })); + } + if (process.env.HOME !== originalHome || originalHome !== this.#layout.expectedHomeDirectory) { + throw new LiveAcceptanceError("home_changed"); + } + throwIfCleanupAborted(options.signal); + await deleteVerifiedLayout(this.#receipt, options.signal); + this.#terminalState = "cleanup_complete"; + } catch (error: unknown) { + await this.#preserveWorkers(); + await markRecoveryRequired( + this.#receipt, + failureCode(error), + [this.#workers.a, this.#workers.b], + ).catch(() => undefined); + this.#terminalState = "recovery_required"; + throw error; + } + } + + async preserveForRecovery( + code: z.infer = "operator_interrupted", + ): Promise<"cleanup_complete" | "recovery_required"> { + if (this.#terminalState !== "active") return this.#terminalState; + if (this.#preservationOperation !== undefined) return await this.#preservationOperation; + this.requestAbort(); + this.#preservationOperation = (async () => { + await this.#preserveWorkers(); + await this.#cleanupOperation?.catch(() => undefined); + if (this.#terminalState !== "active") return this.#terminalState; + await markRecoveryRequired( + this.#receipt, + code, + [this.#workers.a, this.#workers.b], + ); + this.#terminalState = "recovery_required"; + return this.#terminalState; + })(); + return await this.#preservationOperation; + } + + #preserveWorkers(): Promise { + this.#preservedWorkers ??= Promise.allSettled([ + this.#workers.a.preserve(), + this.#workers.b.preserve(), + ]).then(() => undefined); + return this.#preservedWorkers; + } +} + +function layoutFromReceipt(receipt: LiveAcceptanceRecoveryReceipt): LiveAcceptanceLayout { + assertReceiptLayoutShape(receipt); + const resource = (role: ResourceRole): DirectoryIdentity => { + const found = receipt.resources.find((candidate) => candidate.role === role); + if (found === undefined) throw new LiveAcceptanceError("layout_changed"); + return found.identity; + }; + const descriptor = ( + device: LiveAcceptanceDeviceName, + rootDirectory: string, + documentsDirectory: string, + ): AcceptanceInstallationDescriptor => acceptanceInstallationDescriptorSchema.parse({ + ...(receipt.cloudDeploymentUrl === undefined + ? {} + : { cloudDeploymentUrl: receipt.cloudDeploymentUrl }), + device, + documentsDirectory, + expectedHomeDirectory: receipt.expectedHomeDirectory, + rootDirectory, + runId: receipt.runId, + type: "hra-live-acceptance-device", + version: 1, + }); + return { + descriptors: { + a: descriptor("a", resource("device_a").path, resource("project_a").path), + b: descriptor("b", resource("device_b").path, resource("project_b").path), + }, + expectedHomeDirectory: receipt.expectedHomeDirectory, + receiptPath: receipt.receiptPath, + resources: receipt.resources.map((entry) => ({ + identity: entry.identity, + role: entry.role, + })), + runId: receipt.runId, + runRoot: receipt.runRoot, + }; +} + +export async function startLiveAcceptanceRun(options: StartOptions = {}): Promise { + return await LiveAcceptanceRun.start(options); +} + +export async function resumeVerifiedLayoutDeletion( + receiptDocument: unknown, + signal?: AbortSignal, +): Promise { + const receipt = await AtomicRecoveryReceipt.open(receiptDocument); + if (process.env.HOME !== receipt.value.expectedHomeDirectory) { + throw new LiveAcceptanceError("home_changed"); + } + if (!checkpointAtLeast(receipt.value.checkpoint, "cleanup_daemons_stopped")) { + throw new LiveAcceptanceError("daemon_shutdown_unproven"); + } + if (receipt.value.workers.some((worker) => processIsAlive(worker.pid))) { + throw new LiveAcceptanceError("daemon_shutdown_unproven"); + } + await deleteVerifiedLayout(receipt, signal); +} + +export async function resumeLiveAcceptanceCleanup( + receiptDocument: unknown, + options: CleanupOptions & Pick = {}, +): Promise { + const receipt = await AtomicRecoveryReceipt.open(receiptDocument); + if (process.env.HOME !== receipt.value.expectedHomeDirectory) { + throw new LiveAcceptanceError("home_changed"); + } + if (receipt.value.workers.some((worker) => processIsAlive(worker.pid))) { + throw new LiveAcceptanceError("daemon_shutdown_unproven"); + } + if (checkpointAtLeast(receipt.value.checkpoint, "cleanup_daemons_stopped")) { + await deleteVerifiedLayout(receipt, options.signal); + return; + } + const run = await LiveAcceptanceRun.resume(receipt, options); + await run.cleanup(options); +} + +export function readLiveAcceptanceRecoveryReceiptFromFd(fd: number): LiveAcceptanceRecoveryReceipt { + if (!Number.isSafeInteger(fd) || fd < 3 || fd > 255 || isatty(fd)) { + throw new LiveAcceptanceError("input_invalid"); + } + const maximum = 32 * 1024; + const chunks: Buffer[] = []; + let total = 0; + try { + for (;;) { + const remaining = maximum + 1 - total; + if (remaining <= 0) throw new LiveAcceptanceError("input_invalid"); + const chunk = Buffer.allocUnsafe(Math.min(4 * 1024, remaining)); + const count = readSync(fd, chunk, 0, chunk.byteLength, null); + if (count === 0) { + chunk.fill(0); + break; + } + chunks.push(chunk.subarray(0, count)); + total += count; + if (total > maximum) throw new LiveAcceptanceError("input_invalid"); + } + if (total === 0) throw new LiveAcceptanceError("input_invalid"); + const bytes = Buffer.concat(chunks, total); + try { + return liveAcceptanceRecoveryReceiptSchema.parse( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown, + ); + } finally { + bytes.fill(0); + } + } catch (error: unknown) { + if (error instanceof LiveAcceptanceError) throw error; + throw new LiveAcceptanceError("input_invalid"); + } finally { + for (const chunk of chunks) chunk.fill(0); + } +} + +const sourceGitOutput = async (arguments_: readonly string[]): Promise => { + const repositoryRoot = resolve(import.meta.dir, ".."); + return await new Promise((resolvePromise, rejectPromise) => { + execFile( + "git", + [...arguments_], + { + cwd: repositoryRoot, + encoding: "utf8", + maxBuffer: 64 * 1024, + timeout: 5_000, + }, + (error, stdout, stderr) => { + if ( + error !== null + || stderr !== "" + || typeof stdout !== "string" + ) { + rejectPromise(new LiveAcceptanceError("input_invalid")); + return; + } + resolvePromise(stdout); + }, + ); + }); +}; + +export const liveAcceptanceSourceAttestation = async ( + cloudDeploymentUrlInput: string, +): Promise> => { + if (cloudDeploymentUrlInput !== DEFAULT_CLOUD_DEPLOYMENT_URL) { + throw new LiveAcceptanceError("input_invalid"); + } + const [revisionOutput, statusOutput] = await Promise.all([ + sourceGitOutput(["rev-parse", "--verify", "HEAD^{commit}"]), + sourceGitOutput(["status", "--porcelain=v1", "--untracked-files=all"]), + ]); + const sourceRevision = revisionOutput.trim(); + if (!/^[a-f0-9]{40}$/u.test(sourceRevision) || statusOutput !== "") { + throw new LiveAcceptanceError("input_invalid"); + } + return { + cloudTargetDigest: createHash("sha256") + .update(cloudDeploymentUrlInput, "utf8") + .digest("hex"), + packageVersion: HRA_VERSION, + sourceRevision, + }; +}; + +export const liveAcceptanceMain = async ( + arguments_: readonly string[] = Bun.argv.slice(2), +): Promise => { + if (arguments_.length === 2 && arguments_[0] === "--resume-fd") { + const rawFd = arguments_[1]; + if (rawFd === undefined || !/^[0-9]+$/u.test(rawFd)) { + process.stderr.write("hra live acceptance: invalid protected descriptor\n"); + return 2; + } + const resumeAbort = new AbortController(); + const stopResume = () => resumeAbort.abort(new LiveAcceptanceError("operator_interrupted")); + process.once("SIGINT", stopResume); + process.once("SIGTERM", stopResume); + try { + await resumeLiveAcceptanceCleanup( + readLiveAcceptanceRecoveryReceiptFromFd(Number(rawFd)), + { signal: resumeAbort.signal }, + ); + process.stdout.write(`${JSON.stringify({ ok: true, status: "cleanup_complete", version: 1 })}\n`); + return 0; + } catch { + process.stderr.write("hra live acceptance: cleanup remains recovery-required\n"); + return 1; + } finally { + process.off("SIGINT", stopResume); + process.off("SIGTERM", stopResume); + } + } + if ( + arguments_.length !== 2 + || arguments_[0] !== "--scenario-fd" + || arguments_[1] === undefined + || !/^[0-9]+$/u.test(arguments_[1]) + ) { + process.stderr.write( + "hra live acceptance: pass one explicit candidate configuration through --scenario-fd \n", + ); + return 2; + } + const scenarioFd = Number(arguments_[1]); + let run: LiveAcceptanceRun | undefined; + const interruption = deferredSignal(); + const scenarioAbort = new AbortController(); + const stop = () => { + if (!scenarioAbort.signal.aborted) { + scenarioAbort.abort(new LiveAcceptanceError("operator_interrupted")); + } + run?.requestAbort(); + interruption.resolve(); + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + try { + const scenarioModule = await import("./live-acceptance-scenario"); + const configuration = scenarioModule.readLiveAcceptanceScenarioConfigurationFromFd( + scenarioFd, + ); + if ( + configuration.operator.kind === "jsonl" + && (scenarioFd === scenarioModule.liveAcceptanceScenarioFixedOperatorFds.input + || scenarioFd === scenarioModule.liveAcceptanceScenarioFixedOperatorFds.output) + ) throw new LiveAcceptanceError("input_invalid"); + if ( + configuration.operator.kind === "terminal" + && (!process.stdin.isTTY || !process.stderr.isTTY) + ) throw new LiveAcceptanceError("input_invalid"); + const operator = scenarioModule.createLiveAcceptanceScenarioOperator(configuration); + const attestation = await liveAcceptanceSourceAttestation(configuration.cloudDeploymentUrl); + if (scenarioAbort.signal.aborted) throw new LiveAcceptanceError("operator_interrupted"); + run = await startLiveAcceptanceRun({ + cloudDeploymentUrl: configuration.cloudDeploymentUrl, + }); + const activeRun = run; + const scenario = scenarioModule.runLiveAcceptanceScenario( + activeRun, + operator, + attestation, + { signal: scenarioAbort.signal }, + ); + void scenario.catch(() => undefined); + const outcome = await Promise.race([ + scenario.then((evidence) => ({ evidence, type: "complete" as const })), + interruption.promise.then(() => ({ type: "interrupted" as const })), + activeRun.workerFailure().then(() => { + throw new LiveAcceptanceError("worker_failed"); + }), + ]); + if (outcome.type === "interrupted") { + const preservation = await activeRun.preserveForRecovery("operator_interrupted"); + const settlement = await boundedDeadline( + scenario.then( + (evidence) => ({ evidence, type: "complete" as const }), + () => ({ type: "failed" as const }), + ), + 5_000, + "worker_failed", + ).catch(() => ({ type: "failed" as const })); + if (preservation === "cleanup_complete" && settlement.type === "complete") { + process.stdout.write(`${JSON.stringify({ + evidence: settlement.evidence, + ok: true, + status: "passed", + version: 1, + })}\n`); + return 0; + } + if (preservation === "cleanup_complete") { + process.stdout.write(`${JSON.stringify({ + ok: false, + recoveryReceiptRetained: false, + runId: activeRun.runId, + status: "evidence_unavailable_after_cleanup", + version: 1, + })}\n`); + return 1; + } + process.stdout.write(`${JSON.stringify({ + ok: false, + recoveryReceiptPath: activeRun.recoveryReceiptPath, + recoveryReceiptRetained: true, + runId: activeRun.runId, + status: "recovery_required", + version: 1, + })}\n`); + return 75; + } + process.stdout.write(`${JSON.stringify({ + evidence: outcome.evidence, + ok: true, + status: "passed", + version: 1, + })}\n`); + return 0; + } catch (error: unknown) { + const failedRun = run; + const operatorInterrupted = scenarioAbort.signal.aborted + && scenarioAbort.signal.reason instanceof LiveAcceptanceError + && scenarioAbort.signal.reason.code === "operator_interrupted"; + if (!scenarioAbort.signal.aborted) { + scenarioAbort.abort(new LiveAcceptanceError("worker_failed")); + } + failedRun?.requestAbort(); + const preservation = failedRun === undefined + ? undefined + : await failedRun.preserveForRecovery( + operatorInterrupted ? "operator_interrupted" : "worker_failed", + ).catch(() => undefined); + if (failedRun !== undefined && preservation === "cleanup_complete") { + process.stdout.write(`${JSON.stringify({ + ok: false, + recoveryReceiptRetained: false, + runId: failedRun.runId, + status: "evidence_unavailable_after_cleanup", + version: 1, + })}\n`); + return 1; + } + const recovery = failedRun + ?? (error instanceof LiveAcceptanceStartError ? error : undefined); + if (recovery !== undefined) { + process.stdout.write(`${JSON.stringify({ + ok: false, + recoveryReceiptPath: recovery.recoveryReceiptPath, + recoveryReceiptRetained: true, + runId: recovery.runId, + status: "recovery_required", + version: 1, + })}\n`); + } else { + process.stderr.write("hra live acceptance: startup failed safely\n"); + } + return operatorInterrupted ? 75 : 1; + } finally { + process.off("SIGINT", stop); + process.off("SIGTERM", stop); + } +}; + +if (import.meta.main) process.exitCode = await liveAcceptanceMain(); diff --git a/scripts/manage-hosted-admission.test.ts b/scripts/manage-hosted-admission.test.ts new file mode 100644 index 0000000..cca087f --- /dev/null +++ b/scripts/manage-hosted-admission.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test"; + +import type { CommandRequest, CommandRunner } from "./configure-hosted-sync"; +import { + executeHostedAdmission, + manageHostedAdmission, + parseAdmissionArguments, +} from "./manage-hosted-admission"; +import { + HRA_CONVEX_PROJECT_ID, + HRA_CONVEX_TEAM_ID, + type ConvexTarget, + type ConvexTargetVerifier, +} from "./convex-target"; + +const target: ConvexTarget = { + deploymentId: 7_654_321, + deploymentName: "steady-otter-321", + deploymentUrl: "https://steady-otter-321.convex.cloud", + projectId: HRA_CONVEX_PROJECT_ID, + teamId: HRA_CONVEX_TEAM_ID, +}; +const targetArguments = [ + "--deployment", target.deploymentName, + "--team-id", String(target.teamId), + "--project-id", String(target.projectId), + "--deployment-id", String(target.deploymentId), + "--deployment-url", target.deploymentUrl, +] as const; +const mutationId = "018bcfe5-6800-7000-8000-000000000930"; + +const outputWriter = (chunks: string[]): Pick => ({ + write(chunk: string | Uint8Array): boolean { + chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }, +}); + +const verifier = (calls: ConvexTarget[]): ConvexTargetVerifier => async (value) => { + calls.push(value); + expect(value).toEqual(target); +}; + +describe("hosted auth admission operator", () => { + test("parses status, freeze, and explicitly acknowledged resume", () => { + expect(parseAdmissionArguments(["status", ...targetArguments])).toEqual({ + action: { kind: "status" }, + target, + }); + expect(parseAdmissionArguments([ + "freeze", + "--expected-generation", "0", + "--mutation-id", mutationId, + ...targetArguments, + ])).toMatchObject({ action: { expectedGeneration: 0, kind: "freeze", mutationId } }); + expect(() => parseAdmissionArguments([ + "resume", + "--expected-generation", "1", + "--mutation-id", mutationId, + ...targetArguments, + ])).toThrow("usage_invalid"); + expect(parseAdmissionArguments([ + "resume", + "--expected-generation", "1", + "--mutation-id", mutationId, + "--acknowledge-resume", + ...targetArguments, + ])).toMatchObject({ action: { expectedGeneration: 1, kind: "resume", mutationId } }); + }); + + test("freezes with exact target checks and strict postflight", async () => { + const requests: CommandRequest[] = []; + const results = [ + { exitCode: 0, stderr: "", stdout: '{"generation":0,"state":"open","updatedAt":1}' }, + { exitCode: 0, stderr: "", stdout: '{"changed":true,"generation":1,"replay":false,"state":"frozen","updatedAt":2}' }, + { exitCode: 0, stderr: "", stdout: '{"generation":1,"state":"frozen","updatedAt":2}' }, + ]; + const runner: CommandRunner = async (request) => { + requests.push(request); + return results.shift()!; + }; + const verifications: ConvexTarget[] = []; + expect(await manageHostedAdmission({ + action: { expectedGeneration: 0, kind: "freeze", mutationId }, + environment: { CONVEX_DEPLOY_KEY: "secret", PATH: "/safe/bin" }, + runner, + target, + verifyTarget: verifier(verifications), + })).toMatchObject({ generation: 1, state: "frozen" }); + expect(verifications).toHaveLength(4); + expect(requests).toHaveLength(3); + expect(requests[1]?.arguments).toContain(JSON.stringify({ + expectedGeneration: 0, + mutationId, + state: "frozen", + })); + expect(requests.every((request) => request.stdin === "")).toBe(true); + expect(JSON.stringify(requests)).not.toContain("secret"); + }); + + test("replays the same mutation after a lost response", async () => { + const results = [ + { exitCode: 0, stderr: "", stdout: '{"generation":1,"state":"frozen","updatedAt":2}' }, + { exitCode: 0, stderr: "", stdout: '{"changed":true,"generation":1,"replay":true,"state":"frozen","updatedAt":2}' }, + { exitCode: 0, stderr: "", stdout: '{"generation":1,"state":"frozen","updatedAt":2}' }, + ]; + expect(await manageHostedAdmission({ + action: { expectedGeneration: 0, kind: "freeze", mutationId }, + runner: async () => results.shift()!, + target, + verifyTarget: async () => undefined, + })).toMatchObject({ generation: 1, state: "frozen" }); + }); + + test("performs target postflight when a committed transition response is lost or malformed", async () => { + for (const transitionResult of [ + { exitCode: 1, stderr: "lost", stdout: "" }, + { exitCode: 0, stderr: "", stdout: "{}\n{}" }, + ]) { + const results = [ + { exitCode: 0, stderr: "", stdout: '{"generation":0,"state":"open","updatedAt":1}' }, + transitionResult, + ]; + let verifications = 0; + await expect(manageHostedAdmission({ + action: { expectedGeneration: 0, kind: "freeze", mutationId }, + runner: async () => results.shift()!, + target, + verifyTarget: async () => { verifications += 1; }, + })).rejects.toThrow( + transitionResult.exitCode === 0 ? "provider_result_invalid" : "transition_refused", + ); + expect(verifications).toBe(3); + } + }); + + test("refuses stale generation, ambiguous provider output, and target mismatch", async () => { + await expect(manageHostedAdmission({ + action: { expectedGeneration: 0, kind: "freeze", mutationId }, + runner: async () => ({ + exitCode: 0, + stderr: "", + stdout: '{"generation":3,"state":"open","updatedAt":1}', + }), + target, + verifyTarget: async () => undefined, + })).rejects.toThrow("transition_refused"); + await expect(manageHostedAdmission({ + action: { expectedGeneration: 1, kind: "freeze", mutationId }, + runner: async () => ({ + exitCode: 0, + stderr: "", + stdout: '{"generation":1,"state":"frozen","updatedAt":1}', + }), + target, + verifyTarget: async () => undefined, + })).rejects.toThrow("transition_refused"); + await expect(manageHostedAdmission({ + action: { kind: "status" }, + runner: async () => ({ exitCode: 0, stderr: "", stdout: "{}\n{}" }), + target, + verifyTarget: async () => undefined, + })).rejects.toThrow("provider_result_invalid"); + await expect(manageHostedAdmission({ + action: { kind: "status" }, + runner: async () => ({ exitCode: 0, stderr: "", stdout: "{}" }), + target, + verifyTarget: async () => { throw new Error("wrong target"); }, + })).rejects.toThrow("wrong target"); + await expect(manageHostedAdmission({ + action: { kind: "status" }, + runner: async () => ({ + exitCode: 0, + stderr: "", + stdout: '{"generation":9007199254740992,"state":"open","updatedAt":1}', + }), + target, + verifyTarget: async () => undefined, + })).rejects.toThrow("provider_result_invalid"); + }); + + test("prints only bounded safe state and static failures", async () => { + const stdout: string[] = []; + const stderr: string[] = []; + expect(await executeHostedAdmission({ + arguments: ["status", ...targetArguments], + runner: async () => ({ + exitCode: 0, + stderr: "provider-secret", + stdout: '{"generation":4,"state":"frozen","updatedAt":3}', + }), + stderr: outputWriter(stderr), + stdout: outputWriter(stdout), + verifyTarget: async () => undefined, + })).toBe(0); + expect(stdout).toEqual(['{"generation":4,"state":"frozen","version":1}\n']); + expect(stderr).toEqual([]); + + stdout.length = 0; + expect(await executeHostedAdmission({ + arguments: ["status", ...targetArguments], + runner: async () => ({ exitCode: 1, stderr: "provider-secret", stdout: "" }), + stderr: outputWriter(stderr), + stdout: outputWriter(stdout), + verifyTarget: async () => undefined, + })).toBe(1); + expect(stdout).toEqual([]); + expect(stderr.join("")).not.toContain("provider-secret"); + expect(stderr.join("")).toContain("provider_result_invalid"); + }); +}); diff --git a/scripts/manage-hosted-admission.ts b/scripts/manage-hosted-admission.ts new file mode 100644 index 0000000..00a2797 --- /dev/null +++ b/scripts/manage-hosted-admission.ts @@ -0,0 +1,274 @@ +import { resolve } from "node:path"; + +import { z } from "zod"; + +import { isSafeNonNegativeInteger, isUuidV7 } from "../src/cloud/contracts"; +import { + buildConvexChildEnvironment, + runCommand, + type CommandResult, + type CommandRunner, +} from "./configure-hosted-sync"; +import { + ConvexTargetError, + parseConvexTarget, + parseConvexTargetArguments, + verifyConvexDefaultTarget, + type ConvexTarget, + type ConvexTargetVerifier, +} from "./convex-target"; + +const convexCli = resolve(import.meta.dir, "..", "node_modules", "convex", "bin", "main.js"); +const repositoryRoot = resolve(import.meta.dir, ".."); +const providerOutputMaximumBytes = 64 * 1024; + +const statusSchema = z.object({ + generation: z.number().refine(isSafeNonNegativeInteger), + state: z.union([z.literal("open"), z.literal("frozen")]), + updatedAt: z.number().finite().nonnegative(), +}).strict(); + +const transitionSchema = statusSchema.extend({ + changed: z.boolean(), + replay: z.boolean(), +}).strict(); + +type AdmissionStatus = z.infer; +type AdmissionAction = + | Readonly<{ kind: "status" }> + | Readonly<{ + expectedGeneration: number; + kind: "freeze" | "resume"; + mutationId: string; + }>; + +type AdmissionArguments = Readonly<{ + action: AdmissionAction; + target: ConvexTarget; +}>; + +type AdmissionFailureCode = + | "convex_target_refused" + | "provider_result_invalid" + | "transition_refused" + | "usage_invalid"; + +class AdmissionOperatorError extends Error { + constructor(readonly code: AdmissionFailureCode) { + super(code); + this.name = "AdmissionOperatorError"; + } +} + +const takeOption = (values: string[], name: string): string | undefined => { + const index = values.indexOf(name); + if (index < 0) return undefined; + const value = values[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new AdmissionOperatorError("usage_invalid"); + } + values.splice(index, 2); + return value; +}; + +const takeFlag = (values: string[], name: string): boolean => { + const index = values.indexOf(name); + if (index < 0) return false; + values.splice(index, 1); + return true; +}; + +export function parseAdmissionArguments(arguments_: readonly string[]): AdmissionArguments { + let targetArguments: ReturnType; + try { + targetArguments = parseConvexTargetArguments(arguments_); + } catch { + throw new AdmissionOperatorError("usage_invalid"); + } + const values = [...targetArguments.otherArguments]; + const action = values.shift(); + if (action === "status") { + if (values.length !== 0) throw new AdmissionOperatorError("usage_invalid"); + return { action: { kind: "status" }, target: targetArguments.target }; + } + if (action !== "freeze" && action !== "resume") { + throw new AdmissionOperatorError("usage_invalid"); + } + const expected = takeOption(values, "--expected-generation"); + const mutationId = takeOption(values, "--mutation-id"); + const acknowledgedResume = takeFlag(values, "--acknowledge-resume"); + if ( + values.length !== 0 + || expected === undefined + || !/^(0|[1-9][0-9]*)$/u.test(expected) + || !isSafeNonNegativeInteger(Number(expected)) + || mutationId === undefined + || !isUuidV7(mutationId) + || (action === "resume") !== acknowledgedResume + ) throw new AdmissionOperatorError("usage_invalid"); + return { + action: { + expectedGeneration: Number(expected), + kind: action, + mutationId, + }, + target: targetArguments.target, + }; +} + +const parseProviderJson = (stdout: string, schema: z.ZodType): T => { + if ( + stdout.trim().length === 0 + || Buffer.byteLength(stdout, "utf8") > providerOutputMaximumBytes + ) throw new AdmissionOperatorError("provider_result_invalid"); + try { + return schema.parse(JSON.parse(stdout) as unknown); + } catch { + throw new AdmissionOperatorError("provider_result_invalid"); + } +}; + +const statusArguments = (deployment: string): readonly string[] => [ + "run", + "admissionControl:status", + "{}", + "--deployment", + deployment, +]; + +const transitionArguments = ( + deployment: string, + action: Extract, +): readonly string[] => [ + "run", + "admissionControl:transition", + JSON.stringify({ + expectedGeneration: action.expectedGeneration, + mutationId: action.mutationId, + state: action.kind === "freeze" ? "frozen" : "open", + }), + "--deployment", + deployment, +]; + +type AdmissionOptions = Readonly<{ + action: AdmissionAction; + environment?: Readonly; + runner?: CommandRunner; + target: ConvexTarget; + verifyTarget?: ConvexTargetVerifier; +}>; + +export async function manageHostedAdmission(options: AdmissionOptions): Promise { + const target = parseConvexTarget(options.target); + const verify = options.verifyTarget ?? verifyConvexDefaultTarget; + const runner = options.runner ?? runCommand; + const environment = buildConvexChildEnvironment(options.environment ?? process.env, []); + const invoke = async (arguments_: readonly string[]): Promise => await runner({ + arguments: [convexCli, ...arguments_], + cwd: repositoryRoot, + environment, + executable: process.execPath, + outputMaximumBytes: providerOutputMaximumBytes, + stdin: "", + timeoutMs: 60_000, + }); + const invokeWithPostflight = async ( + arguments_: readonly string[], + ): Promise => { + try { + return await invoke(arguments_); + } finally { + await verify(target); + } + }; + + await verify(target); + const beforeResult = await invokeWithPostflight(statusArguments(target.deploymentName)); + if (beforeResult.exitCode !== 0) { + throw new AdmissionOperatorError("provider_result_invalid"); + } + const before = parseProviderJson(beforeResult.stdout, statusSchema); + if (options.action.kind === "status") { + return before; + } + + const desired = options.action.kind === "freeze" ? "frozen" : "open"; + const expectedBefore = before.generation === options.action.expectedGeneration; + const possibleLostResponse = before.generation === options.action.expectedGeneration + 1 + && before.state === desired; + if ( + (!expectedBefore && !possibleLostResponse) + || (expectedBefore && before.state === desired) + ) { + throw new AdmissionOperatorError("transition_refused"); + } + + const changedResult = await invokeWithPostflight( + transitionArguments(target.deploymentName, options.action), + ); + if (changedResult.exitCode !== 0) { + throw new AdmissionOperatorError("transition_refused"); + } + const changed = parseProviderJson(changedResult.stdout, transitionSchema); + const expectedGeneration = options.action.expectedGeneration + 1; + if ( + changed.state !== desired + || changed.generation !== expectedGeneration + || (possibleLostResponse && !changed.replay) + ) throw new AdmissionOperatorError("transition_refused"); + + const afterResult = await invokeWithPostflight(statusArguments(target.deploymentName)); + if (afterResult.exitCode !== 0) { + throw new AdmissionOperatorError("provider_result_invalid"); + } + const after = parseProviderJson(afterResult.stdout, statusSchema); + if (after.state !== desired || after.generation !== expectedGeneration) { + throw new AdmissionOperatorError("transition_refused"); + } + return after; +} + +type ExecuteAdmissionOptions = Readonly<{ + arguments: readonly string[]; + environment?: Readonly; + runner?: CommandRunner; + stderr: Pick; + stdout: Pick; + verifyTarget?: ConvexTargetVerifier; +}>; + +export async function executeHostedAdmission(options: ExecuteAdmissionOptions): Promise { + try { + const parsed = parseAdmissionArguments(options.arguments); + const status = await manageHostedAdmission({ + action: parsed.action, + ...(options.environment === undefined ? {} : { environment: options.environment }), + ...(options.runner === undefined ? {} : { runner: options.runner }), + target: parsed.target, + ...(options.verifyTarget === undefined ? {} : { verifyTarget: options.verifyTarget }), + }); + options.stdout.write(`${JSON.stringify({ + generation: status.generation, + state: status.state, + version: 1, + })}\n`); + return 0; + } catch (error: unknown) { + const code = error instanceof AdmissionOperatorError + ? error.code + : error instanceof ConvexTargetError + ? "convex_target_refused" + : "provider_result_invalid"; + options.stderr.write(`Hosted auth admission operation refused (${code}).\n`); + return 1; + } +} + +if (import.meta.main) { + process.exitCode = await executeHostedAdmission({ + arguments: process.argv.slice(2), + stderr: process.stderr, + stdout: process.stdout, + }); +} diff --git a/scripts/manage-hosted-invites.test.ts b/scripts/manage-hosted-invites.test.ts new file mode 100644 index 0000000..7d9d8ad --- /dev/null +++ b/scripts/manage-hosted-invites.test.ts @@ -0,0 +1,667 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmod, + lstat, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + digestInviteCapability, + invitePublicIdFromCapabilityDigest, +} from "../src/cloud/inviteAuthority"; + +import type { CapabilitySink } from "./bootstrap-hosted-sync"; +import type { CommandRequest, CommandRunner } from "./configure-hosted-sync"; +import { + ConvexTargetError, + HRA_CONVEX_PROJECT_ID, + HRA_CONVEX_TEAM_ID, + HRA_V0_CONVEX_DEPLOYMENT_ID, + HRA_V0_CONVEX_PROJECT_ID, + type ConvexTarget, + type ConvexTargetVerifier, +} from "./convex-target"; +import { + executeHostedInviteOperator, + parseHostedInviteArguments, +} from "./manage-hosted-invites"; + +const target: ConvexTarget = { + deploymentId: 7_654_321, + deploymentName: "steady-otter-321", + deploymentUrl: "https://steady-otter-321.convex.cloud", + projectId: HRA_CONVEX_PROJECT_ID, + teamId: HRA_CONVEX_TEAM_ID, +}; + +const targetArguments = [ + "--deployment", + target.deploymentName, + "--team-id", + String(target.teamId), + "--project-id", + String(target.projectId), + "--deployment-id", + String(target.deploymentId), + "--deployment-url", + target.deploymentUrl, +] as const; + +const capability = `hra_invite_identity_v1_${"S".repeat(43)}`; +const capabilityDigest = await digestInviteCapability(capability, "identity"); +const publicId = invitePublicIdFromCapabilityDigest(capabilityDigest); +const authority = { capability, capabilityDigest, publicId } as const; +const issued = { + expiresAt: 1_800_086_400_000, + publicId, + purpose: "identity", + replay: false, + state: "issued", +} as const; +const status = { + bound: false, + consumedAt: null, + createdAt: 1_800_000_000_000, + expired: false, + expiresAt: issued.expiresAt, + publicId, + purpose: "identity", + state: "issued", + updatedAt: 1_800_000_000_000, +} as const; +const revoked = { + ...status, + expiresAt: 1_800_172_800_000, + replay: false, + state: "revoked", + updatedAt: 1_800_000_001_000, +} as const; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(async (directory) => { + await rm(directory, { force: true, recursive: true }); + })); +}); + +const makeTemporaryDirectory = async (): Promise => { + const directory = await mkdtemp(join(tmpdir(), "hra-hosted-invite-test-")); + temporaryDirectories.push(directory); + return directory; +}; + +const outputWriter = (chunks: string[]): Pick => ({ + write(chunk: string | Uint8Array): boolean { + chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }, +}); + +const makeVerifier = (observed: ConvexTarget[]): ConvexTargetVerifier => async (value) => { + observed.push(value); + expect(value).toEqual(target); +}; + +type FakeSink = Readonly<{ + aborts: () => number; + commits: () => readonly string[]; + sink: CapabilitySink; +}>; + +const makeFakeSink = (): FakeSink => { + let aborts = 0; + const commits: string[] = []; + return { + aborts: () => aborts, + commits: () => commits, + sink: { + async abort() { aborts += 1; }, + async commit(value) { commits.push(value); }, + }, + }; +}; + +describe("hosted friend-beta invitation operator", () => { + test("parses only one checked operation and refuses HRA v0 numeric identities", () => { + expect(parseHostedInviteArguments([ + "issue", + ...targetArguments, + "--invite-output", + "/private/operator/friend.invite", + ])).toEqual({ + operation: { + inviteOutput: "/private/operator/friend.invite", + kind: "issue", + }, + target, + }); + expect(parseHostedInviteArguments([ + "recover", + ...targetArguments, + "--invite-file", + "/private/operator/friend.invite", + ])).toEqual({ + operation: { + inviteFile: "/private/operator/friend.invite", + kind: "recover", + }, + target, + }); + expect(parseHostedInviteArguments([ + "status", + ...targetArguments, + "--public-id", + publicId, + ])).toEqual({ operation: { kind: "status", publicId }, target }); + expect(parseHostedInviteArguments([ + "revoke", + ...targetArguments, + "--public-id", + publicId, + ])).toEqual({ operation: { kind: "revoke", publicId }, target }); + + expect(() => parseHostedInviteArguments([ + "issue", + ...targetArguments, + "--invite-output", + `/private/operator/${capability}`, + ])).toThrow("usage_invalid"); + expect(() => parseHostedInviteArguments([ + "status", + ...targetArguments, + "--public-id", + capability, + ])).toThrow("usage_invalid"); + expect(() => parseHostedInviteArguments([ + "status", + ...targetArguments.slice(0, 4), + "--project-id", + String(HRA_V0_CONVEX_PROJECT_ID), + ...targetArguments.slice(6), + "--public-id", + publicId, + ])).toThrow("usage_invalid"); + expect(() => parseHostedInviteArguments([ + "status", + ...targetArguments.slice(0, 6), + "--deployment-id", + String(HRA_V0_CONVEX_DEPLOYMENT_ID), + ...targetArguments.slice(8), + "--public-id", + publicId, + ])).toThrow("usage_invalid"); + }); + + test("issues one identity invite into an exclusive durable protected file without disclosure", async () => { + const directory = await makeTemporaryDirectory(); + const output = join(directory, "friend.invite"); + const requests: CommandRequest[] = []; + const runner: CommandRunner = async (request) => { + requests.push(request); + return { + exitCode: 0, + stderr: `provider-debug ${capability}`, + stdout: `${JSON.stringify(issued)}\n`, + }; + }; + const verifications: ConvexTarget[] = []; + const stdout: string[] = []; + const stderr: string[] = []; + + expect(await executeHostedInviteOperator({ + arguments: ["issue", ...targetArguments, "--invite-output", output], + authorityFactory: async () => authority, + environment: { + CONVEX_DEPLOY_KEY: capability, + HOME: "/safe/operator", + HRA_AUTH_HMAC_SECRET: capability, + PATH: "/safe/bin", + TMPDIR: `/safe/${capability}`, + }, + runner, + stderr: outputWriter(stderr), + stdout: outputWriter(stdout), + verifyTarget: makeVerifier(verifications), + })).toBe(0); + + expect(verifications).toHaveLength(2); + expect(requests).toHaveLength(1); + expect(requests[0]?.arguments.slice(1)).toEqual([ + "run", + "authInvites:recordIssue", + JSON.stringify({ + capabilityDigest, + lifetimeMs: 86_400_000, + publicId, + purpose: "identity", + }), + "--deployment", + target.deploymentName, + ]); + expect(requests[0]).toMatchObject({ + outputMaximumBytes: 65_536, + stdin: "", + timeoutMs: 60_000, + }); + expect(Object.keys(requests[0]?.environment ?? {}).sort()).toEqual([ + "HOME", + "NO_COLOR", + "PATH", + "TERM", + ]); + expect(Object.values(requests[0]?.environment ?? {})).not.toContain(capability); + expect(JSON.stringify(requests)).not.toContain(capability); + expect(JSON.stringify(requests)).not.toContain("quota:genesisHardAuthority"); + + expect(await readFile(output, "utf8")).toBe(`${capability}\n`); + const observed = await lstat(output); + expect(observed.isFile()).toBe(true); + expect(observed.nlink).toBe(1); + expect(observed.mode & 0o777).toBe(0o600); + expect(stderr).toEqual([]); + expect(JSON.parse(stdout.join(""))).toEqual({ + invite: { + expiresAt: issued.expiresAt, + publicId, + purpose: "identity", + replay: false, + state: "issued", + }, + operation: "issue", + }); + expect(`${stdout.join("")} ${stderr.join("")}`).not.toContain(capability); + }); + + test("recovers an indeterminate issuance from protected local custody", async () => { + const directory = await makeTemporaryDirectory(); + const inviteFile = join(directory, "indeterminate.invite"); + await writeFile(inviteFile, `${capability}\n`, { mode: 0o600 }); + const requests: CommandRequest[] = []; + const verifications: ConvexTarget[] = []; + const stdout: string[] = []; + const stderr: string[] = []; + const results = [ + null, + { malformed: true }, + status, + ] as const; + + expect(await executeHostedInviteOperator({ + arguments: ["recover", ...targetArguments, "--invite-file", inviteFile], + environment: { HRA_AUTH_HMAC_SECRET: capability, PATH: "/safe/bin" }, + runner: async (request) => { + requests.push(request); + const result = results[requests.length - 1]; + if (result === undefined) throw new Error("unexpected recovery call"); + return { + exitCode: 0, + stderr: capability, + stdout: `${JSON.stringify(result)}\n`, + }; + }, + stderr: outputWriter(stderr), + stdout: outputWriter(stdout), + verifyTarget: makeVerifier(verifications), + })).toBe(0); + + expect(verifications).toHaveLength(4); + expect(requests.map((request) => request.arguments.slice(1))).toEqual([ + [ + "run", + "authInvites:status", + JSON.stringify({ publicId }), + "--deployment", + target.deploymentName, + ], + [ + "run", + "authInvites:recordIssue", + JSON.stringify({ + capabilityDigest, + lifetimeMs: 86_400_000, + publicId, + purpose: "identity", + }), + "--deployment", + target.deploymentName, + ], + [ + "run", + "authInvites:status", + JSON.stringify({ publicId }), + "--deployment", + target.deploymentName, + ], + ]); + expect(JSON.stringify(requests)).not.toContain(capability); + expect(JSON.parse(stdout.join(""))).toEqual({ + invite: status, + operation: "recover", + }); + expect(stderr).toEqual([]); + expect(`${stdout.join("")} ${stderr.join("")}`).not.toContain(capability); + }); + + test("reconciles an already terminal invite without replaying issuance", async () => { + const directory = await makeTemporaryDirectory(); + const inviteFile = join(directory, "terminal.invite"); + await writeFile(inviteFile, `${capability}\n`, { mode: 0o600 }); + const requests: CommandRequest[] = []; + const stdout: string[] = []; + const stderr: string[] = []; + const terminal = { + ...status, + expiresAt: revoked.expiresAt, + state: "revoked", + updatedAt: revoked.updatedAt, + } as const; + + expect(await executeHostedInviteOperator({ + arguments: ["recover", ...targetArguments, "--invite-file", inviteFile], + runner: async (request) => { + requests.push(request); + return { exitCode: 0, stderr: capability, stdout: JSON.stringify(terminal) }; + }, + stderr: outputWriter(stderr), + stdout: outputWriter(stdout), + verifyTarget: async () => undefined, + })).toBe(0); + + expect(requests.map((request) => request.arguments[2])).toEqual([ + "authInvites:status", + ]); + expect(JSON.parse(stdout.join(""))).toEqual({ + invite: terminal, + operation: "recover", + }); + expect(stderr).toEqual([]); + }); + + test("refuses weak or linked recovery custody before provider access", async () => { + const directory = await makeTemporaryDirectory(); + const inviteFile = join(directory, "weak.invite"); + const linkedFile = join(directory, "linked.invite"); + const sharedDirectory = await makeTemporaryDirectory(); + const sharedFile = join(sharedDirectory, "shared.invite"); + await writeFile(inviteFile, `${capability}\n`, { mode: 0o600 }); + await symlink(inviteFile, linkedFile); + await chmod(inviteFile, 0o644); + await writeFile(sharedFile, `${capability}\n`, { mode: 0o600 }); + await chmod(sharedDirectory, 0o755); + + for (const candidate of [inviteFile, linkedFile, sharedFile]) { + let runnerCalls = 0; + const stdout: string[] = []; + const stderr: string[] = []; + expect(await executeHostedInviteOperator({ + arguments: ["recover", ...targetArguments, "--invite-file", candidate], + runner: async () => { + runnerCalls += 1; + return { exitCode: 0, stderr: capability, stdout: JSON.stringify(issued) }; + }, + stderr: outputWriter(stderr), + stdout: outputWriter(stdout), + verifyTarget: async () => undefined, + })).toBe(1); + expect(runnerCalls).toBe(0); + expect(stdout).toEqual([]); + expect(stderr).toEqual([ + "Hosted invite operator refused (invite_input_refused).\n", + ]); + } + }); + + test("refuses existing files and symlinks before invitation issuance", async () => { + const directory = await makeTemporaryDirectory(); + const existing = join(directory, "existing.invite"); + const linked = join(directory, "linked.invite"); + await writeFile(existing, "do-not-replace\n", { mode: 0o600 }); + await symlink(existing, linked); + for (const output of [existing, linked]) { + let runnerCalls = 0; + const stdout: string[] = []; + const stderr: string[] = []; + expect(await executeHostedInviteOperator({ + arguments: ["issue", ...targetArguments, "--invite-output", output], + runner: async () => { + runnerCalls += 1; + return { exitCode: 0, stderr: "", stdout: `${JSON.stringify(issued)}\n` }; + }, + stderr: outputWriter(stderr), + stdout: outputWriter(stdout), + verifyTarget: async () => undefined, + })).toBe(1); + expect(runnerCalls).toBe(0); + expect(stdout).toEqual([]); + expect(stderr).toEqual([ + "Hosted invite operator refused (invite_output_refused).\n", + ]); + } + expect(await readFile(existing, "utf8")).toBe("do-not-replace\n"); + }); + + test("reads bounded status by safe public identity", async () => { + const requests: CommandRequest[] = []; + const verifications: ConvexTarget[] = []; + const stdout: string[] = []; + const stderr: string[] = []; + expect(await executeHostedInviteOperator({ + arguments: ["status", ...targetArguments, "--public-id", publicId], + runner: async (request) => { + requests.push(request); + return { + exitCode: 0, + stderr: capability, + stdout: `${JSON.stringify(status)}\n`, + }; + }, + stderr: outputWriter(stderr), + stdout: outputWriter(stdout), + verifyTarget: makeVerifier(verifications), + })).toBe(0); + expect(verifications).toHaveLength(2); + expect(requests.map((request) => request.arguments.slice(1))).toEqual([[ + "run", + "authInvites:status", + JSON.stringify({ publicId }), + "--deployment", + target.deploymentName, + ]]); + expect(JSON.parse(stdout.join(""))).toEqual({ invite: status, operation: "status" }); + expect(stderr).toEqual([]); + expect(`${stdout.join("")} ${stderr.join("")}`).not.toContain(capability); + }); + + test("checks identity status before bounded revocation and returns only public state", async () => { + const requests: CommandRequest[] = []; + const results = [status, revoked]; + const verifications: ConvexTarget[] = []; + const stdout: string[] = []; + const stderr: string[] = []; + expect(await executeHostedInviteOperator({ + arguments: ["revoke", ...targetArguments, "--public-id", publicId], + runner: async (request) => { + requests.push(request); + const result = results.shift(); + if (result === undefined) throw new Error("unexpected operator call"); + return { exitCode: 0, stderr: capability, stdout: `${JSON.stringify(result)}\n` }; + }, + stderr: outputWriter(stderr), + stdout: outputWriter(stdout), + verifyTarget: makeVerifier(verifications), + })).toBe(0); + expect(verifications).toHaveLength(3); + expect(requests.map((request) => request.arguments[2])).toEqual([ + "authInvites:status", + "authInvites:revoke", + ]); + expect(requests.every((request) => request.stdin === "")).toBe(true); + expect(JSON.parse(stdout.join(""))).toEqual({ invite: revoked, operation: "revoke" }); + expect(stderr).toEqual([]); + expect(`${stdout.join("")} ${stderr.join("")}`).not.toContain(capability); + }); + + test("refuses target mismatch before provider or output mutation", async () => { + const directory = await makeTemporaryDirectory(); + const output = join(directory, "never-created.invite"); + let runnerCalls = 0; + const stdout: string[] = []; + const stderr: string[] = []; + expect(await executeHostedInviteOperator({ + arguments: ["issue", ...targetArguments, "--invite-output", output], + runner: async () => { + runnerCalls += 1; + return { exitCode: 0, stderr: capability, stdout: `${JSON.stringify(issued)}\n` }; + }, + stderr: outputWriter(stderr), + stdout: outputWriter(stdout), + verifyTarget: async () => { + throw new ConvexTargetError("target_mismatch"); + }, + })).toBe(1); + expect(runnerCalls).toBe(0); + await expect(lstat(output)).rejects.toThrow(); + expect(stdout).toEqual([]); + expect(stderr).toEqual([ + "Hosted invite operator refused (convex_target_refused).\n", + ]); + expect(stderr.join("")).not.toContain(target.deploymentUrl); + expect(stderr.join("")).not.toContain(capability); + }); + + test("preserves committed recovery custody when postflight target identity changes", async () => { + const directory = await makeTemporaryDirectory(); + const output = join(directory, "postflight-refused.invite"); + let verificationCalls = 0; + const stdout: string[] = []; + const stderr: string[] = []; + expect(await executeHostedInviteOperator({ + arguments: ["issue", ...targetArguments, "--invite-output", output], + authorityFactory: async () => authority, + runner: async () => ({ + exitCode: 0, + stderr: capability, + stdout: `${JSON.stringify(issued)}\n`, + }), + stderr: outputWriter(stderr), + stdout: outputWriter(stdout), + verifyTarget: async () => { + verificationCalls += 1; + if (verificationCalls === 2) { + throw new ConvexTargetError("target_mismatch"); + } + }, + })).toBe(1); + expect(verificationCalls).toBe(2); + expect(await readFile(output, "utf8")).toBe(`${capability}\n`); + expect(stdout).toEqual([]); + expect(stderr).toEqual([ + "Hosted invite operator refused (convex_target_refused).\n", + ]); + expect(stderr.join("")).not.toContain(capability); + }); + + test("turns ambiguous provider results and failures into static nondisclosing refusals", async () => { + const fakeSink = makeFakeSink(); + const issueStdout: string[] = []; + const issueStderr: string[] = []; + expect(await executeHostedInviteOperator({ + arguments: [ + "issue", + ...targetArguments, + "--invite-output", + "/private/operator/ambiguous.invite", + ], + authorityFactory: async () => authority, + reserve: async () => fakeSink.sink, + runner: async () => ({ + exitCode: 0, + stderr: capability, + stdout: `${JSON.stringify(issued)}\n{}`, + }), + stderr: outputWriter(issueStderr), + stdout: outputWriter(issueStdout), + verifyTarget: async () => undefined, + })).toBe(1); + expect(fakeSink.commits()).toEqual([capability]); + expect(fakeSink.aborts()).toBe(0); + expect(issueStdout).toEqual([]); + expect(issueStderr).toEqual([ + "Hosted invite operator refused (invite_result_invalid).\n", + ]); + + const statusStdout: string[] = []; + const statusStderr: string[] = []; + expect(await executeHostedInviteOperator({ + arguments: ["status", ...targetArguments, "--public-id", publicId], + runner: async () => ({ + exitCode: 0, + stderr: capability, + stdout: `${JSON.stringify({ ...status, publicId: `invite_${"Q".repeat(32)}` })}\n`, + }), + stderr: outputWriter(statusStderr), + stdout: outputWriter(statusStdout), + verifyTarget: async () => undefined, + })).toBe(1); + expect(statusStdout).toEqual([]); + expect(statusStderr).toEqual([ + "Hosted invite operator refused (invite_status_result_invalid).\n", + ]); + + let revokeCalls = 0; + const revokeStdout: string[] = []; + const revokeStderr: string[] = []; + expect(await executeHostedInviteOperator({ + arguments: ["revoke", ...targetArguments, "--public-id", publicId], + runner: async () => { + revokeCalls += 1; + return revokeCalls === 1 + ? { exitCode: 0, stderr: capability, stdout: `${JSON.stringify(status)}\n` } + : { exitCode: 1, stderr: capability, stdout: capability }; + }, + stderr: outputWriter(revokeStderr), + stdout: outputWriter(revokeStdout), + verifyTarget: async () => undefined, + })).toBe(1); + expect(revokeCalls).toBe(2); + expect(revokeStdout).toEqual([]); + expect(revokeStderr).toEqual([ + "Hosted invite operator refused (invite_revoke_failed).\n", + ]); + expect(`${issueStderr.join("")} ${statusStderr.join("")} ${revokeStderr.join("")}`) + .not.toContain(capability); + }); + + test("refuses a revocation response that leaves an invitation live", async () => { + const stdout: string[] = []; + const stderr: string[] = []; + let calls = 0; + expect(await executeHostedInviteOperator({ + arguments: ["revoke", ...targetArguments, "--public-id", publicId], + runner: async () => { + calls += 1; + return { + exitCode: 0, + stderr: capability, + stdout: `${JSON.stringify(calls === 1 ? status : { ...status, replay: false })}\n`, + }; + }, + stderr: outputWriter(stderr), + stdout: outputWriter(stdout), + verifyTarget: async () => undefined, + })).toBe(1); + expect(calls).toBe(2); + expect(stdout).toEqual([]); + expect(stderr).toEqual([ + "Hosted invite operator refused (invite_revoke_result_invalid).\n", + ]); + expect(stderr.join("")).not.toContain(capability); + }); +}); diff --git a/scripts/manage-hosted-invites.ts b/scripts/manage-hosted-invites.ts new file mode 100644 index 0000000..481c91c --- /dev/null +++ b/scripts/manage-hosted-invites.ts @@ -0,0 +1,521 @@ +import { isAbsolute, resolve } from "node:path"; + +import { z } from "zod"; + +import { + digestInviteCapability, + generateInviteAuthority, + identityInviteLifetimeMs, + invitePublicIdFromCapabilityDigest, +} from "../src/cloud/inviteAuthority"; + +import { + readProtectedInviteCapability, + reserveCapabilityFile, + type CapabilitySink, +} from "./bootstrap-hosted-sync"; +import { + buildConvexChildEnvironment, + runCommand, + type CommandResult, + type CommandRunner, +} from "./configure-hosted-sync"; +import { + ConvexTargetError, + parseConvexTarget, + parseConvexTargetArguments, + verifyConvexDefaultTarget, + type ConvexTarget, + type ConvexTargetVerifier, +} from "./convex-target"; + +const convexOutputMaximumBytes = 64 * 1024; +const convexTimeoutMs = 60_000; +const inviteCapabilityPattern = + /hra_invite_(?:device|identity)_v1_[A-Za-z0-9_-]{43}/u; + +const publicIdSchema = z.string().regex(/^invite_[A-Za-z0-9_-]{32}$/u); +const identityInviteCapabilitySchema = z.string() + .regex(/^hra_invite_identity_v1_[A-Za-z0-9_-]{43}$/u); + +const issueResultSchema = z.object({ + expiresAt: z.number().finite().positive(), + publicId: publicIdSchema, + purpose: z.literal("identity"), + replay: z.boolean(), + state: z.literal("issued"), +}).strict(); + +const localAuthoritySchema = z.object({ + capability: identityInviteCapabilitySchema, + capabilityDigest: z.string().regex(/^[a-f0-9]{64}$/u), + publicId: publicIdSchema, +}).strict(); + +const statusResultSchema = z.object({ + bound: z.boolean(), + consumedAt: z.number().finite().nonnegative().nullable(), + createdAt: z.number().finite().nonnegative(), + expired: z.boolean(), + expiresAt: z.number().finite().positive(), + publicId: publicIdSchema, + purpose: z.literal("identity"), + state: z.enum(["bound_to_email", "consumed", "issued", "revoked"]), + updatedAt: z.number().finite().nonnegative(), +}).strict(); + +const revokeResultSchema = statusResultSchema.extend({ + replay: z.boolean(), +}).strict().superRefine((value, context) => { + if (value.state !== "revoked" && value.state !== "consumed") { + context.addIssue({ + code: "custom", + message: "revocation did not reach a terminal state", + }); + } + if (value.state === "consumed" && !value.replay) { + context.addIssue({ + code: "custom", + message: "consumed invitation must be an idempotent terminal result", + }); + } +}); + +type InviteOperatorFailureCode = + | "convex_target_refused" + | "invite_issue_failed" + | "invite_input_refused" + | "invite_output_refused" + | "invite_result_invalid" + | "invite_revoke_failed" + | "invite_revoke_result_invalid" + | "invite_status_failed" + | "invite_status_result_invalid" + | "usage_invalid"; + +class InviteOperatorError extends Error { + readonly code: InviteOperatorFailureCode; + + constructor(code: InviteOperatorFailureCode) { + super(code); + this.name = "InviteOperatorError"; + this.code = code; + } +} + +export type HostedInviteOperation = + | Readonly<{ + inviteOutput: string; + kind: "issue"; + }> + | Readonly<{ + inviteFile: string; + kind: "recover"; + }> + | Readonly<{ + kind: "revoke" | "status"; + publicId: string; + }>; + +type HostedInviteArguments = Readonly<{ + operation: HostedInviteOperation; + target: ConvexTarget; +}>; + +const isCapabilityFree = (value: string): boolean => + !inviteCapabilityPattern.test(value); + +export function parseHostedInviteArguments( + arguments_: readonly string[], +): HostedInviteArguments { + let parsedTarget: ReturnType; + try { + parsedTarget = parseConvexTargetArguments(arguments_); + } catch { + throw new InviteOperatorError("usage_invalid"); + } + const [command, flag, value, ...remaining] = parsedTarget.otherArguments; + if (remaining.length !== 0 || value === undefined || !isCapabilityFree(value)) { + throw new InviteOperatorError("usage_invalid"); + } + if (command === "issue" && flag === "--invite-output") { + if ( + value.length === 0 + || value.length > 4_096 + || !isAbsolute(value) + || resolve(value) !== value + ) throw new InviteOperatorError("usage_invalid"); + return { + operation: { inviteOutput: value, kind: "issue" }, + target: parsedTarget.target, + }; + } + if (command === "recover" && flag === "--invite-file") { + if ( + value.length === 0 + || value.length > 4_096 + || !isAbsolute(value) + || resolve(value) !== value + ) throw new InviteOperatorError("usage_invalid"); + return { + operation: { inviteFile: value, kind: "recover" }, + target: parsedTarget.target, + }; + } + if ( + (command === "status" || command === "revoke") + && flag === "--public-id" + && publicIdSchema.safeParse(value).success + ) { + return { + operation: { kind: command, publicId: value }, + target: parsedTarget.target, + }; + } + throw new InviteOperatorError("usage_invalid"); +} + +const parseJson = (output: string, code: InviteOperatorFailureCode): unknown => { + if ( + output.trim().length === 0 + || Buffer.byteLength(output, "utf8") > convexOutputMaximumBytes + ) throw new InviteOperatorError(code); + try { + return JSON.parse(output) as unknown; + } catch { + throw new InviteOperatorError(code); + } +}; + +const issueArguments = ( + deployment: string, + authority: Readonly<{ + capabilityDigest: string; + publicId: string; + }>, +): readonly string[] => [ + "run", + "authInvites:recordIssue", + JSON.stringify({ + capabilityDigest: authority.capabilityDigest, + lifetimeMs: identityInviteLifetimeMs, + publicId: authority.publicId, + purpose: "identity", + }), + "--deployment", + deployment, +]; + +const statusArguments = (deployment: string, publicId: string): readonly string[] => [ + "run", + "authInvites:status", + JSON.stringify({ publicId }), + "--deployment", + deployment, +]; + +const revokeArguments = (deployment: string, publicId: string): readonly string[] => [ + "run", + "authInvites:revoke", + JSON.stringify({ publicId }), + "--deployment", + deployment, +]; + +const convexCli = resolve(import.meta.dir, "..", "node_modules", "convex", "bin", "main.js"); +const repositoryRoot = resolve(import.meta.dir, ".."); + +type PublicStatus = z.infer; +type RevokeStatus = z.infer; +type LocalAuthority = z.infer; + +export type HostedInviteOperatorResult = + | Readonly<{ + invite: z.infer; + operation: "issue"; + }> + | Readonly<{ + invite: PublicStatus; + operation: "recover"; + }> + | Readonly<{ + invite: PublicStatus | null; + operation: "status"; + }> + | Readonly<{ + invite: RevokeStatus | null; + operation: "revoke"; + }>; + +type ManageOptions = Readonly<{ + authorityFactory?: () => Promise; + environment?: Readonly; + operation: HostedInviteOperation; + readCapability?: (path: string) => Promise; + reserve?: (path: string) => Promise; + runner?: CommandRunner; + target: ConvexTarget; + verifyTarget?: ConvexTargetVerifier; +}>; + +const validateLocalAuthority = async (value: unknown): Promise => { + const parsed = localAuthoritySchema.safeParse(value); + if (!parsed.success) throw new InviteOperatorError("invite_result_invalid"); + const digest = await digestInviteCapability(parsed.data.capability, "identity"); + if ( + parsed.data.capabilityDigest !== digest + || parsed.data.publicId !== invitePublicIdFromCapabilityDigest(digest) + ) throw new InviteOperatorError("invite_result_invalid"); + return parsed.data; +}; + +const authorityForCapability = async (capability: string): Promise => { + const capabilityDigest = await digestInviteCapability(capability, "identity"); + return { + capability, + capabilityDigest, + publicId: invitePublicIdFromCapabilityDigest(capabilityDigest), + }; +}; + +const parseStatus = ( + output: string, + publicId: string, +): PublicStatus | null => { + const parsed = statusResultSchema.nullable().safeParse( + parseJson(output, "invite_status_result_invalid"), + ); + if (!parsed.success || (parsed.data !== null && parsed.data.publicId !== publicId)) { + throw new InviteOperatorError("invite_status_result_invalid"); + } + return parsed.data; +}; + +const parseRevoke = ( + output: string, + publicId: string, +): RevokeStatus | null => { + const parsed = revokeResultSchema.nullable().safeParse( + parseJson(output, "invite_revoke_result_invalid"), + ); + if (!parsed.success || (parsed.data !== null && parsed.data.publicId !== publicId)) { + throw new InviteOperatorError("invite_revoke_result_invalid"); + } + return parsed.data; +}; + +export async function manageHostedInvite( + options: ManageOptions, +): Promise { + const target = parseConvexTarget(options.target); + const verifyTarget = options.verifyTarget ?? verifyConvexDefaultTarget; + await verifyTarget(target); + const sourceEnvironment = options.environment ?? process.env; + const forbiddenEnvironmentValues = Object.values(sourceEnvironment) + .filter((value): value is string => + value !== undefined && inviteCapabilityPattern.test(value)); + const environment = buildConvexChildEnvironment( + sourceEnvironment, + forbiddenEnvironmentValues, + ); + const runner = options.runner ?? runCommand; + const invoke = async ( + arguments_: readonly string[], + failureCode: InviteOperatorFailureCode, + ): Promise => { + let result: CommandResult; + try { + result = await runner({ + arguments: [convexCli, ...arguments_], + cwd: repositoryRoot, + environment, + executable: process.execPath, + outputMaximumBytes: convexOutputMaximumBytes, + stdin: "", + timeoutMs: convexTimeoutMs, + }); + } catch { + throw new InviteOperatorError(failureCode); + } + if (result.exitCode !== 0) throw new InviteOperatorError(failureCode); + return result; + }; + const invokeWithPostflight = async ( + arguments_: readonly string[], + failureCode: InviteOperatorFailureCode, + ): Promise => { + try { + return await invoke(arguments_, failureCode); + } finally { + await verifyTarget(target); + } + }; + + if (options.operation.kind === "issue") { + let sink: CapabilitySink; + try { + sink = await (options.reserve ?? reserveCapabilityFile)( + options.operation.inviteOutput, + ); + } catch { + throw new InviteOperatorError("invite_output_refused"); + } + let committed = false; + try { + const authority = await validateLocalAuthority( + await (options.authorityFactory ?? (async () => + await generateInviteAuthority("identity")))(), + ); + try { + await sink.commit(authority.capability); + committed = true; + } catch { + throw new InviteOperatorError("invite_output_refused"); + } + const result = await invokeWithPostflight( + issueArguments(target.deploymentName, authority), + "invite_issue_failed", + ); + const parsed = issueResultSchema.safeParse( + parseJson(result.stdout, "invite_result_invalid"), + ); + if ( + !parsed.success + || parsed.data.publicId !== authority.publicId + ) throw new InviteOperatorError("invite_result_invalid"); + return { + invite: parsed.data, + operation: "issue", + }; + } catch (error: unknown) { + if (!committed) await sink.abort().catch(() => undefined); + throw error; + } + } + + if (options.operation.kind === "recover") { + let capability: string; + try { + capability = await (options.readCapability ?? readProtectedInviteCapability)( + options.operation.inviteFile, + ); + } catch { + throw new InviteOperatorError("invite_input_refused"); + } + let authority: LocalAuthority; + try { + authority = await validateLocalAuthority(await authorityForCapability(capability)); + } catch { + throw new InviteOperatorError("invite_input_refused"); + } + const before = await invokeWithPostflight( + statusArguments(target.deploymentName, authority.publicId), + "invite_status_failed", + ); + const existing = parseStatus(before.stdout, authority.publicId); + if (existing !== null) return { invite: existing, operation: "recover" }; + + let issueFailure: Error | undefined; + try { + const result = await invokeWithPostflight( + issueArguments(target.deploymentName, authority), + "invite_issue_failed", + ); + const parsed = issueResultSchema.safeParse( + parseJson(result.stdout, "invite_result_invalid"), + ); + if ( + !parsed.success + || parsed.data.publicId !== authority.publicId + ) throw new InviteOperatorError("invite_result_invalid"); + } catch (error: unknown) { + if (error instanceof ConvexTargetError) throw error; + issueFailure = error instanceof Error + ? error + : new InviteOperatorError("invite_issue_failed"); + } + + const after = await invokeWithPostflight( + statusArguments(target.deploymentName, authority.publicId), + "invite_status_failed", + ); + const recovered = parseStatus(after.stdout, authority.publicId); + if (recovered === null) { + if (issueFailure !== undefined) throw issueFailure; + throw new InviteOperatorError("invite_result_invalid"); + } + return { invite: recovered, operation: "recover" }; + } + + const before = await invokeWithPostflight( + statusArguments(target.deploymentName, options.operation.publicId), + "invite_status_failed", + ); + const status = parseStatus(before.stdout, options.operation.publicId); + if (options.operation.kind === "status") { + return { invite: status, operation: "status" }; + } + + const revoked = await invokeWithPostflight( + revokeArguments(target.deploymentName, options.operation.publicId), + "invite_revoke_failed", + ); + const revoke = parseRevoke(revoked.stdout, options.operation.publicId); + if ((status === null) !== (revoke === null)) { + throw new InviteOperatorError("invite_revoke_result_invalid"); + } + return { invite: revoke, operation: "revoke" }; +} + +type ExecuteOptions = Readonly<{ + arguments: readonly string[]; + authorityFactory?: () => Promise; + environment?: Readonly; + readCapability?: (path: string) => Promise; + reserve?: (path: string) => Promise; + runner?: CommandRunner; + stderr: Pick; + stdout: Pick; + verifyTarget?: ConvexTargetVerifier; +}>; + +export async function executeHostedInviteOperator( + options: ExecuteOptions, +): Promise { + try { + const arguments_ = parseHostedInviteArguments(options.arguments); + const result = await manageHostedInvite({ + ...(options.authorityFactory === undefined + ? {} + : { authorityFactory: options.authorityFactory }), + ...(options.environment === undefined ? {} : { environment: options.environment }), + operation: arguments_.operation, + ...(options.readCapability === undefined + ? {} + : { readCapability: options.readCapability }), + ...(options.reserve === undefined ? {} : { reserve: options.reserve }), + ...(options.runner === undefined ? {} : { runner: options.runner }), + target: arguments_.target, + ...(options.verifyTarget === undefined ? {} : { verifyTarget: options.verifyTarget }), + }); + options.stdout.write(`${JSON.stringify(result)}\n`); + return 0; + } catch (error: unknown) { + const code = error instanceof InviteOperatorError + ? error.code + : error instanceof ConvexTargetError + ? "convex_target_refused" + : "invite_result_invalid"; + options.stderr.write(`Hosted invite operator refused (${code}).\n`); + return 1; + } +} + +if (import.meta.main) { + const exitCode = await executeHostedInviteOperator({ + arguments: process.argv.slice(2), + stderr: process.stderr, + stdout: process.stdout, + }); + process.exitCode = exitCode; +} diff --git a/scripts/package-policy.test.ts b/scripts/package-policy.test.ts new file mode 100644 index 0000000..638247e --- /dev/null +++ b/scripts/package-policy.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { assertProductionPackageOnly } from "./package-policy"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(async (directory) => { + await rm(directory, { force: true, recursive: true }); + })); +}); + +const fixture = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), "hra-package-policy-")); + temporaryDirectories.push(root); + await mkdir(join(root, "src", "cloud"), { recursive: true }); + await writeFile(join(root, "src", "cli.ts"), "export {};\n"); + await writeFile(join(root, "README.md"), "# HRA\n"); + return root; +}; + +describe("production package policy", () => { + test("accepts the bounded production source surface", async () => { + await expect(assertProductionPackageOnly(await fixture())).resolves.toBeUndefined(); + }); + + test("rejects repository, test, guide, and operator-only source", async () => { + const forbidden = [ + [".github", "workflows", "release.yml"], + ["convex", "schema.ts"], + ["docs", "live-acceptance.md"], + ["kb", "plans", "hra-v1.md"], + ["scripts", "live-acceptance.ts"], + ["site", "content.ts"], + ["src", "AGENTS.md"], + ["src", "cli.test.ts"], + ["src", "cloud", "inviteAuthority.ts"], + ["src", "cloud", "testAssertions.ts"], + ["src", "live-acceptance-private.ts"], + ] as const; + for (const components of forbidden) { + const root = await fixture(); + const path = join(root, ...components); + await mkdir(join(path, ".."), { recursive: true }); + await writeFile(path, "forbidden\n"); + await expect(assertProductionPackageOnly(root)) + .rejects.toThrow(/repository-only|development-only/u); + } + }); +}); diff --git a/scripts/package-policy.ts b/scripts/package-policy.ts new file mode 100644 index 0000000..78158af --- /dev/null +++ b/scripts/package-policy.ts @@ -0,0 +1,35 @@ +import { readdir } from "node:fs/promises"; +import { join, relative } from "node:path"; + +export async function assertProductionPackageOnly(root: string): Promise { + const visit = async (path: string): Promise => { + for (const entry of await readdir(path, { withFileTypes: true })) { + const child = join(path, entry.name); + const packagePath = relative(root, child).replaceAll("\\", "/"); + if ( + /(?:^|\/)scripts(?:\/|$)/u.test(packagePath) + || /(?:^|\/)convex(?:\/|$)/u.test(packagePath) + || /(?:^|\/)kb(?:\/|$)/u.test(packagePath) + || /(?:^|\/)site(?:\/|$)/u.test(packagePath) + || /(?:^|\/)\.github(?:\/|$)/u.test(packagePath) + || /(?:^|\/)docs\/live-acceptance(?:\/|\.|$)/u.test(packagePath) + || /(?:^|\/)live-acceptance[^/]*\.ts$/u.test(packagePath) + || packagePath === "src/cloud/inviteAuthority.ts" + ) { + throw new Error("The install artifact contains repository-only source."); + } + if (entry.isDirectory()) await visit(child); + else if ( + entry.isFile() + && ( + entry.name === "AGENTS.md" + || entry.name.endsWith(".test.ts") + || entry.name === "testAssertions.ts" + ) + ) { + throw new Error("The install artifact contains development-only source."); + } + } + }; + await visit(root); +} diff --git a/scripts/publish-beta-release.test.ts b/scripts/publish-beta-release.test.ts new file mode 100644 index 0000000..faa5313 --- /dev/null +++ b/scripts/publish-beta-release.test.ts @@ -0,0 +1,480 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + assertLocalOperatorEnvironment, + buildGitHubCliEnvironment, + buildIsolatedInstallEnvironment, + executeReleasePublication, + parsePublicationArguments, + verifyAcceptedBundle, + withBestEffortReleaseCleanup, + type PublicationArguments, + type ReleasePublicationProvider, +} from "./publish-beta-release"; + +const commit = "a".repeat(40); +const tag = "v0.1.0"; +const runId = 9_876_543; +const runAttempt = 2; +const releaseId = 456; +const notes = "# HRA v0.1.0 friend beta\n\nAccepted notes."; +const temporaryRoots: string[] = []; + +const digest = (value: Uint8Array): string => + createHash("sha256").update(value).digest("hex"); + +const assetNames = [ + "SHA256SUMS", + `hra-${tag}.artifact.spdx.json`, + `hra-${tag}.tgz`, + `hra-${tag}.ubuntu-24.04-x64.runtime.spdx.json`, +] as const; + +const makeAssets = (): ReadonlyMap => { + const archive = Buffer.from("accepted archive bytes"); + const artifactSbom = Buffer.from(JSON.stringify({ + packages: [{ + checksums: [{ algorithm: "SHA256", checksumValue: digest(archive) }], + name: "hra", + versionInfo: "0.1.0", + }], + })); + const runtimeSbom = Buffer.from(JSON.stringify({ + packages: [ + { name: "hra", versionInfo: "0.1.0" }, + { name: "@openai/codex", versionInfo: "0.149.0" }, + { name: "convex", versionInfo: "1.45.0" }, + { name: "zod", versionInfo: "4.4.3" }, + ], + })); + const checksums = Buffer.from([ + `${digest(archive)} hra-${tag}.tgz`, + `${digest(artifactSbom)} hra-${tag}.artifact.spdx.json`, + `${digest(runtimeSbom)} hra-${tag}.ubuntu-24.04-x64.runtime.spdx.json`, + "", + ].join("\n")); + return new Map([ + ["SHA256SUMS", checksums], + [`hra-${tag}.artifact.spdx.json`, artifactSbom], + [`hra-${tag}.tgz`, archive], + [`hra-${tag}.ubuntu-24.04-x64.runtime.spdx.json`, runtimeSbom], + ]); +}; + +const writeAccepted = async ( + directory: string, + assets = makeAssets(), +): Promise => { + await mkdir(directory, { recursive: true, mode: 0o700 }); + for (const [name, value] of assets) { + await writeFile(join(directory, name), value, { flag: "wx", mode: 0o600 }); + } + await writeFile(join(directory, "RELEASE_COMMIT"), `${commit}\n`, { + flag: "wx", + mode: 0o600, + }); + await writeFile(join(directory, "RELEASE_NOTES.md"), notes, { + flag: "wx", + mode: 0o600, + }); +}; + +class FakeProvider implements ReleasePublicationProvider { + readonly calls: string[] = []; + readonly assets = makeAssets(); + draftImmutable = false; + immutable = true; + mainCommit = commit; + markerRedirected = false; + markerStatus = 200; + published = false; + publicInstallFails = false; + publishFailsAfterCommit = false; + publishFailsBeforeCommit = false; + publishedReleaseId: number | undefined; + publicReleaseImmutable = true; + tagCommit = commit; + + async verifyLocalSource(expectedCommit: string, requireCurrentMain: boolean): Promise { + this.calls.push(`local:${expectedCommit}:${String(requireCurrentMain)}`); + } + + async readRepository(): Promise { + this.calls.push("repository"); + return { full_name: "hraness/hra", id: 1_343_008_607 }; + } + + async readWorkflow(): Promise { + this.calls.push("workflow"); + return { + id: 123, + name: "Release", + path: ".github/workflows/release.yml", + state: "active", + }; + } + + async readRun(id: number): Promise { + this.calls.push(`run:${String(id)}`); + return { + conclusion: "success", + event: "push", + head_branch: tag, + head_sha: commit, + id: runId, + name: "Release", + path: ".github/workflows/release.yml", + repository: { full_name: "hraness/hra", id: 1_343_008_607 }, + run_attempt: runAttempt, + status: "completed", + workflow_id: 123, + }; + } + + async readRunArtifacts(id: number): Promise { + this.calls.push(`artifacts:${String(id)}`); + return { + artifacts: [{ + expired: false, + id: 321, + name: `hra-release-${tag}`, + workflow_run: { id: runId }, + }], + total_count: 1, + }; + } + + async downloadRunArtifact(_id: number, _name: string, destination: string): Promise { + this.calls.push("download-run-artifact"); + await writeAccepted(destination, this.assets); + } + + private release(): unknown { + return { + body: notes, + draft: !this.published, + id: releaseId, + immutable: this.published ? this.publicReleaseImmutable : this.draftImmutable, + name: `HRA ${tag}`, + prerelease: true, + tag_name: tag, + }; + } + + async listReleases(): Promise { + this.calls.push(`releases:${this.published ? "public" : "draft"}`); + return [this.release()]; + } + + async listReleaseAssets(id: number): Promise { + this.calls.push(`release-assets:${String(id)}`); + return assetNames.map((name, index) => ({ id: index + 1, name, state: "uploaded" })); + } + + async downloadReleaseAsset(assetId: number, destination: string): Promise { + const name = assetNames[assetId - 1]; + if (name === undefined) throw new Error("unknown asset"); + this.calls.push(`download-asset:${name}`); + await writeFile(destination, this.assets.get(name) ?? Buffer.alloc(0), { + flag: "wx", + mode: 0o600, + }); + } + + async acceptPackedInstall(): Promise { + this.calls.push("accept-packed-install"); + } + + async readTagCommit(): Promise { + this.calls.push("tag-commit"); + return this.tagCommit; + } + + async readMainCommit(): Promise { + this.calls.push("main-commit"); + return this.mainCommit; + } + + async readImmutableSetting(): Promise { + this.calls.push("immutable-setting"); + return { enabled: this.immutable, enforced_by_owner: false }; + } + + async readMarker(): Promise> { + this.calls.push("marker"); + return { + body: { + generation: 1, + product: "HRA", + repository: { id: 1_343_008_607, path: "hraness/hra" }, + schemaVersion: 2, + source: { commit }, + version: "0.1.0", + }, + redirected: this.markerRedirected, + status: this.markerStatus, + url: "https://hra.sh/.well-known/hra.json?release-check=test", + }; + } + + async publishDraft(id: number): Promise { + this.calls.push("publish"); + this.publishedReleaseId = id; + if (this.publishFailsBeforeCommit) throw new Error("lost before commit"); + this.published = true; + if (this.publishFailsAfterCommit) throw new Error("lost after commit"); + } + + async acceptPublicInstall(url: string): Promise { + this.calls.push(`public-install:${url}`); + if (this.publicInstallFails) throw new Error("public route unavailable"); + } +} + +const publicationArguments = (action: "accept" | "publish"): PublicationArguments => ({ + action, + expectedCommit: commit, + ghCli: "/opt/homebrew/bin/gh", + runAttempt, + runId, + tag, +}); + +const makeRoot = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), "hra-release-publish-test-")); + temporaryRoots.push(root); + return root; +}; + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(async (root) => { + await rm(root, { force: true, recursive: true }); + })); +}); + +describe("release publication arguments", () => { + test("requires an exact run, source commit, CLI, tag, and explicit publish acknowledgement", () => { + expect(parsePublicationArguments([ + "publish", + "--tag", tag, + "--run-id", String(runId), + "--run-attempt", String(runAttempt), + "--expected-commit", commit, + "--gh-cli", "/opt/homebrew/bin/gh", + "--acknowledge-immutable-publication", + ])).toEqual(publicationArguments("publish")); + expect(parsePublicationArguments([ + "accept", + "--tag", tag, + "--run-id", String(runId), + "--run-attempt", String(runAttempt), + "--expected-commit", commit, + "--gh-cli", "/opt/homebrew/bin/gh", + ])).toEqual(publicationArguments("accept")); + expect(() => parsePublicationArguments([ + "publish", + "--tag", tag, + "--run-id", String(runId), + "--run-attempt", String(runAttempt), + "--expected-commit", commit, + "--gh-cli", "/opt/homebrew/bin/gh", + ])).toThrow("usage_invalid"); + expect(() => parsePublicationArguments([ + "accept", + "--tag", tag, + "--run-id", String(runId), + "--run-attempt", String(runAttempt), + "--expected-commit", commit, + "--gh-cli", "/opt/homebrew/bin/gh", + "--acknowledge-immutable-publication", + ])).toThrow("usage_invalid"); + }); + + test("uses local keyring authority and isolates install state without changing HOME", async () => { + const source = { + CI: "", + GH_ENTERPRISE_TOKEN: "sentinel", + GH_HOST: "enterprise.invalid", + GH_TOKEN: "sentinel", + GITHUB_AUTH_TOKEN: "sentinel", + GITHUB_ENTERPRISE_TOKEN: "sentinel", + GITHUB_TOKEN: "sentinel", + HOME: "/Users/operator", + NODE_AUTH_TOKEN: "sentinel", + NPM_TOKEN: "sentinel", + }; + expect(buildGitHubCliEnvironment(source)).toEqual({ CI: "", HOME: "/Users/operator", NODE_AUTH_TOKEN: "sentinel", NPM_TOKEN: "sentinel" }); + const root = await makeRoot(); + const isolated = await buildIsolatedInstallEnvironment(source, root); + expect(isolated.environment.HOME).toBe(source.HOME); + for (const name of [ + "GH_ENTERPRISE_TOKEN", + "GH_TOKEN", + "GITHUB_AUTH_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "GITHUB_TOKEN", + "NODE_AUTH_TOKEN", + "NPM_TOKEN", + ]) expect(isolated.environment[name]).toBeUndefined(); + expect(isolated.environment.BUN_INSTALL).toStartWith(root); + expect(isolated.environment.TMPDIR).toStartWith(root); + expect(isolated.environment.XDG_CONFIG_HOME).toStartWith(root); + expect(() => assertLocalOperatorEnvironment({ GITHUB_ACTIONS: "true" })) + .toThrow("local_source_invalid"); + expect(() => assertLocalOperatorEnvironment({ CI: "1" })) + .toThrow("local_source_invalid"); + }); +}); + +describe("release publication cleanup", () => { + test("never replaces the authoritative result or publication phase", async () => { + await expect(withBestEffortReleaseCleanup( + async () => "published" as const, + async () => { throw new Error("cleanup failed"); }, + )).resolves.toBe("published"); + + const primary = new Error("publication outcome"); + let observed: unknown; + try { + await withBestEffortReleaseCleanup( + async () => { throw primary; }, + async () => { throw new Error("cleanup failed"); }, + ); + } catch (error: unknown) { + observed = error; + } + expect(observed).toBe(primary); + }); +}); + +describe("accepted release bundle", () => { + test("binds checksums and both SPDX contracts to the exact artifact set", async () => { + const root = await makeRoot(); + await writeAccepted(root); + const accepted = await verifyAcceptedBundle(root, commit); + expect(accepted.commit).toBe(commit); + expect(accepted.notes).toBe(notes); + expect([...accepted.releaseAssets.keys()].sort()).toEqual([...assetNames]); + }); + + test("rejects a changed tarball before publication", async () => { + const root = await makeRoot(); + await writeAccepted(root); + await writeFile(join(root, `hra-${tag}.tgz`), "changed"); + await expect(verifyAcceptedBundle(root, commit)).rejects.toMatchObject({ + code: "accepted_artifact_invalid", + }); + }); +}); + +describe("release publication authority", () => { + test("publishes only after exact reversible evidence and accepts the public URL", async () => { + const root = await makeRoot(); + const provider = new FakeProvider(); + const result = await executeReleasePublication({ + arguments: publicationArguments("publish"), + provider, + temporaryRoot: root, + }); + expect(result).toEqual({ commit, status: "published", tag }); + expect(provider.publishedReleaseId).toBe(releaseId); + expect(provider.calls.indexOf("marker")).toBeLessThan(provider.calls.indexOf("releases:draft")); + expect(provider.calls.indexOf(`download-asset:hra-${tag}.ubuntu-24.04-x64.runtime.spdx.json`)) + .toBeLessThan(provider.calls.indexOf("tag-commit")); + expect(provider.calls.indexOf(`download-asset:hra-${tag}.ubuntu-24.04-x64.runtime.spdx.json`)) + .toBeLessThan(provider.calls.indexOf("main-commit")); + expect(provider.calls.indexOf("immutable-setting")).toBeLessThan(provider.calls.indexOf("publish")); + expect(provider.calls.indexOf("immutable-setting") + 1).toBe(provider.calls.indexOf("publish")); + expect(provider.calls.indexOf("marker")).toBeLessThan(provider.calls.indexOf("publish")); + expect(provider.calls).toContain( + `public-install:https://github.com/hraness/hra/releases/download/${tag}/hra-${tag}.tgz`, + ); + }); + + test("fails closed when main moves, immutability is off, or canonical traffic redirects", async () => { + for (const mutate of [ + (provider: FakeProvider): void => { provider.mainCommit = "b".repeat(40); }, + (provider: FakeProvider): void => { provider.immutable = false; }, + (provider: FakeProvider): void => { provider.markerRedirected = true; }, + (provider: FakeProvider): void => { provider.draftImmutable = true; }, + ]) { + const root = await makeRoot(); + const provider = new FakeProvider(); + mutate(provider); + await expect(executeReleasePublication({ + arguments: publicationArguments("publish"), + provider, + temporaryRoot: root, + })).rejects.toMatchObject({ phase: "before_publication" }); + expect(provider.calls).not.toContain("publish"); + } + }); + + test("recovers a lost publish response only from an immutable public readback", async () => { + const root = await makeRoot(); + const provider = new FakeProvider(); + provider.publishFailsAfterCommit = true; + const result = await executeReleasePublication({ + arguments: publicationArguments("publish"), + provider, + temporaryRoot: root, + }); + expect(result.status).toBe("published"); + expect(provider.calls).toContain("releases:public"); + }); + + test("reports an unknown commit point when publication fails without a public readback", async () => { + const root = await makeRoot(); + const provider = new FakeProvider(); + provider.publishFailsBeforeCommit = true; + await expect(executeReleasePublication({ + arguments: publicationArguments("publish"), + provider, + temporaryRoot: root, + })).rejects.toMatchObject({ + code: "publication_unknown", + phase: "publication_unknown", + }); + expect(provider.published).toBeFalse(); + }); + + test("keeps post-publication failures in acceptance-only recovery", async () => { + for (const mutate of [ + (provider: FakeProvider): void => { provider.publicInstallFails = true; }, + (provider: FakeProvider): void => { provider.publicReleaseImmutable = false; }, + ]) { + const root = await makeRoot(); + const provider = new FakeProvider(); + mutate(provider); + await expect(executeReleasePublication({ + arguments: publicationArguments("publish"), + provider, + temporaryRoot: root, + })).rejects.toMatchObject({ phase: "published_acceptance_failed" }); + expect(provider.published).toBeTrue(); + } + }); + + test("accepts an existing immutable release without invoking publication", async () => { + const root = await makeRoot(); + const provider = new FakeProvider(); + provider.published = true; + const result = await executeReleasePublication({ + arguments: publicationArguments("accept"), + provider, + temporaryRoot: root, + }); + expect(result.status).toBe("accepted"); + expect(provider.calls).not.toContain("publish"); + expect(provider.calls[0]).toBe(`local:${commit}:false`); + }); +}); diff --git a/scripts/publish-beta-release.ts b/scripts/publish-beta-release.ts new file mode 100644 index 0000000..36561e1 --- /dev/null +++ b/scripts/publish-beta-release.ts @@ -0,0 +1,1229 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdir, + lstat, + mkdtemp, + readFile, + readdir, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { z } from "zod"; + +import { assertProductionPackageOnly } from "./package-policy"; +import { assertPublicTree } from "./public-text-policy"; + +const repository = "hraness/hra"; +const repositoryId = 1_343_008_607; +const releaseVersion = "0.1.0"; +const releaseTag = `v${releaseVersion}`; +const workflowName = "Release"; +const workflowPath = ".github/workflows/release.yml"; +const artifactName = `hra-release-${releaseTag}`; +const title = `HRA ${releaseTag}`; +const repositoryRoot = resolve(import.meta.dir, ".."); +const commandOutputMaximumBytes = 64 * 1024 * 1024; +const providerJsonMaximumBytes = 2 * 1024 * 1024; +const markerMaximumBytes = 64 * 1024; + +const commitSchema = z.string().regex(/^[0-9a-f]{40}$/u); +const positiveIntegerSchema = z.number().int().positive().safe(); + +export type PublicationAction = "accept" | "publish"; + +export type PublicationArguments = Readonly<{ + action: PublicationAction; + expectedCommit: string; + ghCli: string; + runAttempt: number; + runId: number; + tag: typeof releaseTag; +}>; + +export type PublicationPhase = + | "before_publication" + | "publication_unknown" + | "published_acceptance_failed"; + +type PublicationFailureCode = + | "accepted_artifact_invalid" + | "command_failed" + | "draft_invalid" + | "immutable_release_disabled" + | "local_source_invalid" + | "marker_invalid" + | "provider_result_invalid" + | "public_acceptance_failed" + | "publication_unknown" + | "published_release_invalid" + | "release_authority_changed" + | "usage_invalid" + | "workflow_run_invalid"; + +export class ReleasePublicationError extends Error { + constructor( + readonly code: PublicationFailureCode, + readonly phase: PublicationPhase = "before_publication", + ) { + super(code); + this.name = "ReleasePublicationError"; + } +} + +const takeOption = (values: string[], name: string): string => { + const index = values.indexOf(name); + if (index < 0) throw new ReleasePublicationError("usage_invalid"); + const value = values[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new ReleasePublicationError("usage_invalid"); + } + values.splice(index, 2); + return value; +}; + +const takeFlag = (values: string[], name: string): boolean => { + const index = values.indexOf(name); + if (index < 0) return false; + values.splice(index, 1); + return true; +}; + +export function parsePublicationArguments(arguments_: readonly string[]): PublicationArguments { + const values = [...arguments_]; + const action = values.shift(); + if (action !== "publish" && action !== "accept") { + throw new ReleasePublicationError("usage_invalid"); + } + const tag = takeOption(values, "--tag"); + const expectedCommit = takeOption(values, "--expected-commit"); + const runIdText = takeOption(values, "--run-id"); + const runAttemptText = takeOption(values, "--run-attempt"); + const ghCli = takeOption(values, "--gh-cli"); + const acknowledged = takeFlag(values, "--acknowledge-immutable-publication"); + const runId = Number(runIdText); + const runAttempt = Number(runAttemptText); + if ( + values.length !== 0 + || tag !== releaseTag + || !commitSchema.safeParse(expectedCommit).success + || !/^[1-9][0-9]*$/u.test(runIdText) + || !/^[1-9][0-9]*$/u.test(runAttemptText) + || !positiveIntegerSchema.safeParse(runId).success + || !positiveIntegerSchema.safeParse(runAttempt).success + || !isAbsolute(ghCli) + || (action === "publish") !== acknowledged + ) throw new ReleasePublicationError("usage_invalid"); + return { + action, + expectedCommit, + ghCli, + runAttempt, + runId, + tag: releaseTag, + }; +} + +type MarkerReadback = Readonly<{ + body: unknown; + redirected: boolean; + status: number; + url: string; +}>; + +export interface ReleasePublicationProvider { + acceptPackedInstall(archive: string, temporaryRoot: string): Promise; + acceptPublicInstall(url: string, temporaryRoot: string, expectedDigest: string): Promise; + downloadReleaseAsset(assetId: number, destination: string): Promise; + downloadRunArtifact(runId: number, name: string, destination: string): Promise; + listReleaseAssets(releaseId: number): Promise; + listReleases(): Promise; + publishDraft(releaseId: number): Promise; + readImmutableSetting(): Promise; + readMainCommit(): Promise; + readMarker(cacheKey: string): Promise; + readRepository(): Promise; + readRun(runId: number): Promise; + readRunArtifacts(runId: number): Promise; + readTagCommit(tag: string): Promise; + readWorkflow(): Promise; + verifyLocalSource(expectedCommit: string, requireCurrentMain: boolean): Promise; +} + +const repositorySchema = z.object({ + full_name: z.literal(repository), + id: z.literal(repositoryId), +}).passthrough(); + +const workflowSchema = z.object({ + id: positiveIntegerSchema, + name: z.literal(workflowName), + path: z.literal(workflowPath), + state: z.literal("active"), +}).passthrough(); + +const runSchema = z.object({ + conclusion: z.literal("success"), + event: z.literal("push"), + head_branch: z.literal(releaseTag), + head_sha: commitSchema, + id: positiveIntegerSchema, + name: z.literal(workflowName), + path: z.string().min(1).max(512), + repository: repositorySchema, + run_attempt: positiveIntegerSchema, + status: z.literal("completed"), + workflow_id: positiveIntegerSchema, +}).passthrough(); + +const artifactSchema = z.object({ + expired: z.literal(false), + id: positiveIntegerSchema, + name: z.literal(artifactName), + workflow_run: z.object({ id: positiveIntegerSchema }).passthrough(), +}).passthrough(); + +const artifactListSchema = z.object({ + artifacts: z.array(artifactSchema).max(2), + total_count: z.number().int().nonnegative().max(2), +}).passthrough(); + +const releaseSchema = z.object({ + body: z.string().max(256 * 1024), + draft: z.boolean(), + id: positiveIntegerSchema, + immutable: z.boolean(), + name: z.string().max(256), + prerelease: z.boolean(), + tag_name: z.string().max(128), +}).passthrough(); + +const releaseAssetSchema = z.object({ + id: positiveIntegerSchema, + name: z.string().min(1).max(256), + state: z.literal("uploaded"), +}).passthrough(); + +const markerSchema = z.object({ + generation: z.literal(1), + product: z.literal("HRA"), + repository: z.object({ + id: z.literal(repositoryId), + path: z.literal(repository), + }).strict(), + schemaVersion: z.literal(2), + source: z.object({ commit: commitSchema }).strict(), + version: z.literal(releaseVersion), +}).strict(); + +const immutableSettingSchema = z.object({ + enabled: z.literal(true), + enforced_by_owner: z.boolean(), +}).passthrough(); + +const expectedReleaseAssetNames = [ + "SHA256SUMS", + `hra-${releaseTag}.artifact.spdx.json`, + `hra-${releaseTag}.tgz`, + `hra-${releaseTag}.ubuntu-24.04-x64.runtime.spdx.json`, +] as const; + +const expectedChecksumNames = [ + `hra-${releaseTag}.tgz`, + `hra-${releaseTag}.artifact.spdx.json`, + `hra-${releaseTag}.ubuntu-24.04-x64.runtime.spdx.json`, +] as const; + +const expectedAcceptedNames = [ + ...expectedReleaseAssetNames, + "RELEASE_COMMIT", + "RELEASE_NOTES.md", +].sort(); + +const readBounded = async (file: string, maximumBytes: number): Promise => { + const metadata = await lstat(file); + if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > maximumBytes) { + throw new ReleasePublicationError("accepted_artifact_invalid"); + } + return await readFile(file); +}; + +const sha256 = (value: Uint8Array): string => + createHash("sha256").update(value).digest("hex"); + +const readResponseBounded = async ( + response: Response, + maximumBytes: number, + failureCode: "marker_invalid" | "public_acceptance_failed", +): Promise => { + const contentLength = response.headers.get("content-length"); + if ( + contentLength !== null + && (!/^[0-9]+$/u.test(contentLength) || Number(contentLength) > maximumBytes) + ) throw new ReleasePublicationError(failureCode); + const reader = response.body?.getReader(); + if (reader === undefined) throw new ReleasePublicationError(failureCode); + const chunks: Uint8Array[] = []; + let bytes = 0; + let next = await reader.read(); + while (!next.done) { + bytes += next.value.byteLength; + if (bytes > maximumBytes) { + await reader.cancel(); + throw new ReleasePublicationError(failureCode); + } + chunks.push(next.value); + next = await reader.read(); + } + const combined = new Uint8Array(bytes); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.byteLength; + } + return combined; +}; + +type AcceptedBundle = Readonly<{ + archive: string; + commit: string; + notes: string; + releaseAssets: ReadonlyMap; +}>; + +const parseJsonBuffer = (value: Buffer): unknown => { + try { + return JSON.parse(value.toString("utf8")) as unknown; + } catch { + throw new ReleasePublicationError("accepted_artifact_invalid"); + } +}; + +const asObject = (value: unknown): Record => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new ReleasePublicationError("accepted_artifact_invalid"); + } + return value as Record; +}; + +const verifyArtifactSbom = ( + value: unknown, + archiveDigest: string, +): void => { + const packages = asObject(value).packages; + if (!Array.isArray(packages) || packages.length !== 1) { + throw new ReleasePublicationError("accepted_artifact_invalid"); + } + const package_ = asObject(packages[0]); + if (package_.name !== "hra" || package_.versionInfo !== releaseVersion) { + throw new ReleasePublicationError("accepted_artifact_invalid"); + } + if ( + !Array.isArray(package_.checksums) + || !package_.checksums.some((entry) => { + const checksum = asObject(entry); + return checksum.algorithm === "SHA256" && checksum.checksumValue === archiveDigest; + }) + ) throw new ReleasePublicationError("accepted_artifact_invalid"); +}; + +const verifyRuntimeSbom = (value: unknown): void => { + const packages = asObject(value).packages; + if (!Array.isArray(packages)) { + throw new ReleasePublicationError("accepted_artifact_invalid"); + } + const required = [ + ["hra", releaseVersion], + ["@openai/codex", "0.149.0"], + ["convex", "1.45.0"], + ["zod", "4.4.3"], + ] as const; + for (const [name, version] of required) { + if (!packages.some((entry) => { + const package_ = asObject(entry); + return package_.name === name && package_.versionInfo === version; + })) throw new ReleasePublicationError("accepted_artifact_invalid"); + } + const serialized = JSON.stringify(value); + if (/\/(?:home\/runner|private\/tmp|tmp\/hra-release-publish-)/u.test(serialized)) { + throw new ReleasePublicationError("accepted_artifact_invalid"); + } +}; + +export async function verifyAcceptedBundle( + directory: string, + expectedCommit: string, +): Promise { + const names = (await readdir(directory)).sort(); + if (JSON.stringify(names) !== JSON.stringify(expectedAcceptedNames)) { + throw new ReleasePublicationError("accepted_artifact_invalid"); + } + const commit = (await readBounded(join(directory, "RELEASE_COMMIT"), 128)) + .toString("utf8").trimEnd(); + if (commit !== expectedCommit || !commitSchema.safeParse(commit).success) { + throw new ReleasePublicationError("accepted_artifact_invalid"); + } + const notesBuffer = await readBounded(join(directory, "RELEASE_NOTES.md"), 256 * 1024); + const notes = notesBuffer.toString("utf8"); + if (notes.trim().length === 0 || notes !== notes.trimEnd()) { + throw new ReleasePublicationError("accepted_artifact_invalid"); + } + const checksumBuffer = await readBounded(join(directory, "SHA256SUMS"), 8 * 1024); + const checksumText = checksumBuffer.toString("utf8"); + if (!checksumText.endsWith("\n")) { + throw new ReleasePublicationError("accepted_artifact_invalid"); + } + const checksumLines = checksumText.slice(0, -1).split("\n"); + const checksums = new Map(); + for (const line of checksumLines) { + const match = /^([0-9a-f]{64}) {2}([^/]+)$/u.exec(line); + if (match === null || checksums.has(match[2] ?? "")) { + throw new ReleasePublicationError("accepted_artifact_invalid"); + } + checksums.set(match[2] ?? "", match[1] ?? ""); + } + if (JSON.stringify([...checksums.keys()]) !== JSON.stringify([...expectedChecksumNames])) { + throw new ReleasePublicationError("accepted_artifact_invalid"); + } + const releaseAssets = new Map(); + for (const name of expectedReleaseAssetNames) { + const value = await readBounded( + join(directory, name), + name.endsWith(".tgz") ? 32 * 1024 * 1024 : 16 * 1024 * 1024, + ); + releaseAssets.set(name, value); + const expectedDigest = checksums.get(name); + if (expectedDigest !== undefined && sha256(value) !== expectedDigest) { + throw new ReleasePublicationError("accepted_artifact_invalid"); + } + } + const archiveName = `hra-${releaseTag}.tgz`; + const archive = join(directory, archiveName); + const archiveDigest = sha256(releaseAssets.get(archiveName) ?? Buffer.alloc(0)); + verifyArtifactSbom( + parseJsonBuffer(releaseAssets.get(`hra-${releaseTag}.artifact.spdx.json`) ?? Buffer.alloc(0)), + archiveDigest, + ); + verifyRuntimeSbom( + parseJsonBuffer( + releaseAssets.get(`hra-${releaseTag}.ubuntu-24.04-x64.runtime.spdx.json`) + ?? Buffer.alloc(0), + ), + ); + return { archive, commit, notes, releaseAssets }; +} + +const exactRelease = (value: unknown): z.infer => { + if (!Array.isArray(value)) throw new ReleasePublicationError("provider_result_invalid"); + const matches = value + .map((entry) => releaseSchema.parse(entry)) + .filter((entry) => entry.tag_name === releaseTag); + if (matches.length !== 1) throw new ReleasePublicationError("draft_invalid"); + return matches[0] as z.infer; +}; + +const exactAssets = (value: unknown): readonly z.infer[] => { + if (!Array.isArray(value)) throw new ReleasePublicationError("provider_result_invalid"); + const assets = value.map((entry) => releaseAssetSchema.parse(entry)); + const names = assets.map((entry) => entry.name).sort(); + if (JSON.stringify(names) !== JSON.stringify([...expectedReleaseAssetNames])) { + throw new ReleasePublicationError("draft_invalid"); + } + return assets; +}; + +const downloadAndVerifyAssets = async ( + provider: ReleasePublicationProvider, + releaseId: number, + directory: string, + accepted: AcceptedBundle, +): Promise => { + await mkdir(directory, { mode: 0o700 }); + const assets = exactAssets(await provider.listReleaseAssets(releaseId)); + for (const asset of assets) { + const destination = join(directory, asset.name); + await provider.downloadReleaseAsset(asset.id, destination); + const downloaded = await readBounded(destination, 32 * 1024 * 1024); + const expected = accepted.releaseAssets.get(asset.name); + if (expected === undefined || !downloaded.equals(expected)) { + throw new ReleasePublicationError("draft_invalid"); + } + } +}; + +const verifyRunAuthority = async ( + provider: ReleasePublicationProvider, + arguments_: PublicationArguments, +): Promise => { + repositorySchema.parse(await provider.readRepository()); + const workflow = workflowSchema.parse(await provider.readWorkflow()); + const run = runSchema.parse(await provider.readRun(arguments_.runId)); + const fullTagRef = ["refs", "tags", releaseTag].join("/"); + const acceptedPaths = [workflowPath, `${workflowPath}@${releaseTag}`, `${workflowPath}@${fullTagRef}`]; + if ( + run.id !== arguments_.runId + || run.run_attempt !== arguments_.runAttempt + || run.workflow_id !== workflow.id + || run.head_sha !== arguments_.expectedCommit + || !acceptedPaths.includes(run.path) + ) throw new ReleasePublicationError("workflow_run_invalid"); + const artifactList = artifactListSchema.parse(await provider.readRunArtifacts(arguments_.runId)); + if ( + artifactList.total_count !== 1 + || artifactList.artifacts.length !== 1 + || artifactList.artifacts[0]?.workflow_run.id !== arguments_.runId + ) throw new ReleasePublicationError("workflow_run_invalid"); +}; + +const verifyReleaseMetadata = ( + release: z.infer, + accepted: AcceptedBundle, + expectedDraft: boolean, +): void => { + if ( + release.tag_name !== releaseTag + || release.name !== title + || release.body !== accepted.notes + || !release.prerelease + || release.draft !== expectedDraft + || release.immutable !== !expectedDraft + ) throw new ReleasePublicationError(expectedDraft ? "draft_invalid" : "published_release_invalid"); +}; + +const verifyCanonicalMarker = async ( + provider: ReleasePublicationProvider, + arguments_: PublicationArguments, +): Promise => { + const marker = await provider.readMarker( + `run-${String(arguments_.runId)}-attempt-${String(arguments_.runAttempt)}-${crypto.randomUUID()}`, + ); + let markerUrl: URL; + try { + markerUrl = new URL(marker.url); + } catch { + throw new ReleasePublicationError("marker_invalid"); + } + if ( + marker.status !== 200 + || marker.redirected + || markerUrl.origin !== "https://hra.sh" + || markerUrl.pathname !== "/.well-known/hra.json" + ) throw new ReleasePublicationError("marker_invalid"); + const parsedMarker = markerSchema.safeParse(marker.body); + if (!parsedMarker.success || parsedMarker.data.source.commit !== arguments_.expectedCommit) { + throw new ReleasePublicationError("marker_invalid"); + } +}; + +const verifyFinalPublicationAuthority = async ( + provider: ReleasePublicationProvider, + arguments_: PublicationArguments, +): Promise => { + const [tagCommit, mainCommit] = await Promise.all([ + provider.readTagCommit(arguments_.tag), + provider.readMainCommit(), + ]); + if (tagCommit !== arguments_.expectedCommit || mainCommit !== arguments_.expectedCommit) { + throw new ReleasePublicationError("release_authority_changed"); + } + try { + immutableSettingSchema.parse(await provider.readImmutableSetting()); + } catch { + throw new ReleasePublicationError("immutable_release_disabled"); + } +}; + +const verifyPublished = async ( + provider: ReleasePublicationProvider, + accepted: AcceptedBundle, + temporaryRoot: string, + expectedReleaseId: number, +): Promise => { + try { + const release = exactRelease(await provider.listReleases()); + verifyReleaseMetadata(release, accepted, false); + if (release.id !== expectedReleaseId) { + throw new ReleasePublicationError("published_release_invalid"); + } + await downloadAndVerifyAssets(provider, release.id, join(temporaryRoot, "published-assets"), accepted); + const publicUrl = `https://github.com/${repository}/releases/download/${releaseTag}/hra-${releaseTag}.tgz`; + const archive = accepted.releaseAssets.get(`hra-${releaseTag}.tgz`); + if (archive === undefined) throw new ReleasePublicationError("published_release_invalid"); + await provider.acceptPublicInstall( + publicUrl, + join(temporaryRoot, "public-install"), + sha256(archive), + ); + } catch (error: unknown) { + const code = error instanceof ReleasePublicationError + ? error.code + : "published_release_invalid"; + throw new ReleasePublicationError( + code === "public_acceptance_failed" ? code : "published_release_invalid", + "published_acceptance_failed", + ); + } +}; + +export async function executeReleasePublication(options: Readonly<{ + arguments: PublicationArguments; + provider: ReleasePublicationProvider; + temporaryRoot: string; +}>): Promise> { + const { arguments: arguments_, provider, temporaryRoot } = options; + await provider.verifyLocalSource( + arguments_.expectedCommit, + arguments_.action === "publish", + ); + await verifyRunAuthority(provider, arguments_); + const acceptedDirectory = join(temporaryRoot, "accepted"); + await mkdir(acceptedDirectory, { mode: 0o700 }); + await provider.downloadRunArtifact(arguments_.runId, artifactName, acceptedDirectory); + const accepted = await verifyAcceptedBundle(acceptedDirectory, arguments_.expectedCommit); + try { + await provider.acceptPackedInstall(accepted.archive, join(temporaryRoot, "packed-install")); + } catch { + throw new ReleasePublicationError("accepted_artifact_invalid"); + } + if (arguments_.action === "accept") { + let acceptedReleaseId: number | undefined; + try { + const release = exactRelease(await provider.listReleases()); + verifyReleaseMetadata(release, accepted, false); + acceptedReleaseId = release.id; + await downloadAndVerifyAssets( + provider, + release.id, + join(temporaryRoot, "staged-assets"), + accepted, + ); + } catch (error: unknown) { + const code = error instanceof ReleasePublicationError + ? error.code + : "published_release_invalid"; + throw new ReleasePublicationError(code, "published_acceptance_failed"); + } + await verifyPublished(provider, accepted, temporaryRoot, acceptedReleaseId); + return { commit: accepted.commit, status: "accepted", tag: releaseTag }; + } + + await verifyCanonicalMarker(provider, arguments_); + const release = exactRelease(await provider.listReleases()); + verifyReleaseMetadata(release, accepted, true); + await downloadAndVerifyAssets(provider, release.id, join(temporaryRoot, "staged-assets"), accepted); + + await verifyFinalPublicationAuthority(provider, arguments_); + try { + await provider.publishDraft(release.id); + } catch { + try { + const recovered = exactRelease(await provider.listReleases()); + verifyReleaseMetadata(recovered, accepted, false); + if (recovered.id !== release.id) { + throw new ReleasePublicationError("publication_unknown", "publication_unknown"); + } + } catch { + throw new ReleasePublicationError("publication_unknown", "publication_unknown"); + } + } + await verifyPublished(provider, accepted, temporaryRoot, release.id); + return { commit: accepted.commit, status: "published", tag: releaseTag }; +} + +type ProcessResult = Readonly<{ + exitCode: number; + stderr: Buffer; + stdout: Buffer; +}>; + +type ProcessRequest = Readonly<{ + arguments: readonly string[]; + cwd?: string; + environment?: Readonly; + executable: string; + outputMaximumBytes?: number; + timeoutMs?: number; +}>; + +const runProcess = async (request: ProcessRequest): Promise => + await new Promise((resolvePromise) => { + const child = spawn(request.executable, [...request.arguments], { + cwd: request.cwd ?? repositoryRoot, + env: request.environment ?? process.env, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let finished = false; + const maximum = request.outputMaximumBytes ?? commandOutputMaximumBytes; + const finish = (exitCode: number): void => { + if (finished) return; + finished = true; + clearTimeout(timer); + resolvePromise({ + exitCode, + stderr: Buffer.concat(stderr), + stdout: Buffer.concat(stdout), + }); + }; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + finish(124); + }, request.timeoutMs ?? 120_000); + child.stdout.on("data", (chunk: Buffer) => { + stdoutBytes += chunk.byteLength; + if (stdoutBytes <= maximum) stdout.push(chunk); + else { + child.kill("SIGKILL"); + finish(1); + } + }); + child.stderr.on("data", (chunk: Buffer) => { + stderrBytes += chunk.byteLength; + if (stderrBytes <= maximum) stderr.push(chunk); + else { + child.kill("SIGKILL"); + finish(1); + } + }); + child.once("error", () => finish(1)); + child.once("close", (exitCode) => finish(exitCode ?? 1)); + }); + +const requireSuccess = (result: ProcessResult): ProcessResult => { + if (result.exitCode !== 0) throw new ReleasePublicationError("command_failed"); + return result; +}; + +const parseJson = (result: ProcessResult): unknown => { + const stdout = requireSuccess(result).stdout; + if (stdout.byteLength === 0 || stdout.byteLength > providerJsonMaximumBytes) { + throw new ReleasePublicationError("provider_result_invalid"); + } + try { + return JSON.parse(stdout.toString("utf8")) as unknown; + } catch { + throw new ReleasePublicationError("provider_result_invalid"); + } +}; + +const assertInstalledExecutable = async ( + globalRoot: string, + environment: Readonly, + doctorStateRoot: string, +): Promise => { + const installedRoot = await realpath(join(globalRoot, "install", "global", "node_modules", "hra")); + await assertProductionPackageOnly(installedRoot); + const executable = join(globalRoot, "bin", "hra"); + const version = requireSuccess(await runProcess({ + arguments: ["--version"], + environment, + executable, + })).stdout.toString("utf8"); + if (version !== `hra ${releaseVersion}\n`) { + throw new ReleasePublicationError("accepted_artifact_invalid"); + } + type InstalledCliModule = Readonly<{ + main( + arguments_: readonly string[], + output: Readonly<{ writeStderr(value: string): void; writeStdout(value: string): void }>, + input: Readonly<{ statePaths: unknown }>, + ): Promise; + }>; + type InstalledPathsModule = Readonly<{ + resolveStatePaths(input: Readonly<{ rootDirectory: string }>): unknown; + }>; + const [cliModule, pathsModule] = await Promise.all([ + import(pathToFileURL(join(installedRoot, "src", "cli.ts")).href) as Promise, + import(pathToFileURL(join(installedRoot, "src", "storage", "paths.ts")).href) as Promise, + ]); + let doctorStdout = ""; + let doctorStderr = ""; + const doctorCode = await cliModule.main( + ["doctor", "--offline", "--json"], + { + writeStderr: (value) => { doctorStderr += value; }, + writeStdout: (value) => { doctorStdout += value; }, + }, + { statePaths: pathsModule.resolveStatePaths({ rootDirectory: doctorStateRoot }) }, + ); + const doctorValue = z.object({ + data: z.object({ offline: z.literal(true) }).passthrough(), + ok: z.literal(true), + }).passthrough().safeParse(((): unknown => { + try { + return JSON.parse(doctorStdout) as unknown; + } catch { + return null; + } + })()); + if (!doctorValue.success || doctorCode !== 0 || doctorStderr !== "") { + throw new ReleasePublicationError("accepted_artifact_invalid"); + } +}; + +const withoutEnvironmentKeys = ( + source: Readonly, + names: readonly string[], +): NodeJS.ProcessEnv => { + const omitted = new Set(names); + return Object.fromEntries( + Object.entries(source).filter(([name]) => !omitted.has(name)), + ); +}; + +export const buildGitHubCliEnvironment = ( + source: Readonly, +): NodeJS.ProcessEnv => withoutEnvironmentKeys(source, [ + "GH_ENTERPRISE_TOKEN", + "GH_HOST", + "GH_TOKEN", + "GITHUB_AUTH_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "GITHUB_TOKEN", + ]); + +export const assertLocalOperatorEnvironment = ( + environment: Readonly, +): void => { + if ( + environment.GITHUB_ACTIONS === "true" + || ["1", "true"].includes(environment.CI?.toLowerCase() ?? "") + ) throw new ReleasePublicationError("local_source_invalid"); +}; + +export const buildIsolatedInstallEnvironment = async ( + source: Readonly, + temporaryRoot: string, +): Promise> => { + const globalRoot = join(temporaryRoot, "bun-global"); + const temporaryDirectory = join(temporaryRoot, "tmp"); + const xdgRoot = join(temporaryRoot, "xdg"); + const npmConfig = join(temporaryRoot, "empty.npmrc"); + const netrc = join(temporaryRoot, "empty.netrc"); + const gitConfig = join(temporaryRoot, "empty.gitconfig"); + const bunConfig = join(xdgRoot, "config", ".bunfig.toml"); + await Promise.all([ + mkdir(temporaryDirectory, { mode: 0o700 }), + mkdir(join(xdgRoot, "cache"), { recursive: true, mode: 0o700 }), + mkdir(join(xdgRoot, "config"), { recursive: true, mode: 0o700 }), + mkdir(join(xdgRoot, "data"), { recursive: true, mode: 0o700 }), + mkdir(join(xdgRoot, "state"), { recursive: true, mode: 0o700 }), + ]); + await Promise.all([ + writeFile(npmConfig, "", { flag: "wx", mode: 0o600 }), + writeFile(netrc, "", { flag: "wx", mode: 0o600 }), + writeFile(gitConfig, "", { flag: "wx", mode: 0o600 }), + writeFile( + bunConfig, + '[install]\nregistry = "https://registry.npmjs.org"\n', + { flag: "wx", mode: 0o600 }, + ), + ]); + const environment: NodeJS.ProcessEnv = { + ...withoutEnvironmentKeys(source, [ + "BUN_AUTH_TOKEN", + "BUN_CONFIG_TOKEN", + "GIT_ASKPASS", + "GH_ENTERPRISE_TOKEN", + "GH_TOKEN", + "GITHUB_AUTH_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "GITHUB_TOKEN", + "NODE_AUTH_TOKEN", + "NPM_TOKEN", + "SSH_ASKPASS", + "SSH_AUTH_SOCK", + "npm_config__auth", + "npm_config__authToken", + ]), + BUN_CONFIG_REGISTRY: "https://registry.npmjs.org", + BUN_INSTALL: globalRoot, + DO_NOT_TRACK: "1", + GIT_CONFIG_GLOBAL: gitConfig, + NETRC: netrc, + NPM_CONFIG_REGISTRY: "https://registry.npmjs.org", + NPM_CONFIG_USERCONFIG: npmConfig, + TMPDIR: temporaryDirectory, + XDG_CACHE_HOME: join(xdgRoot, "cache"), + XDG_CONFIG_HOME: join(xdgRoot, "config"), + XDG_DATA_HOME: join(xdgRoot, "data"), + XDG_STATE_HOME: join(xdgRoot, "state"), + }; + return { environment, globalRoot }; +}; + +export class GitHubReleasePublicationProvider implements ReleasePublicationProvider { + constructor( + private readonly options: Readonly<{ + environment?: Readonly; + fetcher?: typeof fetch; + ghCli: string; + root?: string; + }>, + ) {} + + private get environment(): Readonly { + return this.options.environment ?? process.env; + } + + private get ghEnvironment(): Readonly { + return buildGitHubCliEnvironment(this.environment); + } + + private get root(): string { + return this.options.root ?? repositoryRoot; + } + + private async gh(arguments_: readonly string[], maximum = providerJsonMaximumBytes): Promise { + return await runProcess({ + arguments: [...arguments_], + cwd: this.root, + environment: this.ghEnvironment, + executable: this.options.ghCli, + outputMaximumBytes: maximum, + }); + } + + private async ghJson(arguments_: readonly string[]): Promise { + return parseJson(await this.gh(arguments_)); + } + + async verifyLocalSource(expectedCommit: string, requireCurrentMain: boolean): Promise { + assertLocalOperatorEnvironment(this.environment); + requireSuccess(await runProcess({ + arguments: ["fetch", "origin", "main", "--tags"], + cwd: this.root, + environment: this.environment, + executable: "git", + })); + const query = async (arguments_: readonly string[]): Promise => + requireSuccess(await runProcess({ + arguments: [...arguments_], + cwd: this.root, + environment: this.environment, + executable: "git", + outputMaximumBytes: 64 * 1024, + })).stdout.toString("utf8").trimEnd(); + const [status, head, tagCommit, remote, branch, main] = await Promise.all([ + query(["status", "--porcelain=v1", "--untracked-files=all"]), + query(["rev-parse", "HEAD^{commit}"]), + query(["rev-parse", `refs/tags/${releaseTag}^{commit}`]), + query(["remote", "get-url", "origin"]), + query(["branch", "--show-current"]), + query(["rev-parse", "origin/main^{commit}"]), + ]); + const remoteAccepted = remote === "https://github.com/hraness/hra.git" + || remote === "git@github.com:hraness/hra.git"; + if ( + status !== "" + || head !== expectedCommit + || tagCommit !== expectedCommit + || !remoteAccepted + || (requireCurrentMain && (branch !== "main" || main !== expectedCommit)) + ) throw new ReleasePublicationError("local_source_invalid"); + } + + async readRepository(): Promise { + return await this.ghJson(["api", `repos/${repository}`]); + } + + async readWorkflow(): Promise { + return await this.ghJson(["api", `repos/${repository}/actions/workflows/release.yml`]); + } + + async readRun(runId: number): Promise { + return await this.ghJson(["api", `repos/${repository}/actions/runs/${String(runId)}`]); + } + + async readRunArtifacts(runId: number): Promise { + return await this.ghJson([ + "api", + `repos/${repository}/actions/runs/${String(runId)}/artifacts?per_page=100`, + ]); + } + + async downloadRunArtifact(runId: number, name: string, destination: string): Promise { + requireSuccess(await this.gh([ + "run", + "download", + String(runId), + "--repo", + repository, + "--name", + name, + "--dir", + destination, + ])); + } + + async listReleases(): Promise { + const pages = await this.ghJson([ + "api", + "--paginate", + "--slurp", + `repos/${repository}/releases?per_page=100`, + ]); + if (!Array.isArray(pages) || !pages.every(Array.isArray)) { + throw new ReleasePublicationError("provider_result_invalid"); + } + return pages.flat(); + } + + async listReleaseAssets(releaseId: number): Promise { + return await this.ghJson([ + "api", + `repos/${repository}/releases/${String(releaseId)}/assets?per_page=100`, + ]); + } + + async downloadReleaseAsset(assetId: number, destination: string): Promise { + const result = requireSuccess(await this.gh([ + "api", + "-H", + "Accept: application/octet-stream", + `repos/${repository}/releases/assets/${String(assetId)}`, + ], commandOutputMaximumBytes)); + await writeFile(destination, result.stdout, { flag: "wx", mode: 0o600 }); + } + + async readTagCommit(tag: string): Promise { + const value = z.object({ sha: commitSchema }).passthrough().parse( + await this.ghJson(["api", `repos/${repository}/commits/refs/tags/${tag}`]), + ); + return value.sha; + } + + async readMainCommit(): Promise { + const value = z.object({ + object: z.object({ sha: commitSchema }).passthrough(), + }).passthrough().parse( + await this.ghJson(["api", `repos/${repository}/git/ref/heads/main`]), + ); + return value.object.sha; + } + + async readImmutableSetting(): Promise { + return await this.ghJson(["api", `repos/${repository}/immutable-releases`]); + } + + async readMarker(cacheKey: string): Promise { + const fetcher = this.options.fetcher ?? fetch; + let response: Response; + try { + response = await fetcher( + `https://hra.sh/.well-known/hra.json?release-check=${encodeURIComponent(cacheKey)}`, + { + cache: "no-store", + headers: { "Cache-Control": "no-cache, no-store, max-age=0", Pragma: "no-cache" }, + redirect: "manual", + signal: AbortSignal.timeout(30_000), + }, + ); + } catch { + throw new ReleasePublicationError("marker_invalid"); + } + let bytes: Uint8Array; + try { + bytes = await readResponseBounded(response, markerMaximumBytes, "marker_invalid"); + } catch { + throw new ReleasePublicationError("marker_invalid"); + } + if (bytes.byteLength === 0) { + throw new ReleasePublicationError("marker_invalid"); + } + let body: unknown; + try { + body = JSON.parse(new TextDecoder().decode(bytes)) as unknown; + } catch { + throw new ReleasePublicationError("marker_invalid"); + } + return { + body, + redirected: response.redirected, + status: response.status, + url: response.url, + }; + } + + async publishDraft(releaseId: number): Promise { + requireSuccess(await this.gh([ + "api", + "--method", + "PATCH", + "--silent", + "-F", + "draft=false", + "-F", + "prerelease=true", + `repos/${repository}/releases/${String(releaseId)}`, + ])); + } + + async acceptPackedInstall(archive: string, temporaryRoot: string): Promise { + await mkdir(temporaryRoot, { mode: 0o700 }); + const listing = requireSuccess(await runProcess({ + arguments: ["-tzf", archive], + cwd: this.root, + executable: "tar", + outputMaximumBytes: 4 * 1024 * 1024, + })).stdout.toString("utf8"); + const verboseListing = requireSuccess(await runProcess({ + arguments: ["-tvzf", archive], + cwd: this.root, + executable: "tar", + outputMaximumBytes: 8 * 1024 * 1024, + })).stdout.toString("utf8").trimEnd().split("\n"); + const entries = listing.trimEnd().split("\n"); + if ( + entries.length === 0 + || entries.length > 10_000 + || verboseListing.length !== entries.length + || verboseListing.some((entry) => !entry.startsWith("-") && !entry.startsWith("d")) + || entries.some((entry) => { + const segments = entry.split("/"); + return !entry.startsWith("package/") + || entry.startsWith("/") + || segments.includes("..") + || entry.includes("\\"); + }) + ) throw new ReleasePublicationError("accepted_artifact_invalid"); + const extracted = join(temporaryRoot, "extracted"); + await mkdir(extracted, { mode: 0o700 }); + requireSuccess(await runProcess({ + arguments: ["-xzf", archive, "-C", extracted], + cwd: this.root, + executable: "tar", + })); + await assertPublicTree(extracted); + await assertProductionPackageOnly(extracted); + const { environment, globalRoot } = await buildIsolatedInstallEnvironment( + this.environment, + temporaryRoot, + ); + requireSuccess(await runProcess({ + arguments: ["add", "--global", "--ignore-scripts", archive], + cwd: temporaryRoot, + environment, + executable: process.execPath, + })); + await assertInstalledExecutable( + globalRoot, + environment, + join(temporaryRoot, "doctor-state"), + ); + } + + async acceptPublicInstall( + url: string, + temporaryRoot: string, + expectedDigest: string, + ): Promise { + await mkdir(temporaryRoot, { mode: 0o700 }); + const { environment, globalRoot } = await buildIsolatedInstallEnvironment( + this.environment, + temporaryRoot, + ); + const fetcher = this.options.fetcher ?? fetch; + let response: Response; + try { + response = await fetcher(url, { + cache: "no-store", + redirect: "follow", + signal: AbortSignal.timeout(60_000), + }); + } catch { + throw new ReleasePublicationError("public_acceptance_failed", "published_acceptance_failed"); + } + let publicBytes: Uint8Array; + try { + publicBytes = await readResponseBounded( + response, + 32 * 1024 * 1024, + "public_acceptance_failed", + ); + } catch { + throw new ReleasePublicationError("public_acceptance_failed", "published_acceptance_failed"); + } + if ( + response.status !== 200 + || !response.url.startsWith("https://") + || publicBytes.byteLength === 0 + || sha256(publicBytes) !== expectedDigest + ) throw new ReleasePublicationError("public_acceptance_failed", "published_acceptance_failed"); + requireSuccess(await runProcess({ + arguments: ["add", "--global", url], + cwd: temporaryRoot, + environment, + executable: process.execPath, + })); + await assertInstalledExecutable( + globalRoot, + environment, + join(temporaryRoot, "doctor-state"), + ); + } +} + +export async function withBestEffortReleaseCleanup( + operation: () => Promise, + cleanup: () => Promise, +): Promise { + try { + return await operation(); + } finally { + // Cleanup contains only public release bytes and isolated empty state. It must + // never replace the authoritative pre- or post-publication outcome. + await cleanup().catch(() => undefined); + } +} + +export async function runReleasePublication(options: Readonly<{ + arguments: PublicationArguments; + provider: ReleasePublicationProvider; + temporaryParent?: string; +}>): Promise> { + const parent = options.temporaryParent ?? tmpdir(); + const temporaryRoot = await realpath(await mkdtemp(join(parent, "hra-release-publish-"))); + return await withBestEffortReleaseCleanup( + async () => await executeReleasePublication({ + arguments: options.arguments, + provider: options.provider, + temporaryRoot, + }), + async () => await rm(temporaryRoot, { force: true, recursive: true }), + ); +} + +if (import.meta.main) { + let exitCode = 1; + try { + const arguments_ = parsePublicationArguments(process.argv.slice(2)); + const result = await runReleasePublication({ + arguments: arguments_, + provider: new GitHubReleasePublicationProvider({ ghCli: arguments_.ghCli }), + }); + process.stdout.write(`${JSON.stringify({ schemaVersion: 1, ...result })}\n`); + exitCode = 0; + } catch (error: unknown) { + const failure = error instanceof ReleasePublicationError + ? error + : new ReleasePublicationError("provider_result_invalid"); + process.stderr.write(`${JSON.stringify({ + code: failure.code, + phase: failure.phase, + schemaVersion: 1, + status: "refused", + })}\n`); + } + process.exitCode = exitCode; +} diff --git a/scripts/release-workflow.test.ts b/scripts/release-workflow.test.ts index 35480b3..2c390a0 100644 --- a/scripts/release-workflow.test.ts +++ b/scripts/release-workflow.test.ts @@ -11,7 +11,7 @@ function asRecord(value: unknown, label: string): Record { } describe("release workflow", () => { - test("binds the artifact-only publish job to the exact repository", async () => { + test("binds the artifact-only draft job to the exact repository", async () => { const workflow = await readFile( join(import.meta.dir, "..", ".github", "workflows", "release.yml"), "utf8", @@ -19,27 +19,66 @@ describe("release workflow", () => { const document = asRecord(Bun.YAML.parse(workflow), "release workflow"); const jobs = asRecord(document.jobs, "release workflow jobs"); const verify = asRecord(jobs.verify, "verify job"); - const publish = asRecord(jobs.publish, "publish job"); - const steps = publish.steps; + const stage = asRecord(jobs.stage, "stage job"); + const steps = stage.steps; if (!Array.isArray(steps)) { - throw new TypeError("publish job steps must be an array"); + throw new TypeError("stage job steps must be an array"); } const createRelease = steps - .map((step, index) => asRecord(step, `publish step ${index}`)) - .find((step) => step.name === "Create the GitHub release"); + .map((step, index) => asRecord(step, `stage step ${index}`)) + .find((step) => step.name === "Create or resume the accepted release draft"); expect(createRelease).toBeDefined(); const releaseStep = asRecord(createRelease, "GitHub release step"); const environment = asRecord(releaseStep.env, "GitHub release environment"); - expect(publish.needs).toBe("verify"); + expect(stage.needs).toBe("verify"); expect(environment.GH_REPO).toBe("${{ github.repository }}"); expect(environment.GH_TOKEN).toBe("${{ github.token }}"); expect(releaseStep.run).toContain( - 'gh release create "$GITHUB_REF_NAME" release/*', + 'gh release create "$GITHUB_REF_NAME"', ); + expect(releaseStep.run).toContain('tag_commit="$(gh api "repos/$GH_REPO/commits/refs/tags/$GITHUB_REF_NAME"'); + expect(releaseStep.run).toContain('main_commit="$(gh api "repos/$GH_REPO/git/ref/heads/main"'); + expect(releaseStep.run).toContain('test "$main_commit" = "$accepted_commit"'); + expect(releaseStep.run).toContain('test "$accepted_commit" = "$GITHUB_SHA"'); + expect(releaseStep.run).toContain("https://hra.sh/.well-known/hra.json?release-check="); + expect(releaseStep.run).not.toContain("--location"); + expect(releaseStep.run).toContain("--write-out '%{http_code}'"); + expect(releaseStep.run).toContain('test "$marker_status" = 200'); + expect(releaseStep.run).toContain("'Cache-Control: no-cache, no-store, max-age=0'"); + expect(releaseStep.run).toContain(".schemaVersion == 2"); + expect(releaseStep.run).toContain(".generation == 1"); + expect(releaseStep.run).toContain(".repository.id == 1343008607"); + expect(releaseStep.run).toContain('.repository.path == "hraness/hra"'); + expect(releaseStep.run).toContain('.source.commit == $commit'); + expect(releaseStep.run).toContain("--prerelease"); + expect(releaseStep.run).toContain("--draft"); + expect(releaseStep.run).toContain("--notes-file release/RELEASE_NOTES.md"); + expect(releaseStep.run).toContain("--jq '.immutable')\" = false"); + expect(releaseStep.run).toContain('gh release upload "$GITHUB_REF_NAME"'); + expect(releaseStep.run).toContain("--clobber"); + expect(releaseStep.run).not.toContain("--generate-notes"); + expect(releaseStep.run).not.toContain("release/*"); + const stageSteps = steps.map((step, index) => asRecord(step, `stage step ${index}`)); + const stagedDraft = stageSteps.find((step) => + step.name === "Read back the staged draft assets"); + expect(stagedDraft?.run).toContain("shasum -a 256 -c SHA256SUMS"); + expect(stagedDraft?.run).toContain("= 4"); + expect(stagedDraft?.run).toContain(".artifact.spdx.json"); + expect(stagedDraft?.run).toContain(".ubuntu-24.04-x64.runtime.spdx.json"); + expect(stagedDraft?.run).toContain('test "$tag_commit" = "$accepted_commit"'); + expect(stagedDraft?.run).toContain('commits/refs/tags/$GITHUB_REF_NAME'); + expect(stagedDraft?.run).toContain('test "$main_commit" = "$accepted_commit"'); + expect(stagedDraft?.run).toContain("canonical-marker-publish.json"); + expect(stagedDraft?.run).toContain("--jq '.immutable')\" = false"); + expect(stagedDraft?.run).toContain('.source.commit == $commit'); + expect(stagedDraft?.run).not.toContain("immutable-releases"); + expect(stagedDraft?.run).not.toContain("--draft=false"); + expect(jobs.publish).toBeUndefined(); + expect(jobs.accept).toBeUndefined(); if (!Array.isArray(verify.steps)) { throw new TypeError("verify job steps must be an array"); @@ -48,12 +87,59 @@ describe("release workflow", () => { const checkout = verifySteps.find((step) => step.name === "Check out the tagged source"); const exactHead = verifySteps.find((step) => step.name === "Verify exact release head and ordering"); const generated = verifySteps.find((step) => step.name === "Verify generated public documents"); + const availability = verifySteps.find((step) => + step.name === "Verify public release availability"); + const packedInstall = verifySteps.find((step) => + step.name === "Accept the exact packed installation"); + const artifactSbom = verifySteps.find((step) => + step.name === "Generate the artifact identity SPDX SBOM"); + const artifactSbomVerification = verifySteps.find((step) => + step.name === "Verify the artifact identity SPDX SBOM"); + const runtimeSbom = verifySteps.find((step) => + step.name === "Generate the Ubuntu 24.04 x64 runtime SPDX SBOM"); + const runtimeSbomVerification = verifySteps.find((step) => + step.name === "Verify the Ubuntu 24.04 x64 runtime SPDX SBOM"); + const releaseMetadata = verifySteps.find((step) => + step.name === "Preserve the reviewed release metadata"); expect(asRecord(checkout, "release checkout step").with).toEqual({ "fetch-depth": 0 }); - expect(exactHead?.run).toContain("tagged_commit=\"$(git rev-parse \"$GITHUB_REF_NAME^{commit}\")\""); + expect(exactHead?.run).toContain("tagged_commit=\"$(git rev-parse \"refs/tags/$GITHUB_REF_NAME^{commit}\")\""); + expect(workflow).not.toContain('commits/$GITHUB_REF_NAME'); expect(exactHead?.run).toContain("test \"$tagged_commit\" = \"$main_commit\""); expect(exactHead?.run).not.toContain("merge-base --is-ancestor"); expect(asRecord(generated, "release generated-documents step").run) .toBe("bun run build:site -- --check"); + expect(availability?.run).toContain('publicReleaseState !== "release-ready"'); + expect(availability?.run).toContain('endpoints.hostedSync !== "live"'); + expect(packedInstall?.run).toContain("./release/hra-${GITHUB_REF_NAME}.tgz"); + expect(packedInstall?.run).toContain("check-installed-package.ts"); + expect(packedInstall?.run).not.toContain("github:${GITHUB_REPOSITORY}"); + const artifactSbomStep = asRecord(artifactSbom, "artifact identity SBOM step"); + const artifactSbomWith = asRecord( + artifactSbomStep.with, + "artifact identity SBOM inputs", + ); + expect(artifactSbomWith.file).toBe("release/hra-${{ github.ref_name }}.tgz"); + expect(artifactSbomWith.path).toBeUndefined(); + expect(artifactSbomWith["output-file"]).toContain(".artifact.spdx.json"); + expect(asRecord(artifactSbomStep.env, "artifact identity SBOM environment")) + .toMatchObject({ SYFT_SOURCE_NAME: "hra", SYFT_SOURCE_VERSION: "${{ env.HRA_RELEASE_VERSION }}" }); + expect(artifactSbomVerification?.run).toContain("artifact SPDX checksum does not bind the tarball"); + const runtimeSbomStep = asRecord(runtimeSbom, "runtime SBOM step"); + const runtimeSbomWith = asRecord(runtimeSbomStep.with, "runtime SBOM inputs"); + expect(runtimeSbomWith.path).toBe("${{ runner.temp }}/hra-global/install/global/node_modules"); + expect(runtimeSbomWith.config).toBe(".github/syft-runtime.yaml"); + expect(runtimeSbomWith.file).toBeUndefined(); + expect(runtimeSbomWith["output-file"]).toContain(".ubuntu-24.04-x64.runtime.spdx.json"); + expect(runtimeSbomVerification?.run).toContain('["@openai/codex", "0.149.0"]'); + expect(runtimeSbomVerification?.run).toContain('["convex", "1.45.0"]'); + expect(runtimeSbomVerification?.run).toContain('["zod", "4.4.3"]'); + expect(releaseMetadata?.run).toContain('Bun.file("docs/beta-release-notes.md")'); + expect(releaseMetadata?.run).toContain("git rev-parse 'HEAD^{commit}' > release/RELEASE_COMMIT"); + + const syftConfig = await readFile(join(import.meta.dir, "..", ".github", "syft-runtime.yaml"), "utf8"); + expect(Bun.YAML.parse(syftConfig)).toEqual({ + "select-catalogers": ["+javascript-package-cataloger"], + }); }); test("gives the public-text gate complete Git history in CI", async () => { diff --git a/site/content.test.ts b/site/content.test.ts index aa6baca..6d2bf86 100644 --- a/site/content.test.ts +++ b/site/content.test.ts @@ -18,7 +18,7 @@ describe("public content contract", () => { expect(publicContent).toMatchObject({ doctorCommand: "hra doctor --offline", initCommand: "hra init", - installCommand: "bun add --global github:hraness/hra#v0.1.0", + installCommand: "bun add --global https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz", links: { github: "https://github.com/hraness/hra", }, @@ -36,12 +36,15 @@ describe("public content contract", () => { html.indexOf(publicContent.installCommand), ); expect(html.indexOf(publicContent.installCommand)).toBeLessThan( - html.indexOf(publicContent.initCommand), - ); - expect(html.indexOf(publicContent.initCommand)).toBeLessThan( html.indexOf(publicContent.doctorCommand), ); + expect(html.indexOf(publicContent.doctorCommand)).toBeLessThan( + html.indexOf(publicContent.initCommand), + ); expect(markdown.indexOf(publicContent.installCommand)).toBeLessThan( + markdown.indexOf(publicContent.doctorCommand), + ); + expect(markdown.indexOf(publicContent.doctorCommand)).toBeLessThan( markdown.indexOf(publicContent.initCommand), ); }); @@ -94,6 +97,12 @@ describe("public content contract", () => { expect(markdown).toContain("hra device approve "); expect(html).toContain("hra device approve <pending-device-id-or-prefix>"); for (const surface of [markdown, html]) { + expect(surface).toContain("An unset"); + expect(surface).toContain("hosted deployment"); + expect(surface).toContain("explicit empty value"); + expect(surface).toContain("self-managed Convex deployment"); + expect(surface).toContain("permanently binds that local state root"); + expect(surface).not.toContain("require an explicit deployment URL"); expect(surface).toContain("automatically registers the current installation"); expect(surface).toContain("registered as pending"); expect(surface).toContain("no synchronized data, execution, or key authority"); @@ -233,7 +242,12 @@ describe("public content contract", () => { "Provider login and request IDs, permission values, MCP field contracts, protected answers, or response digests.", "Email access alone does not recover that key.", "an uncontested, unrevoked copy can impersonate that device", - "The website uses no analytics, cookies, remote fonts, or executable JavaScript.", + "HRA uses Convex to authenticate the HRA identity", + "HRA uses Resend to deliver verification email.", + "one-time verification code and message content", + "Vercel serves hra.sh.", + "GitHub hosts the source repository, releases, and release downloads.", + "HRA does not add analytics, cookies, remote fonts, or executable JavaScript to the site.", ]; const surfaces = [ renderReadmeMarkdown(), @@ -249,6 +263,34 @@ describe("public content contract", () => { } }); + test("publishes exact beta prerequisites and package lifecycle limits", () => { + const markdown = renderReadmeMarkdown(); + const surfaces = [markdown, renderSiteHtml()]; + for (const surface of surfaces) { + expect(surface).toContain("HRA requires Bun 1.3.14"); + expect(surface).toContain("support macOS and Linux"); + expect(surface).toContain("bun add --global https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz"); + expect(surface).toContain("bun remove --global hra"); + expect(surface).toContain("hra daemon stop"); + expect(surface).toContain("hra daemon status --json"); + expect(surface).toContain("hra daemon start"); + expect(surface).toContain("Removing the package does not remove"); + expect(surface).toContain("local profiles, session history, recovery evidence, or cloud account"); + expect(surface).toContain("Do not install a moving branch"); + expect(surface).toContain("verified repair installation of v0.1.0"); + expect(surface).toContain("replace both v0.1.0 occurrences"); + } + const updateStart = markdown.indexOf("Before updating"); + const updateDoctor = markdown.indexOf("hra doctor --offline", updateStart); + const updateRestart = markdown.indexOf("hra daemon start", updateStart); + expect(updateDoctor).toBeGreaterThan(updateStart); + expect(updateRestart).toBeGreaterThan(updateDoctor); + const removalWarning = markdown.indexOf("Removing the package does not remove"); + const removalCommand = markdown.indexOf("bun remove --global hra"); + expect(removalWarning).toBeGreaterThan(-1); + expect(removalCommand).toBeGreaterThan(removalWarning); + }); + test("publishes the local interaction deadline boundary", () => { const surfaces = [renderReadmeMarkdown(), renderSiteHtml()]; for (const surface of surfaces) { diff --git a/site/content.ts b/site/content.ts index 021a8e6..eff60cd 100644 --- a/site/content.ts +++ b/site/content.ts @@ -1,4 +1,4 @@ -export type EndpointAvailability = "beta-not-yet-live" | "live"; +export type EndpointAvailability = "beta-not-yet-live" | "live" | "release-ready"; export interface PublicEndpoints { readonly betaTag: EndpointAvailability; @@ -91,6 +91,15 @@ const privacyBlocks: readonly ContentBlock[] = [ paragraph( text("The sync service necessarily sees the verified HRA email address, device identifiers, record types, revisions, ciphertext sizes, timestamps, and execution-lease or command lifecycle metadata. It cannot decrypt session content without a paired device key. Email access alone does not recover that key."), ), + paragraph( + text("HRA uses Convex to authenticate the HRA identity and store server-visible metadata plus encrypted projections. Convex receives the verified email address and the service metadata described above, but not the keys required to decrypt session content."), + ), + paragraph( + text("HRA uses Resend to deliver verification email. Resend receives the recipient email address, sender identity, one-time verification code and message content, and ordinary delivery metadata. It receives no Codex credentials or encrypted session projection."), + ), + paragraph( + text("Vercel serves hra.sh. GitHub hosts the source repository, releases, and release downloads. When you visit or download from either service, that provider receives ordinary web request metadata such as the requested URL, IP address, user agent, and time. HRA does not add analytics, cookies, remote fonts, or executable JavaScript to the site."), + ), paragraph( text("Device credentials are bearer credentials, not hardware-bound proofs. Connection and generation fencing blocks a copied credential from creating a second concurrent connection or surviving revocation, but an uncontested, unrevoked copy can impersonate that device until it is detected and revoked."), ), @@ -98,7 +107,7 @@ const privacyBlocks: readonly ContentBlock[] = [ text("Compact-projection recovery is append-only. It preserves every older encrypted cloud chunk, opens a new stream epoch, and keeps the acknowledged unsynced interval visible as a recovery gap until authenticated account deletion."), ), paragraph( - text("The website uses no analytics, cookies, remote fonts, or executable JavaScript. Codex activity remains subject to OpenAI's own service and privacy terms."), + text("Codex activity remains subject to OpenAI's own service and privacy terms."), ), { kind: "notice", @@ -109,11 +118,13 @@ const privacyBlocks: readonly ContentBlock[] = [ }, ]; +export const publicReleaseState: "release-ready" | "staged" = "staged"; + export const publicContent: PublicContent = { productName: "HRA", description: "A persistent Bun CLI for isolated Codex accounts, live sessions, safe macOS account switching, and optional encrypted sync.", siteUrl: "https://hra.sh", - installCommand: "bun add --global github:hraness/hra#v0.1.0", + installCommand: "bun add --global https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz", initCommand: "hra init", doctorCommand: "hra doctor --offline", endpoints: { @@ -147,6 +158,49 @@ export const publicContent: PublicContent = { ), ], sections: [ + { + id: "install-update-and-remove", + heading: "Install, update, and remove", + blocks: [ + paragraph( + text("HRA requires Bun 1.3.14. The CLI and local daemon support macOS and Linux; supported ChatGPT desktop account switching is macOS-only. Install one reviewed immutable tag, then verify the binary before initialization:"), + ), + { + kind: "commands", + commands: [ + "bun --version", + "bun add --global https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz", + "hra --version", + "hra doctor --offline", + ], + }, + paragraph( + text("Before replacing the installed binary, stop the persistent daemon and confirm that its old process has released authority. The command below performs a verified repair installation of v0.1.0. For a future update, replace both v0.1.0 occurrences in the URL with the exact reviewed release version, verify it, then restart explicitly. Do not install a moving branch for a release machine:"), + ), + { + kind: "commands", + commands: [ + "hra daemon stop", + "hra daemon status --json", + "bun add --global https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz", + "hra --version", + "hra doctor --offline", + "hra daemon start", + ], + }, + paragraph( + text("Removing the package does not remove HRA's local profiles, session history, recovery evidence, or cloud account. Log out each Codex profile and complete any intended cloud-account deletion before uninstalling. Then stop the daemon, confirm that it is stopped, and remove the installed command:"), + ), + { + kind: "commands", + commands: [ + "hra daemon stop", + "hra daemon status --json", + "bun remove --global hra", + ], + }, + ], + }, { id: "first-account", heading: "First account", @@ -185,9 +239,9 @@ export const publicContent: PublicContent = { heading: "Cloud sign-in and device pairing", blocks: [ paragraph( - text("The hosted endpoint is beta-not-yet-live. Until it is published, these commands require an explicit deployment URL in "), + text("The hosted endpoint is beta-not-yet-live. An unset "), code("HRA_CONVEX_URL"), - text(" before the daemon starts. HRA accepts cloud credentials only as protected JSON on standard input or a nonterminal file descriptor. It rejects email addresses, identity invites, and verification codes on the command line:"), + text(" selects HRA's hosted deployment. Set it to an explicit empty value before the first daemon starts to disable cloud transport. A nonempty HTTPS value selects a self-managed Convex deployment. The first valid selection permanently binds that local state root; a later mismatch fails closed instead of moving credentials or recovery state. HRA accepts cloud credentials only as protected JSON on standard input or a nonterminal file descriptor. It rejects email addresses, identity invites, and verification codes on the command line:"), ), { kind: "commands", @@ -605,8 +659,8 @@ export const renderReadmeMarkdown = (content: PublicContent = publicContent): st return [ `# ${content.productName}`, `\`\`\`sh\n${content.installCommand}\n\`\`\``, - `\`\`\`sh\n${content.initCommand}\n\`\`\``, `\`\`\`sh\n${content.doctorCommand}\n\`\`\``, + `\`\`\`sh\n${content.initCommand}\n\`\`\``, renderMarkdownBlocks(content.introduction, 3), sections, ].join("\n\n") + "\n"; diff --git a/site/social-card.svg b/site/social-card.svg index d1d8b01..edadfbb 100644 --- a/site/social-card.svg +++ b/site/social-card.svg @@ -4,6 +4,7 @@ HRA - bun add --global github:hraness/hra#v0.1.0 + bun add --global + https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz isolated accounts · encrypted sync · one CLI diff --git a/site/template.ts b/site/template.ts index 0074ed2..eaa4939 100644 --- a/site/template.ts +++ b/site/template.ts @@ -118,8 +118,8 @@ ${renderHead(content, {

${escapeHtml(content.productName)}

${escapeHtml(content.installCommand)}
-
${escapeHtml(content.initCommand)}
${escapeHtml(content.doctorCommand)}
+
${escapeHtml(content.initCommand)}
${content.introduction.map((block, index) => renderBlock(block, "introduction", index)).join("\n ")}
diff --git a/src/cli.test.ts b/src/cli.test.ts index 16f50d8..e8a1a8d 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -25,6 +25,7 @@ import { import type { CloudSecretCustodyPort } from "./cloud/local-control"; import type { CommandResponse, LocalCommand } from "./domain/contracts"; import { DAEMON_PROTOCOL, DaemonLock } from "./daemon/daemon-lock"; +import { createAcceptanceInstallation } from "../scripts/live-acceptance-installation"; import { initializeStatePaths, resolveStatePaths } from "./storage/paths"; import { FileSecretBackend, GenerationalSecretCustody } from "./storage/secret-custody"; @@ -113,6 +114,46 @@ describe("CLI entry point", () => { } }); + test("threads a source-only installation through initialization without changing HOME", async () => { + const runId = "018f1f55-3f10-7c1a-8f7b-c6dc608bcd3b"; + const runRoot = await realpath( + await mkdtemp(join(tmpdir(), `hra-live-acceptance-${runId}-`)), + ); + const documentsDirectory = join(runRoot, "project-a-fixture"); + await mkdir(documentsDirectory, { mode: 0o700 }); + try { + const expectedHomeDirectory = process.env.HOME; + if (expectedHomeDirectory === undefined) throw new Error("Test requires HOME."); + const installation = createAcceptanceInstallation({ + device: "a", + documentsDirectory, + expectedHomeDirectory, + rootDirectory: join(runRoot, "device-a-fixture"), + runId, + type: "hra-live-acceptance-device", + version: 1, + }); + const captured = capture(); + + expect(await main(["init", "--yes", "--json"], captured.output, { + installation, + })).toBe(0); + expect(JSON.parse(captured.read().stdout)).toMatchObject({ + data: { + defaultProjectCreated: true, + initialized: true, + stateRoot: installation.paths.root, + }, + ok: true, + version: 1, + }); + expect(process.env.HOME).toBe(expectedHomeDirectory); + expect((await lstat(installation.paths.database)).isFile()).toBe(true); + } finally { + await rm(runRoot, { force: true, recursive: true }); + } + }); + test("refresh-all skips signed-out accounts, bounds concurrency, and reports every outcome", async () => { const accounts = Array.from({ length: 7 }, (_, index) => ({ id: `acct_${String(index).padStart(32, "0")}`, diff --git a/src/cli.ts b/src/cli.ts index 57dd71f..6ba0461 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -78,8 +78,13 @@ import { SessionEventCursorCodec } from "./daemon/session-event-cursor"; import { HraService } from "./daemon/service"; import { AccountUsagePoller } from "./daemon/usage-poller"; import { ExactChatGptBundlePort, LocalDesktopSwitchPort, PidBoundDesktopAccountRuntime } from "./desktop/index"; +import { + assertInstallationHome, + createProductionInstallation, + type HraInstallation, +} from "./installation"; import { initializeStatePaths, resolveStatePaths, type StatePaths } from "./storage/paths"; -import { GenerationalSecretCustody } from "./storage/secret-custody"; +import type { GenerationalSecretCustody } from "./storage/secret-custody"; import { StateStore } from "./storage/state-store"; import { HRA_VERSION } from "./version"; @@ -272,7 +277,9 @@ type ProjectionRecoverySummary = session: string; }>; -type CliMainInput = Readonly<{ +export type CliMainInput = Readonly<{ + installation?: HraInstallation; + startDaemon?: (installation: HraInstallation) => Promise; statePaths?: StatePaths; callDaemon?: (command: LocalCommand, signal?: AbortSignal) => Promise; getRemoteCommandStatus?: CloudRemoteControlPort["getRemoteCommandStatus"]; @@ -737,9 +744,13 @@ function renderProjectionRecoveryFailure( }, invocation.json, output); } -async function startDaemonProcess(): Promise { +async function startDaemonProcess(installation: HraInstallation): Promise { + assertInstallationHome(installation); + if (installation.kind !== "production") { + throw new Error("A live-acceptance daemon must be started by its source-only worker."); + } const cliPath = process.argv[1] ?? import.meta.path; - const paths = resolveStatePaths(); + const paths = installation.paths; await initializeStatePaths(paths); const child = Bun.spawn([process.execPath, cliPath, "daemon", "run"], { detached: true, @@ -767,13 +778,16 @@ async function startDaemonProcess(): Promise { } async function callWithAutostart( + installation: HraInstallation, command: LocalCommand, signal?: AbortSignal, + injectedStart?: (installation: HraInstallation) => Promise, ): Promise>> { - const paths = resolveStatePaths(); + assertInstallationHome(installation); + const paths = installation.paths; return await callWithSafeAutostart( async () => await callLocalDaemon({ paths, command, ...(signal === undefined ? {} : { signal }) }), - startDaemonProcess, + async () => await (injectedStart ?? startDaemonProcess)(installation), ); } @@ -864,11 +878,16 @@ async function offlineDoctor(json: boolean, output: Output, paths: StatePaths): return data.healthy ? 0 : 1; } -async function editSessionNote(session: string, json: boolean, output: Output): Promise { +async function editSessionNote( + session: string, + json: boolean, + output: Output, + callDaemon: (command: LocalCommand, signal?: AbortSignal) => Promise, +): Promise { if (json || !process.stdin.isTTY || !process.stdout.isTTY) { return renderFailure({ code: "INTERACTION_REQUIRED", message: "Note editing requires an interactive terminal. Use `session note set` for scripts." }, json, output); } - const current = await callWithAutostart({ kind: "session.note.get", session }); + const current = await callDaemon({ kind: "session.note.get", session }); if (!current.ok) return renderFailure(current.error, false, output); const note = typeof current.data === "object" && current.data !== null && "note" in current.data && typeof current.data.note === "string" ? current.data.note : ""; const directory = await mkdtemp(join(tmpdir(), "hra-note-")); @@ -882,7 +901,7 @@ async function editSessionNote(session: string, json: boolean, output: Output): const exitCode = await child.exited; if (exitCode !== 0) throw new Error(`Editor exited with status ${exitCode}.`); const edited = await readFile(file, "utf8"); - const response = await callWithAutostart({ kind: "session.note.set", session, note: edited }); + const response = await callDaemon({ kind: "session.note.set", session, note: edited }); if (!response.ok) return renderFailure(response.error, false, output); renderSuccess({ kind: "session.note.set", session, note: edited }, response.data, false, output); return 0; @@ -1112,9 +1131,10 @@ export function renderRemoteSuccess( async function executeRemoteInvocation( invocation: Extract, output: Output, - input: Pick = {}, + input: Pick = {}, ): Promise { - const paths = resolveStatePaths(); + const installation = input.installation ?? createProductionInstallation(); + assertInstallationHome(installation); const controller = new AbortController(); const injectedStatus = invocation.command.kind === "remote.command" ? input.getRemoteCommandStatus @@ -1125,8 +1145,9 @@ async function executeRemoteInvocation( try { const control = injectedStatus === undefined ? await createLocalCloudControlFromEnvironment({ + environment: installation.cloudEnvironment, lifetimeSignal: controller.signal, - secretCustody: new GenerationalSecretCustody(paths), + secretCustody: installation.createSecretCustody(), }) : null; if (control === null && injectedStatus === undefined) { @@ -1237,8 +1258,11 @@ async function joinBeforeDeadline(operation: string, promise: Promise, dea } } -async function runDaemon(): Promise { - const paths = resolveStatePaths(); +export async function runDaemon( + installation: HraInstallation = createProductionInstallation(), +): Promise { + assertInstallationHome(installation); + const paths = installation.paths; await initializeStatePaths(paths); await mkdir(paths.runtime, { recursive: true, mode: 0o700 }); const daemonLock = await DaemonLock.acquire(paths); @@ -1287,6 +1311,15 @@ async function runDaemon(): Promise { checkpointBoot(); const serviceReference: { current?: HraService } = {}; codex = new PinnedCodexRuntimeManager({ + ...(installation.kind === "live_acceptance" + ? { + codexEnvironment: installation.codexEnvironment, + prepareCodexHome: installation.prepareCodexHome, + } + : {}), + ...(installation.credentialStorePreflight === null + ? {} + : { credentialStorePreflight: installation.credentialStorePreflight }), isCurrent: (authority) => { try { const profile = activeStore.requireProfile(authority.id); @@ -1302,8 +1335,8 @@ async function runDaemon(): Promise { fact: async (authority, fact) => { await serviceReference.current?.observeCodexFact(authority, fact); }, }, }); - const secretCustody = new GenerationalSecretCustody(paths); - const cloudEnvironment = { HRA_CONVEX_URL: process.env.HRA_CONVEX_URL }; + const secretCustody = installation.createSecretCustody(); + const cloudEnvironment = installation.cloudEnvironment; const cloudStartup = await resolveDaemonCloudStartup({ environment: cloudEnvironment, isSessionTerminal: (sessionPublicId) => { @@ -1422,7 +1455,7 @@ async function runDaemon(): Promise { } } checkpointBoot(); - const desktop = process.platform === "darwin" + const desktop = process.platform === "darwin" && installation.desktopSwitching ? (() => { const bundle = new ExactChatGptBundlePort("/Applications/ChatGPT.app"); return new LocalDesktopSwitchPort({ @@ -1565,8 +1598,16 @@ async function runDaemon(): Promise { const commandCaller = ( input: CliMainInput, -): ((command: LocalCommand, signal?: AbortSignal) => Promise) => - input.callDaemon ?? callWithAutostart; +): ((command: LocalCommand, signal?: AbortSignal) => Promise) => { + if (input.callDaemon !== undefined) return input.callDaemon; + const installation = input.installation ?? createProductionInstallation(); + return async (command, signal) => await callWithAutostart( + installation, + command, + signal, + input.startDaemon, + ); +}; const protectedInputDescriptor = (source: ProtectedInputSource): number => source.kind === "stdin" ? 0 : source.fd; @@ -2037,11 +2078,21 @@ async function executeInvocation( output: Output, input: CliMainInput = {}, ): Promise { + const installation = input.installation ?? createProductionInstallation(); + assertInstallationHome(installation); + const callDaemon = commandCaller({ ...input, installation }); if (invocation.kind === "help") { output.writeStdout(`${usageForGroup(invocation.group)}\n`); return 0; } if (invocation.kind === "version") { output.writeStdout(`hra ${HRA_VERSION}\n`); return 0; } - if (invocation.kind === "init") return await initialize(invocation.yes, invocation.json, output); - if (invocation.kind === "daemon.run") return await runDaemon(); - if (invocation.kind === "remote") return await executeRemoteInvocation(invocation, output, input); + if (invocation.kind === "init") { + return await initialize(invocation.yes, invocation.json, output, { + documentsDirectory: installation.documentsDirectory, + paths: installation.paths, + }); + } + if (invocation.kind === "daemon.run") return await runDaemon(installation); + if (invocation.kind === "remote") { + return await executeRemoteInvocation(invocation, output, { ...input, installation }); + } if (invocation.kind === "auth.login-protected") { return await executeProtectedAuthLogin(invocation, output, input); } @@ -2055,7 +2106,7 @@ async function executeInvocation( return renderFailure(invocation.error, invocation.json, output); } if (invocation.kind === "sync.projection-recover") { - const response = await (input.callDaemon ?? callWithAutostart)(invocation.command); + const response = await callDaemon(invocation.command); if (!response.ok) { return renderProjectionRecoveryFailure(response.error, invocation, output); } @@ -2063,7 +2114,7 @@ async function executeInvocation( } if (invocation.kind === "daemon.start") { try { - const existing = await callLocalDaemon({ paths: resolveStatePaths(), command: { kind: "daemon.status" }, deadlineMs: 500 }); + const existing = await callLocalDaemon({ paths: installation.paths, command: { kind: "daemon.status" }, deadlineMs: 500 }); if (existing.ok) { daemonStatusIdentity(existing); renderSuccess({ kind: "daemon.status" }, existing.data, invocation.json, output); @@ -2072,15 +2123,15 @@ async function executeInvocation( } catch (error: unknown) { if (!isLocalDaemonUnavailable(error)) throw error; } - await startDaemonProcess(); - const response = await callLocalDaemon({ paths: resolveStatePaths(), command: { kind: "daemon.status" } }); + await (input.startDaemon ?? startDaemonProcess)(installation); + const response = await callLocalDaemon({ paths: installation.paths, command: { kind: "daemon.status" } }); if (!response.ok) return renderFailure(response.error, invocation.json, output); daemonStatusIdentity(response); renderSuccess({ kind: "daemon.status" }, response.data, invocation.json, output); return 0; } if (invocation.command.kind === "doctor" && invocation.command.offline) { - return await offlineDoctor(invocation.json, output, input.statePaths ?? resolveStatePaths()); + return await offlineDoctor(invocation.json, output, input.statePaths ?? installation.paths); } if ( invocation.command.kind === "account.usage" @@ -2089,10 +2140,12 @@ async function executeInvocation( ) { return await executeUsageRefreshAll(invocation.command, invocation.json, output, input); } - if (invocation.command.kind === "session.note.edit") return await editSessionNote(invocation.command.session, invocation.json, output); + if (invocation.command.kind === "session.note.edit") { + return await editSessionNote(invocation.command.session, invocation.json, output, callDaemon); + } if (invocation.command.kind === "daemon.status" || invocation.command.kind === "daemon.stop") { try { - const paths = resolveStatePaths(); + const paths = installation.paths; const response = await callLocalDaemon({ paths, command: invocation.command, deadlineMs: 500 }); if (!response.ok) return renderFailure(response.error, invocation.json, output); const identity = daemonStatusIdentity(response); @@ -2128,7 +2181,7 @@ async function executeInvocation( const command = invocation.command.kind === "project.add" ? { ...invocation.command, path: await realpath(invocation.command.path) } : invocation.command; - const response = await (input.callDaemon ?? callWithAutostart)(command); + const response = await callDaemon(command); if (!response.ok) { return command.kind === "sync.now" ? renderSyncNowFailure(response.error, invocation.json, output) @@ -2146,14 +2199,17 @@ export async function main( output: Output = processOutput, input: CliMainInput = {}, ): Promise { - const interactive = input.interactive + const installation = input.installation ?? createProductionInstallation(); + assertInstallationHome(installation); + const resolvedInput = { ...input, installation }; + const interactive = resolvedInput.interactive ?? (process.stdin.isTTY && process.stderr.isTTY); - if (argv.length === 0 && interactive) return await runPersistentShell(output, input); + if (argv.length === 0 && interactive) return await runPersistentShell(output, resolvedInput); const json = requestsJsonOutput(argv); let invocation: CliInvocation | undefined; try { invocation = parseCli(argv); - return await executeInvocation(invocation, output, input); + return await executeInvocation(invocation, output, resolvedInput); } catch (error: unknown) { const syncNow = invocation?.kind === "command" && invocation.command.kind === "sync.now"; const projectionRecovery = invocation?.kind === "sync.projection-recover" diff --git a/src/cli/parser.test.ts b/src/cli/parser.test.ts index 3b626ed..40870f0 100644 --- a/src/cli/parser.test.ts +++ b/src/cli/parser.test.ts @@ -81,6 +81,14 @@ describe("CLI parser", () => { test("rejects unknown flags instead of ignoring them", () => { expect(() => parseCli(["account", "list", "--surprise"])).toThrow(CliUsageError); + for (const argv of [ + ["daemon", "run", "--state-root", "/tmp/other"], + ["daemon", "run", "--socket", "/tmp/other.sock"], + ["daemon", "run", "--capability", "/tmp/other.capability"], + ["daemon", "run", "--live-acceptance-fd", "3"], + ]) { + expect(() => parseCli(argv)).toThrow(CliUsageError); + } }); test("never repeats unknown argv values in usage errors", () => { diff --git a/src/cloud/identity-custody.ts b/src/cloud/identity-custody.ts index 0d8b691..7e52907 100644 --- a/src/cloud/identity-custody.ts +++ b/src/cloud/identity-custody.ts @@ -10,7 +10,7 @@ const legacyCloudSlots = [ "cloud-auth", "cloud-auth-logout", ] as const; -export const DEFAULT_CLOUD_DEPLOYMENT_URL = "https://quiet-bison-462.convex.cloud"; +export const DEFAULT_CLOUD_DEPLOYMENT_URL = "https://qualified-hummingbird-537.convex.cloud"; const scopedSlots = new Set([ "cloud-account-key", "cloud-account-deletion", diff --git a/src/cloud/inviteAuthority.ts b/src/cloud/inviteAuthority.ts new file mode 100644 index 0000000..af7487c --- /dev/null +++ b/src/cloud/inviteAuthority.ts @@ -0,0 +1,109 @@ +import { + identityInviteCapabilityPrefix, + identityInviteSecretLength, + isIdentityInviteCapability, +} from "./authCredentials"; + +export type InvitePurpose = "device" | "identity"; + +export const invitePublicIdPrefix = "invite_"; +export const deviceInviteCapabilityPrefix = "hra_invite_device_v1_"; +export const identityInviteLifetimeMs = 24 * 60 * 60 * 1_000; + +const invitePublicIdPattern = /^invite_[A-Za-z0-9_-]{32}$/u; +const deviceInviteCapabilityPattern = + /^hra_invite_device_v1_[A-Za-z0-9_-]{43}$/u; +const authDigestPattern = /^[a-f0-9]{64}$/u; +const base64UrlAlphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +function encodeBase64Url(bytes: Uint8Array): string { + let encoded = ""; + let buffer = 0; + let bits = 0; + for (const byte of bytes) { + buffer = (buffer << 8) | byte; + bits += 8; + while (bits >= 6) { + bits -= 6; + encoded += base64UrlAlphabet.charAt((buffer >>> bits) & 63); + buffer &= (1 << bits) - 1; + } + } + if (bits > 0) encoded += base64UrlAlphabet.charAt((buffer << (6 - bits)) & 63); + return encoded; +} + +function toHex(bytes: ArrayBuffer): string { + return [...new Uint8Array(bytes)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +export function isInvitePublicId(value: unknown): value is string { + return typeof value === "string" && invitePublicIdPattern.test(value); +} + +export function isInviteCapability( + value: unknown, + purpose: InvitePurpose, +): value is string { + return purpose === "identity" + ? isIdentityInviteCapability(value) + : typeof value === "string" && deviceInviteCapabilityPattern.test(value); +} + +export async function digestInviteCapability( + capability: string, + purpose: InvitePurpose, +): Promise { + if (!isInviteCapability(capability, purpose)) { + throw new Error("invalid invite capability"); + } + const bytes = new TextEncoder().encode( + `hra-control-plane-invite-capability:v1:${purpose}:${capability}`, + ); + return toHex(await crypto.subtle.digest("SHA-256", bytes)); +} + +export function invitePublicIdFromCapabilityDigest( + capabilityDigest: string, +): string { + if (!authDigestPattern.test(capabilityDigest)) { + throw new Error("invalid invite capability digest"); + } + const publicIdBytes = new Uint8Array(24); + for (let index = 0; index < publicIdBytes.length; index += 1) { + publicIdBytes[index] = Number.parseInt( + capabilityDigest.slice(index * 2, index * 2 + 2), + 16, + ); + } + const encoded = encodeBase64Url(publicIdBytes); + if (encoded.length !== 32) throw new Error("invalid invite public identity"); + return `${invitePublicIdPrefix}${encoded}`; +} + +export async function generateInviteAuthority( + purpose: InvitePurpose, +): Promise> { + const capabilitySecret = encodeBase64Url( + crypto.getRandomValues(new Uint8Array(32)), + ); + if (capabilitySecret.length !== identityInviteSecretLength) { + throw new Error("invalid invite entropy"); + } + const capability = `${purpose === "identity" + ? identityInviteCapabilityPrefix + : deviceInviteCapabilityPrefix}${capabilitySecret}`; + const capabilityDigest = await digestInviteCapability(capability, purpose); + return { + capability, + capabilityDigest, + publicId: invitePublicIdFromCapabilityDigest(capabilityDigest), + }; +} diff --git a/src/codex/client.test.ts b/src/codex/client.test.ts index d422a6f..955d143 100644 --- a/src/codex/client.test.ts +++ b/src/codex/client.test.ts @@ -147,6 +147,114 @@ async function waitFor(predicate: () => boolean): Promise { } describe("CodexAppServerClient", () => { + test("preflights both effective credential stores before becoming available", async () => { + const codexHome = "/tmp/hra-control-plane/profile-a/codex-home"; + let configReads = 0; + const process = new FakeProcess((message, runtime) => { + if (message.method === "initialize") { + runtime.respond({ + id: message.id, + result: { + userAgent: "codex-cli/0.149.0", + codexHome, + platformFamily: "unix", + platformOs: "macos", + }, + }); + } else if (message.method === "config/read") { + expect(message.params).toEqual({ + cwd: configReads === 0 + ? "/private/tmp/hra-acceptance/project-a" + : "/private/tmp/hra-acceptance/project-b", + includeLayers: false, + }); + configReads += 1; + runtime.respond({ + id: message.id, + result: { + config: { + cli_auth_credentials_store: "file", + mcp_oauth_credentials_store: "file", + }, + origins: {}, + }, + }); + } + }); + const client = new CodexAppServerClient({ + process, + authority: { profileId: "profile-a", processGeneration: 7 }, + credentialStorePreflight: { + cliAuth: "file", + cwd: "/private/tmp/hra-acceptance/project-a", + mcpOauth: "file", + }, + expectedCodexHome: codexHome, + isAuthorityCurrent: () => true, + }); + + await expect(client.initialize()).resolves.toMatchObject({ + authority: { profileId: "profile-a", processGeneration: 7 }, + }); + await expect(client.assertCredentialStores( + "/private/tmp/hra-acceptance/project-b", + )).resolves.toBeUndefined(); + expect(process.writes).toContainEqual({ + id: 2, + method: "config/read", + params: { + cwd: "/private/tmp/hra-acceptance/project-a", + includeLayers: false, + }, + }); + expect(configReads).toBe(2); + await client.close(); + }); + + test("fails closed when an effective credential store is not file-backed", async () => { + const codexHome = "/tmp/hra-control-plane/profile-a/codex-home"; + const process = new FakeProcess((message, runtime) => { + if (message.method === "initialize") { + runtime.respond({ + id: message.id, + result: { + userAgent: "codex-cli/0.149.0", + codexHome, + platformFamily: "unix", + platformOs: "macos", + }, + }); + } else if (message.method === "config/read") { + runtime.respond({ + id: message.id, + result: { + config: { + cli_auth_credentials_store: "file", + mcp_oauth_credentials_store: "keyring", + }, + origins: {}, + }, + }); + } + }); + const client = new CodexAppServerClient({ + process, + authority: { profileId: "profile-a", processGeneration: 7 }, + credentialStorePreflight: { + cliAuth: "file", + cwd: "/private/tmp/hra-acceptance/project-a", + mcpOauth: "file", + }, + expectedCodexHome: codexHome, + isAuthorityCurrent: () => true, + }); + + const error = await client.initialize().catch((caught: unknown) => caught); + expect(error).toMatchObject({ code: "RUNTIME_MISMATCH" }); + expect(client.state).toBe("failed"); + expect(process.signals).toEqual(["SIGTERM"]); + }); + test("sends the exact pinned login cancellation authority", async () => { const codexHome = "/tmp/hra-control-plane/profile-a/codex-home"; const process = new FakeProcess((message, runtime) => { diff --git a/src/codex/client.ts b/src/codex/client.ts index bc37fbf..40fccea 100644 --- a/src/codex/client.ts +++ b/src/codex/client.ts @@ -30,6 +30,7 @@ import { parseAccountUsage, parseAppPage, parseBrokeredCodexServerRequest, + parseCredentialStores, parseFact, parseFeaturePage, parseInitialize, @@ -114,6 +115,11 @@ export interface CodexAppServerClientOptions { readonly process: CodexProcess; readonly authority: CodexAuthority; readonly expectedCodexHome: string; + readonly credentialStorePreflight?: Readonly<{ + readonly cliAuth: "file"; + readonly cwd: string; + readonly mcpOauth: "file"; + }>; readonly experimentalApi?: boolean; readonly isAuthorityCurrent: (authority: CodexAuthority) => boolean | Promise; readonly onFact?: (fact: FencedCodexValue) => void | Promise; @@ -181,6 +187,7 @@ export class CodexAppServerClient { readonly #process: CodexProcess; readonly #authority: CodexAuthority; readonly #expectedCodexHome: string; + readonly #credentialStorePreflight: CodexAppServerClientOptions["credentialStorePreflight"]; readonly #experimentalApi: boolean; readonly #isAuthorityCurrent: CodexAppServerClientOptions["isAuthorityCurrent"]; readonly #onFact: NonNullable; @@ -210,6 +217,13 @@ export class CodexAppServerClient { throw new CodexError("INVALID_INPUT", "expected CODEX_HOME must be absolute"); } this.#expectedCodexHome = resolve(options.expectedCodexHome); + this.#credentialStorePreflight = options.credentialStorePreflight === undefined + ? undefined + : { + cliAuth: options.credentialStorePreflight.cliAuth, + cwd: canonicalAbsolute(options.credentialStorePreflight.cwd, "credential-store preflight cwd"), + mcpOauth: options.credentialStorePreflight.mcpOauth, + }; this.#experimentalApi = options.experimentalApi ?? false; this.#isAuthorityCurrent = options.isAuthorityCurrent; this.#onFact = options.onFact ?? (() => undefined); @@ -291,6 +305,9 @@ export class CodexAppServerClient { await this.#writeFrame({ method: "initialized" }); await this.#assertAuthority(); this.#state = "ready"; + if (this.#credentialStorePreflight !== undefined) { + await this.assertCredentialStores(this.#credentialStorePreflight.cwd); + } void this.#enqueueFact({ type: "providerConnected", connectionId: this.#connectionId, @@ -313,6 +330,28 @@ export class CodexAppServerClient { return this.#closedRequest("account/read", { refreshToken }, parseAccountRead); } + /** Rechecks project-layer effective custody before a project-scoped effect. */ + async assertCredentialStores(cwd: string): Promise { + if (this.#credentialStorePreflight === undefined) return; + const stores = await this.#closedRequest( + "config/read", + { + includeLayers: false, + cwd: canonicalAbsolute(cwd, "credential-store preflight cwd"), + }, + parseCredentialStores, + ); + if ( + stores.value.cliAuth !== this.#credentialStorePreflight.cliAuth + || stores.value.mcpOauth !== this.#credentialStorePreflight.mcpOauth + ) { + throw new CodexError( + "RUNTIME_MISMATCH", + "Codex did not apply the required file-backed credential stores", + ); + } + } + async startManagedLogin(mode: "browser" | "device-code"): Promise> { const params = mode === "browser" diff --git a/src/codex/index.ts b/src/codex/index.ts index 0ac6f13..35c7569 100644 --- a/src/codex/index.ts +++ b/src/codex/index.ts @@ -38,6 +38,7 @@ export type { CodexApp, CodexAuthority, CodexCapabilitySnapshot, + CodexCredentialStores, CodexFact, CodexFeature, CodexMethod, diff --git a/src/codex/protocol.ts b/src/codex/protocol.ts index 5be696b..2fe6ba4 100644 --- a/src/codex/protocol.ts +++ b/src/codex/protocol.ts @@ -238,6 +238,7 @@ export type CodexMethod = | "account/rateLimits/read" | "account/usage/read" | "app/list" + | "config/read" | "experimentalFeature/list" | "initialize" | "model/list" @@ -256,6 +257,7 @@ export type CodexMethod = export const OPERATIONS: Readonly> = { initialize: operation("initialize", "read", 10_000, "retry-read"), + "config/read": operation("config/read", "read", 10_000, "retry-read"), "account/read": operation("account/read", "read", 10_000, "retry-read"), "account/login/cancel": operation("account/login/cancel", "auth", 10_000, "retry-read"), "account/login/start": operation("account/login/start", "auth", 20_000, "reconcile"), @@ -312,6 +314,11 @@ export interface InitializeResult { readonly platformOs: string; } +export interface CodexCredentialStores { + readonly cliAuth: "file" | "keyring" | "auto" | "ephemeral"; + readonly mcpOauth: "file" | "keyring" | "auto"; +} + export type CodexAccount = | { readonly type: "chatgpt"; readonly email: string | null; readonly planType: string } | { readonly type: "apiKey" } @@ -772,6 +779,23 @@ export function parseInitialize(value: unknown): InitializeResult { }; } +/** Closed projection of the two credential-store settings used by acceptance. */ +export function parseCredentialStores(value: unknown): CodexCredentialStores { + const config = record(record(value, "config/read result").config, "config/read config"); + return { + cliAuth: oneOf( + config.cli_auth_credentials_store, + "config/read cli_auth_credentials_store", + ["file", "keyring", "auto", "ephemeral"] as const, + ), + mcpOauth: oneOf( + config.mcp_oauth_credentials_store, + "config/read mcp_oauth_credentials_store", + ["file", "keyring", "auto"] as const, + ), + }; +} + export function parseAccountRead(value: unknown): AccountReadResult { const root = record(value, "account/read result"); return { diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index 6ae0b53..5552ab3 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -123,6 +123,9 @@ export async function launchPinnedCodexAppServer( authority: options.authority, expectedCodexHome: options.expectedCodexHome, isAuthorityCurrent: options.isAuthorityCurrent, + ...(options.credentialStorePreflight === undefined + ? {} + : { credentialStorePreflight: options.credentialStorePreflight }), ...(options.experimentalApi === undefined ? {} : { experimentalApi: options.experimentalApi }), diff --git a/src/daemon/codex-runtime-adapter.test.ts b/src/daemon/codex-runtime-adapter.test.ts index 1d315f2..4dbe553 100644 --- a/src/daemon/codex-runtime-adapter.test.ts +++ b/src/daemon/codex-runtime-adapter.test.ts @@ -57,6 +57,60 @@ const makeThread = (turns: readonly CodexTurn[]): CodexThread => ({ }); describe("PinnedCodexRuntimeManager", () => { + test("prepares each isolated Codex home and threads its bounded launch policy", async () => { + const steps: string[] = []; + let launched: LaunchPinnedCodexOptions | undefined; + const fake = { + state: "ready", + accountRead: async () => ({ + authority: { profileId: authority.id, processGeneration: authority.generation }, + value: { account: null, requiresOpenaiAuth: true }, + }), + close: async () => undefined, + } as unknown as CodexAppServerClient; + const manager = new PinnedCodexRuntimeManager({ + codexEnvironment: async (codexHome) => { + steps.push(`environment:${codexHome}`); + return { HOME: "/Users/person", TMPDIR: `${codexHome}/tmp` }; + }, + credentialStorePreflight: { + cliAuth: "file", + cwd: "/private/tmp/hra-acceptance/project-a", + mcpOauth: "file", + }, + isCurrent: () => true, + launchClient: async (options) => { + steps.push("launch"); + launched = options; + return fake; + }, + observer: { account: () => undefined, fact: () => undefined }, + prepareCodexHome: async (codexHome) => { + steps.push(`prepare:${codexHome}`); + }, + }); + + await manager.readAccount({ authority, signal: new AbortController().signal }); + expect(steps).toEqual([ + `prepare:${authority.codexHome}`, + `environment:${authority.codexHome}`, + "launch", + ]); + expect(launched).toMatchObject({ + credentialStorePreflight: { + cliAuth: "file", + cwd: "/private/tmp/hra-acceptance/project-a", + mcpOauth: "file", + }, + environment: { + HOME: "/Users/person", + TMPDIR: `${authority.codexHome}/tmp`, + }, + expectedCodexHome: authority.codexHome, + }); + await manager.close(); + }); + test("preserves the provider login ID and cancels only that exact current-generation login", async () => { const canceled: string[] = []; const fake = { diff --git a/src/daemon/codex-runtime-adapter.ts b/src/daemon/codex-runtime-adapter.ts index c264676..0d5e3e8 100644 --- a/src/daemon/codex-runtime-adapter.ts +++ b/src/daemon/codex-runtime-adapter.ts @@ -662,15 +662,41 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { readonly #isCurrent: (authority: ProfileAuthority) => boolean; readonly #observer: CodexRuntimeObserver; readonly #launchClient: typeof launchPinnedCodexAppServer; + readonly #prepareCodexHome: ((codexHome: string) => Promise) | undefined; + readonly #codexEnvironment: (( + codexHome: string, + ) => Promise> | undefined>) | undefined; + readonly #credentialStorePreflight: Readonly<{ + readonly cliAuth: "file"; + readonly cwd: string; + readonly mcpOauth: "file"; + }> | null; readonly #now: () => number; #usageRevision = Date.now(); #state: "open" | "closing" | "closed" = "open"; #closeTask: Promise | undefined; - constructor(input: { isCurrent: (authority: ProfileAuthority) => boolean; observer: CodexRuntimeObserver; launchClient?: typeof launchPinnedCodexAppServer; now?: () => number }) { + constructor(input: { + isCurrent: (authority: ProfileAuthority) => boolean; + observer: CodexRuntimeObserver; + launchClient?: typeof launchPinnedCodexAppServer; + prepareCodexHome?: (codexHome: string) => Promise; + codexEnvironment?: ( + codexHome: string, + ) => Promise> | undefined>; + credentialStorePreflight?: Readonly<{ + readonly cliAuth: "file"; + readonly cwd: string; + readonly mcpOauth: "file"; + }>; + now?: () => number; + }) { this.#isCurrent = input.isCurrent; this.#observer = input.observer; this.#launchClient = input.launchClient ?? launchPinnedCodexAppServer; + this.#prepareCodexHome = input.prepareCodexHome; + this.#codexEnvironment = input.codexEnvironment; + this.#credentialStorePreflight = input.credentialStorePreflight ?? null; this.#now = input.now ?? Date.now; } @@ -728,7 +754,13 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { }): Promise { return await this.#admit(async () => { if (input.signal.aborted) throw input.signal.reason; - const catalog = await (await this.#client(input.authority)).listPlugins({ + const client = await this.#client(input.authority); + if (this.#credentialStorePreflight !== null) { + await client.assertCredentialStores( + input.projectRoot ?? this.#credentialStorePreflight.cwd, + ); + } + const catalog = await client.listPlugins({ ...(input.projectRoot === undefined ? {} : { cwd: input.projectRoot }), forceRefetch: input.forceRefetch, }); @@ -1492,9 +1524,19 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { this.#clients.delete(authority.id); await existing.client.close(); } + if (this.#prepareCodexHome !== undefined) { + await this.#prepareCodexHome(authority.codexHome); + } + const environment = this.#codexEnvironment === undefined + ? undefined + : await this.#codexEnvironment(authority.codexHome); const client = await this.#launchClient({ authority: { profileId: authority.id, processGeneration: authority.generation }, expectedCodexHome: authority.codexHome, + ...(environment === undefined ? {} : { environment }), + ...(this.#credentialStorePreflight === null + ? {} + : { credentialStorePreflight: this.#credentialStorePreflight }), experimentalApi: true, isAuthorityCurrent: () => this.#isCurrent(authority), onFact: async (value: FencedCodexValue) => { @@ -1568,6 +1610,9 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { preset: ResolvedPreset; profile: EffectiveRuntimeProfile; }> { + if (this.#credentialStorePreflight !== null) { + await running.client.assertCredentialStores(cwd); + } const capabilities = await running.client.discoverCapabilities({ cwd, ...(threadId === undefined ? {} : { threadId }), includeExperimental: true }); const preset = running.client.resolvePreset(capabilities, alias, fast); const profile = compileEffectiveRuntimeProfile({ diff --git a/src/installation.test.ts b/src/installation.test.ts new file mode 100644 index 0000000..3ab5f74 --- /dev/null +++ b/src/installation.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + assertInstallationHome, + createProductionInstallation, +} from "./installation"; +import { + createAcceptanceInstallation, + type AcceptanceInstallationDescriptor, +} from "../scripts/live-acceptance-installation"; +import { resolveStatePaths } from "./storage/paths"; + +const roots: string[] = []; +const ACCEPTANCE_RUN_ID = "018f1f55-3f10-7c1a-8f7b-c6dc608bcd3b"; + +afterEach(async () => { + await Promise.all(roots.splice(0).map(async (root) => { + await rm(root, { force: true, recursive: true }); + })); +}); + +async function acceptanceDescriptor( + device: "a" | "b" = "a", +): Promise { + const runRoot = await realpath( + await mkdtemp(join(tmpdir(), `hra-live-acceptance-${ACCEPTANCE_RUN_ID}-`)), + ); + roots.push(runRoot); + const documentsDirectory = join(runRoot, `project-${device}-fixture`); + await mkdir(documentsDirectory, { mode: 0o700 }); + const expectedHomeDirectory = process.env.HOME; + if (expectedHomeDirectory === undefined) throw new Error("Test requires HOME."); + return { + device, + documentsDirectory, + expectedHomeDirectory, + rootDirectory: join(runRoot, `device-${device}-fixture`), + runId: ACCEPTANCE_RUN_ID, + type: "hra-live-acceptance-device", + version: 1, + }; +} + +describe("HRA installation composition", () => { + test("keeps the production namespace fixed", () => { + const installation = createProductionInstallation(); + expect(installation.kind).toBe("production"); + expect(installation.desktopSwitching).toBe(true); + expect(installation.credentialStorePreflight).toBeNull(); + expect(installation.paths).toEqual(resolveStatePaths()); + }); + + test("uses only file-backed custody and an isolated process temp directory", async () => { + const descriptor = await acceptanceDescriptor(); + const installation = createAcceptanceInstallation(descriptor); + await installation.prepareCodexHome(join(installation.paths.profiles, "profile-a", "codex-home")); + const custody = installation.createSecretCustody(); + await expect(custody.compareAndSwap("device-secret", null, "secret-value")).resolves.toEqual({ + generation: 0, + value: "secret-value", + }); + + const configPath = join( + installation.paths.profiles, + "profile-a", + "codex-home", + "config.toml", + ); + expect(await readFile(configPath, "utf8")).toBe([ + 'cli_auth_credentials_store = "file"', + 'mcp_oauth_credentials_store = "file"', + "", + ].join("\n")); + expect((await lstat(configPath)).mode & 0o777).toBe(0o600); + expect((await readdir(join(installation.paths.root, "secret-values"))).length).toBe(1); + + const environment = await installation.codexEnvironment( + join(installation.paths.profiles, "profile-a", "codex-home"), + ); + expect(environment?.HOME).toBe(process.env.HOME); + expect(environment?.TMPDIR).toBe( + join(installation.paths.profiles, "profile-a", "codex-home", "tmp"), + ); + expect(environment?.CODEX_HOME).toBeUndefined(); + expect(environment?.HRA_CONVEX_URL).toBeUndefined(); + expect(installation.desktopSwitching).toBe(false); + expect(installation.credentialStorePreflight).toEqual({ + cliAuth: "file", + cwd: descriptor.documentsDirectory, + mcpOauth: "file", + }); + }); + + test("refuses a changed Codex credential-store file on restart", async () => { + const installation = createAcceptanceInstallation(await acceptanceDescriptor()); + const codexHome = join(installation.paths.profiles, "profile-a", "codex-home"); + await installation.prepareCodexHome(codexHome); + const configPath = join(codexHome, "config.toml"); + await writeFile(configPath, 'mcp_oauth_credentials_store = "keyring"\n'); + await chmod(configPath, 0o600); + + await expect(installation.prepareCodexHome(codexHome)).rejects.toThrow( + "unexpected credential-store configuration", + ); + }); + + test("rejects unbounded roots, unknown descriptor fields, and HOME changes", async () => { + const descriptor = await acceptanceDescriptor(); + expect(() => createAcceptanceInstallation({ + ...descriptor, + rootDirectory: join(descriptor.rootDirectory, "nested"), + })).toThrow("direct child"); + expect(() => createAcceptanceInstallation({ + ...descriptor, + extra: true, + } as AcceptanceInstallationDescriptor)).toThrow(); + + const installation = createAcceptanceInstallation({ + ...descriptor, + expectedHomeDirectory: join(descriptor.expectedHomeDirectory, "changed"), + }); + expect(() => assertInstallationHome(installation)).toThrow("preserve the invoking HOME"); + }); +}); diff --git a/src/installation.ts b/src/installation.ts new file mode 100644 index 0000000..ca226fa --- /dev/null +++ b/src/installation.ts @@ -0,0 +1,64 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { resolveStatePaths, type StatePaths } from "./storage/paths"; +import { GenerationalSecretCustody } from "./storage/secret-custody"; + +type HraInstallationCommon = Readonly<{ + cloudEnvironment: Readonly<{ HRA_CONVEX_URL?: string }>; + codexEnvironment(codexHome: string): Promise> | undefined>; + documentsDirectory: string; + paths: StatePaths; + createSecretCustody(): GenerationalSecretCustody; + prepareCodexHome(codexHome: string): Promise; +}>; + +export type HraInstallation = HraInstallationCommon & ( + | Readonly<{ + credentialStorePreflight: null; + desktopSwitching: true; + expectedHomeDirectory: null; + kind: "production"; + }> + | Readonly<{ + credentialStorePreflight: Readonly<{ + readonly cliAuth: "file"; + readonly cwd: string; + readonly mcpOauth: "file"; + }>; + desktopSwitching: false; + expectedHomeDirectory: string; + kind: "live_acceptance"; + }> +); + +const noOpPrepareCodexHome = (): Promise => Promise.resolve(); +const defaultCodexEnvironment = (): Promise => Promise.resolve(undefined); + +export function createProductionInstallation(): HraInstallation { + const paths = resolveStatePaths(); + const cloudDeploymentUrl = process.env.HRA_CONVEX_URL; + return { + cloudEnvironment: cloudDeploymentUrl === undefined + ? {} + : { HRA_CONVEX_URL: cloudDeploymentUrl }, + codexEnvironment: defaultCodexEnvironment, + credentialStorePreflight: null, + createSecretCustody: () => new GenerationalSecretCustody(paths), + desktopSwitching: true, + documentsDirectory: join(homedir(), "Documents"), + expectedHomeDirectory: null, + kind: "production", + paths, + prepareCodexHome: noOpPrepareCodexHome, + }; +} + +export function assertInstallationHome(installation: HraInstallation): void { + if ( + installation.expectedHomeDirectory !== null + && process.env.HOME !== installation.expectedHomeDirectory + ) { + throw new Error("Live acceptance must preserve the invoking HOME exactly."); + } +}